diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cbc073138102fbfdb05cdeacd50eaef1d24426..d1d708419a8576ab7e700ee6c8dcd423b92df455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- Replaced the JavaFX-based 3D view with an OpenGL renderer (openglfx/JOGL) for smooth display of + large city models. Picking is now GPU-based; the 3D view requires OpenGL 3.3 (Windows/Linux). + ## [3.18.3] (2026-04-16) ### Fixes diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml index c7bfddab8c995ee1f1d3904d32fa0aa8eda5b98d..e9d152c9c2f5e03ce00d618a680092d3e9f620ce 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml +++ b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml @@ -59,6 +59,24 @@ org.openjfx javafx-swing + + + com.huskerdev + openglfx-jogl + 4.1.19 + + + + org.jogamp.jogl + jogl-all-main + + + org.jogamp.gluegen + gluegen-rt-main + + diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/CityDoctorController.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/CityDoctorController.java index 57849884f35d68170f26d2d4ea5c580719ac3212..f21c5c17cb1a3a9c38655c61525cce4e13b83417 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/CityDoctorController.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/CityDoctorController.java @@ -130,7 +130,7 @@ public class CityDoctorController { mainWindow.getOpenBtn().setDisable(true); mainWindow.getWriteReportButton().setDisable(true); - mainWindow.getMeshGroup().getChildren().clear(); + mainWindow.clearMeshView(); clearTrees(); mainWindow.resetSearchBar(); @@ -1660,7 +1660,7 @@ public class CityDoctorController { } public void errorFilterIndexChanged(Number newV) { - mainWindow.getMeshGroup().getChildren().clear(); + mainWindow.clearMeshView(); mainWindow.unselectEverything(); mainWindow.resetSearchBar(); renderer.clearCurrentRender(); diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/HighlightController.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/HighlightController.java index be979f5b14c2b06420ab187798effdb02d1c35cf..20c2b32e1efed2e5241dcbe2c167384372636e75 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/HighlightController.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/HighlightController.java @@ -1,194 +1,185 @@ package de.hft.stuttgart.citydoctor2.gui; import java.util.List; +import java.util.function.Supplier; import de.hft.stuttgart.citydoctor2.datastructure.Edge; import de.hft.stuttgart.citydoctor2.datastructure.LinearRing; import de.hft.stuttgart.citydoctor2.datastructure.Polygon; import de.hft.stuttgart.citydoctor2.datastructure.Vertex; import de.hft.stuttgart.citydoctor2.datastructure.LinearRing.LinearRingType; +import de.hft.stuttgart.citydoctor2.gui.gl.FloatList; +import de.hft.stuttgart.citydoctor2.gui.gl.GLViewport; import de.hft.stuttgart.citydoctor2.math.Triangle3d; import de.hft.stuttgart.citydoctor2.math.Vector3d; -import javafx.application.Platform; -import javafx.geometry.Point3D; -import javafx.scene.Group; -import javafx.scene.Node; import javafx.scene.paint.Color; -import javafx.scene.paint.PhongMaterial; -import javafx.scene.shape.Cylinder; -import javafx.scene.shape.Sphere; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Translate; +/** + * Accumulates highlight marker geometry (points and lines) and pushes it to the {@link GLViewport} + * overlay. Coordinates are recentred by the current scene center supplied by the {@link Renderer}. + */ public class HighlightController { - - - private double scale; - private Group points; - private Group edges; - - private Group highlights; - - public HighlightController(Group world) { - highlights = new Group(); - points = new Group(); - edges = new Group(); - highlights.getChildren().add(edges); - highlights.getChildren().add(points); - Platform.runLater(() -> world.getChildren().add(highlights)); - } - - public void clearHighlights() { - points.getChildren().clear(); - edges.getChildren().clear(); - } - - public void changeScaling(double translateZ) { - scale = Math.abs(translateZ); - scale = Math.min(150, scale); - scale = Math.max(10, scale); - scale = scale * 0.01; - for (Node n : points.getChildren()) { - n.setScaleX(scale); - n.setScaleY(scale); - n.setScaleZ(scale); - } - double edgeSize = scale / 10; - for (Node n : edges.getChildren()) { - if (n instanceof Cylinder cy) { - cy.setRadius(edgeSize); - } - } - } - - public void highlight(Polygon p, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - addHighlight(p, currentTriGeom, Color.RED, Color.BLUE); - } - - private void addHighlight(Polygon p, TriangulatedGeometry currentTriGeom, Color extColor, Color intColor) { - if (currentTriGeom == null || p == null) { - return; - } - addHighlight(p.getExteriorRing(), currentTriGeom, extColor); - for (LinearRing intRing : p.getInnerRings()) { - addHighlight(intRing, currentTriGeom, intColor); - } - } - - public void highlight(LinearRing ring, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - if (ring.getType() == LinearRingType.EXTERIOR) { - addHighlight(ring, currentTriGeom, Color.RED); - } else { - addHighlight(ring, currentTriGeom, Color.BLUE); - } - } - - public void highlight(List rings, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - for (LinearRing lr : rings) { - if (lr.getType() == LinearRingType.EXTERIOR) { - addHighlight(lr, currentTriGeom, Color.RED); - } else { - addHighlight(lr, currentTriGeom, Color.BLUE); - } - } - } - - private void addHighlight(LinearRing ring, TriangulatedGeometry currentTriGeom, Color pointColor) { - if (currentTriGeom == null || ring == null) { - return; - } - Vector3d movedBy = currentTriGeom.getMovedBy(); - for (Vertex v : ring.getVertices()) { - highlightPoint(movedBy, v, pointColor); - } - for (int i = 0; i < ring.getVertices().size() - 1; i++) { - Vertex v1 = ring.getVertices().get(i); - Vertex v2 = ring.getVertices().get(i + 1); - highlightEdge(v1, v2, movedBy); - } - } - - public void highlight(Edge e, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - addHighlight(e, currentTriGeom); - } - - private void addHighlight(Edge e, TriangulatedGeometry currentTriGeom) { - if (currentTriGeom == null || e == null) { - return; - } - Vector3d movedBy = currentTriGeom.getMovedBy(); - highlightEdge(e.getFrom(), e.getTo(), movedBy); - highlightPoint(movedBy, e.getFrom(), Color.RED); - highlightPoint(movedBy, e.getTo(), Color.RED); - } - - public void highlight(Vertex v, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - addHighlight(v, currentTriGeom, Color.RED); - } - - public void addHighlight(Vertex v, TriangulatedGeometry currentTriGeom, Color c) { - if (currentTriGeom == null || v == null) { - return; - } - Vector3d movedBy = currentTriGeom.getMovedBy(); - highlightPoint(movedBy, v, c); - } - - private void highlightPoint(Vector3d movedBy, Vertex v, Color color) { - Sphere sp = new Sphere(0.5); - sp.setMaterial(new PhongMaterial(color)); - sp.setTranslateX(v.getX() - movedBy.getX()); - sp.setTranslateY(v.getY() - movedBy.getY()); - sp.setTranslateZ(v.getZ() - movedBy.getZ()); - sp.setScaleX(scale); - sp.setScaleY(scale); - sp.setScaleZ(scale); - - sp.setUserData(new VertexClickDispatcher(v)); - - points.getChildren().add(sp); - } - - private void highlightEdge(Vector3d v1, Vector3d v2, Vector3d movedBy) { - Point3D origin = new Point3D(v1.getX() - movedBy.getX(), v1.getY() - movedBy.getY(), - v1.getZ() - movedBy.getZ()); - Point3D target = new Point3D(v2.getX() - movedBy.getX(), v2.getY() - movedBy.getY(), - v2.getZ() - movedBy.getZ()); - - Point3D yAxis = new Point3D(0, 1, 0); - Point3D diff = target.subtract(origin); - double height = diff.magnitude(); - - Point3D mid = target.midpoint(origin); - Translate moveToMidpoint = new Translate(mid.getX(), mid.getY(), mid.getZ()); - - Point3D axisOfRotation = diff.crossProduct(yAxis); - double angle = Math.acos(diff.normalize().dotProduct(yAxis)); - Rotate rotateAroundCenter = new Rotate(-Math.toDegrees(angle), axisOfRotation); - - Cylinder cy = new Cylinder(scale / 10, height); - cy.setMaterial(new PhongMaterial(Color.ORANGE)); - cy.getTransforms().addAll(moveToMidpoint, rotateAroundCenter); - edges.getChildren().add(cy); - } - - public void highlightEdges(List errorEdges, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - for (Edge e : errorEdges) { - addHighlight(e, currentTriGeom); - } - } - - public void highlightPolygons(List> components, TriangulatedGeometry currentTriGeom) { - clearHighlights(); - for (int i = 0; i < components.size(); i++) { - Color extColor; - Color intColor = switch (i % 3) { + + private final GLViewport viewport; + private Supplier centerSupplier; + + private final FloatList points = new FloatList(); + private final FloatList pointColors = new FloatList(); + private final FloatList lines = new FloatList(); + private final FloatList lineColors = new FloatList(); + + public HighlightController(GLViewport viewport) { + this.viewport = viewport; + } + + public void setCenterSupplier(Supplier centerSupplier) { + this.centerSupplier = centerSupplier; + } + + private double[] center() { + if (centerSupplier == null) { + return new double[3]; + } + double[] c = centerSupplier.get(); + return c == null ? new double[3] : c; + } + + private void resetBuffers() { + points.clear(); + pointColors.clear(); + lines.clear(); + lineColors.clear(); + } + + public void clearHighlights() { + resetBuffers(); + push(); + } + + private void push() { + viewport.setHighlights(points.toArray(), pointColors.toArray(), lines.toArray(), lineColors.toArray()); + } + + public void changeScaling(double translateZ) { + // GL points/lines are sized in screen pixels, so highlight markers keep a constant on-screen + // size regardless of zoom distance; nothing to recompute here. + } + + // ---- point/line accumulation ---- + + private void addPoint(Vertex v, Color color) { + double[] c = center(); + points.add((float) (v.getX() - c[0]), (float) (v.getY() - c[1]), (float) (v.getZ() - c[2])); + pointColors.add((float) color.getRed(), (float) color.getGreen(), (float) color.getBlue()); + } + + private void addLine(Vector3d v1, Vector3d v2, Color color) { + double[] c = center(); + lines.add((float) (v1.getX() - c[0]), (float) (v1.getY() - c[1]), (float) (v1.getZ() - c[2])); + lines.add((float) (v2.getX() - c[0]), (float) (v2.getY() - c[1]), (float) (v2.getZ() - c[2])); + float r = (float) color.getRed(); + float g = (float) color.getGreen(); + float b = (float) color.getBlue(); + lineColors.add(r, g, b); + lineColors.add(r, g, b); + } + + // ---- public highlight API ---- + + public void highlight(Polygon p) { + resetBuffers(); + addHighlight(p, Color.RED, Color.BLUE); + push(); + } + + private void addHighlight(Polygon p, Color extColor, Color intColor) { + if (p == null) { + return; + } + addHighlight(p.getExteriorRing(), extColor); + for (LinearRing intRing : p.getInnerRings()) { + addHighlight(intRing, intColor); + } + } + + public void highlight(LinearRing ring) { + resetBuffers(); + if (ring.getType() == LinearRingType.EXTERIOR) { + addHighlight(ring, Color.RED); + } else { + addHighlight(ring, Color.BLUE); + } + push(); + } + + public void highlight(List rings) { + resetBuffers(); + for (LinearRing lr : rings) { + if (lr.getType() == LinearRingType.EXTERIOR) { + addHighlight(lr, Color.RED); + } else { + addHighlight(lr, Color.BLUE); + } + } + push(); + } + + private void addHighlight(LinearRing ring, Color pointColor) { + if (ring == null) { + return; + } + for (Vertex v : ring.getVertices()) { + addPoint(v, pointColor); + } + for (int i = 0; i < ring.getVertices().size() - 1; i++) { + Vertex v1 = ring.getVertices().get(i); + Vertex v2 = ring.getVertices().get(i + 1); + addLine(v1, v2, Color.ORANGE); + } + } + + public void highlight(Edge e) { + resetBuffers(); + addHighlight(e); + push(); + } + + private void addHighlight(Edge e) { + if (e == null) { + return; + } + addLine(e.getFrom(), e.getTo(), Color.ORANGE); + addPoint(e.getFrom(), Color.RED); + addPoint(e.getTo(), Color.RED); + } + + public void highlight(Vertex v) { + resetBuffers(); + addPoint(v, Color.RED); + push(); + } + + public void addHighlight(Vertex v, Color c) { + if (v == null) { + return; + } + addPoint(v, c); + push(); + } + + public void highlightEdges(List errorEdges) { + resetBuffers(); + for (Edge e : errorEdges) { + addHighlight(e); + } + push(); + } + + public void highlightPolygons(List> components) { + resetBuffers(); + for (int i = 0; i < components.size(); i++) { + Color extColor; + Color intColor = switch (i % 3) { case 1 -> { extColor = Color.GREEN; yield Color.YELLOW; @@ -202,21 +193,24 @@ public class HighlightController { yield Color.BLUE; } }; - // select some color pairs for exterior and inner rings + // select some color pairs for exterior and inner rings List component = components.get(i); - for (Polygon p : component) { - addHighlight(p, currentTriGeom, extColor, intColor); - } - } - } - - public void addHighlight(Polygon p, TriangulatedGeometry currentTriGeom) { - addHighlight(p, currentTriGeom, Color.RED, Color.BLUE); - } - - public void highlight(Triangle3d t, TriangulatedGeometry currentTriGeom) { - highlightEdge(t.getP1(), t.getP2(), currentTriGeom.getMovedBy()); - highlightEdge(t.getP2(), t.getP3(), currentTriGeom.getMovedBy()); - highlightEdge(t.getP3(), t.getP1(), currentTriGeom.getMovedBy()); - } + for (Polygon p : component) { + addHighlight(p, extColor, intColor); + } + } + push(); + } + + public void addHighlight(Polygon p) { + addHighlight(p, Color.RED, Color.BLUE); + push(); + } + + public void highlight(Triangle3d t) { + addLine(t.getP1(), t.getP2(), Color.ORANGE); + addLine(t.getP2(), t.getP3(), Color.ORANGE); + addLine(t.getP3(), t.getP1(), Color.ORANGE); + push(); + } } diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ListErrorVisitor.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ListErrorVisitor.java index 8412762c26ea8bfe6bba4cdd7b5a79e39279223b..0a999f7b4b908c8517215b291a7dd2c92494245c 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ListErrorVisitor.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ListErrorVisitor.java @@ -55,32 +55,27 @@ import javafx.scene.paint.Color; public class ListErrorVisitor implements ErrorVisitor { private HighlightController controller; - private TriangulatedGeometry geom; - + public ListErrorVisitor(HighlightController controller) { this.controller = controller; } - - public void setGeometry(TriangulatedGeometry geom) { - this.geom = geom; - } @Override public void visit(PolygonHoleOutsideError err) { List highlightedRings = new ArrayList<>(); highlightedRings.add(err.getPolygon().getExteriorRing()); highlightedRings.addAll(err.getHolesOutside()); - controller.highlight(highlightedRings, geom); + controller.highlight(highlightedRings); } @Override public void visit(NonManifoldEdgeError err) { - controller.highlightEdges(err.getEdges(), geom); + controller.highlightEdges(err.getEdges()); } @Override public void visit(MultipleConnectedComponentsError err) { - controller.highlightPolygons(err.getComponents(), geom); + controller.highlightPolygons(err.getComponents()); } @Override @@ -88,18 +83,18 @@ public class ListErrorVisitor implements ErrorVisitor { List highlightedRings = new ArrayList<>(); highlightedRings.add(err.getPolygon().getExteriorRing()); highlightedRings.add(err.getInnerRing()); - controller.highlight(highlightedRings, geom); + controller.highlight(highlightedRings); } @Override public void visit(NonManifoldVertexError err) { - controller.highlightPolygons(err.getComponents(), geom); - controller.addHighlight(err.getVertex(), geom, Color.BLUEVIOLET); + controller.highlightPolygons(err.getComponents()); + controller.addHighlight(err.getVertex(), Color.BLUEVIOLET); } @Override public void visit(PolygonWrongOrientationError err) { - controller.highlightEdges(err.getEdges(), geom); + controller.highlightEdges(err.getEdges()); } @Override @@ -107,12 +102,12 @@ public class ListErrorVisitor implements ErrorVisitor { List highlightedRings = new ArrayList<>(); highlightedRings.add(err.getPolygon().getExteriorRing()); highlightedRings.add(err.getInnerRing()); - controller.highlight(highlightedRings, geom); + controller.highlight(highlightedRings); } @Override public void visit(SolidNotClosedError err) { - controller.highlightEdges(err.getErrorEdges(), geom); + controller.highlightEdges(err.getErrorEdges()); } @Override @@ -127,14 +122,14 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(RingNotClosedError err) { - controller.highlight(err.getRing(), geom); + controller.highlight(err.getRing()); } @Override public void visit(ConsecutivePointSameError err) { - controller.highlight(err.getRing(), geom); - controller.addHighlight(err.getVertex1(), geom, Color.BLACK); - controller.addHighlight(err.getVertex2(), geom, Color.BLACK); + controller.highlight(err.getRing()); + controller.addHighlight(err.getVertex1(), Color.BLACK); + controller.addHighlight(err.getVertex2(), Color.BLACK); } @Override @@ -144,7 +139,7 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(PolygonInteriorDisconnectedError err) { - controller.highlight(err.getConnectedRings(), geom); + controller.highlight(err.getConnectedRings()); } @Override @@ -154,18 +149,18 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(RingTooFewPointsError err) { - controller.highlight(err.getRing(), geom); + controller.highlight(err.getRing()); } @Override public void visit(NonPlanarPolygonNormalsDeviation err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override public void visit(NonPlanarPolygonDistancePlaneError err) { - controller.highlight(err.getPolygon(), geom); - controller.addHighlight(err.getVertex(), geom, Color.BLACK); + controller.highlight(err.getPolygon()); + controller.addHighlight(err.getVertex(), Color.BLACK); } @Override @@ -173,15 +168,15 @@ public class ListErrorVisitor implements ErrorVisitor { List rings = new ArrayList<>(); rings.add(err.getIntersectingRings().getValue0()); rings.add(err.getIntersectingRings().getValue1()); - controller.highlight(rings, geom); + controller.highlight(rings); } @Override public void visit(SolidSelfIntError err) { controller.clearHighlights(); for (PolygonIntersection intersection : err.getIntersections()) { - controller.addHighlight(intersection.getP1(), geom); - controller.addHighlight(intersection.getP2(), geom); + controller.addHighlight(intersection.getP1()); + controller.addHighlight(intersection.getP2()); } } @@ -192,9 +187,9 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(RingDuplicatePointError err) { - controller.highlight(err.getRing(), geom); - controller.addHighlight(err.getVertex1(), geom, Color.BLACK); - controller.addHighlight(err.getVertex2(), geom, Color.BLACK); + controller.highlight(err.getRing()); + controller.addHighlight(err.getVertex1(), Color.BLACK); + controller.addHighlight(err.getVertex2(), Color.BLACK); } @Override @@ -202,14 +197,14 @@ public class ListErrorVisitor implements ErrorVisitor { List list = new ArrayList<>(); list.add(err.getEdge1()); list.add(err.getEdge2()); - controller.highlightEdges(list, geom); - controller.addHighlight(new Vertex(err.getIntersection()), geom, Color.BLACK); + controller.highlightEdges(list); + controller.addHighlight(new Vertex(err.getIntersection()), Color.BLACK); } @Override public void visit(PointTouchesEdgeError err) { - controller.highlight(err.getEdge(), geom); - controller.addHighlight(err.getVertex(), geom, Color.BLACK); + controller.highlight(err.getEdge()); + controller.addHighlight(err.getVertex(), Color.BLACK); } @Override @@ -219,22 +214,22 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(NotCeilingError err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override public void visit(NotFloorError err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override public void visit(NotWallError err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override public void visit(NotGroundError err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override @@ -249,7 +244,7 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(DegeneratedRingError err) { - controller.highlight(err.getRing(), geom); + controller.highlight(err.getRing()); } @Override @@ -269,7 +264,7 @@ public class ListErrorVisitor implements ErrorVisitor { @Override public void visit(PolygonWithoutSurfaceError err) { - controller.highlight(err.getPolygon(), geom); + controller.highlight(err.getPolygon()); } @Override diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java index 357f70ecb9b86a886f93ec610acb0ab85629d802..298617963b1b1acc02af11d8a244a920f1cc3f06 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java @@ -31,9 +31,11 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; import javafx.scene.paint.Color; -import javafx.scene.transform.Rotate; import javafx.stage.Stage; +import de.hft.stuttgart.citydoctor2.gui.gl.GLViewport; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -49,10 +51,6 @@ public class MainWindow extends Application { private static final Logger logger = LogManager.getLogger(MainWindow.class); - private static final double CAMERA_TRANSLATE_Z = -100.0; - private static final double CAMERA_INITIAL_X_ANGLE = 20.0; - private static final double CAMERA_INITIAL_Y_ANGLE = 120.0; - @FXML private TreeView buildingsView; @@ -214,19 +212,8 @@ public class MainWindow extends Application { private Image north; private Image northFlip; - private Group meshGroup; - - private Group world; - private PerspectiveCamera camera; - private Rotate cameraXRotation = new Rotate(); - private Rotate cameraZRotation = new Rotate(); + private GLViewport glViewport; - private double dragX; - private double dragY; - - private double cameraXRot = CAMERA_INITIAL_X_ANGLE; - private double cameraYRot = CAMERA_INITIAL_Y_ANGLE; - private double translateZ = CAMERA_TRANSLATE_Z; private double[] clickStart = new double[2]; @FXML @@ -245,7 +232,6 @@ public class MainWindow extends Application { private FeatureType selectedTab = FeatureType.BUILDING; private MainToolBar mainToolBar; - private SubScene geomScene; private ImageView northArrow; private static boolean loadFileAtStartup = false; @@ -259,6 +245,11 @@ public class MainWindow extends Application { private ChangeListener filterChangeListener; public static void main(String[] args) { + // The embedded OpenGL canvas (openglfx) breaks JavaFX's partial-repaint accounting, leaving + // sibling controls (e.g. the log panel) with a stale background. Disabling Prism's dirty-region + // optimization forces a full scene repaint each pulse, which fixes the artifacts. Must be set + // before the JavaFX toolkit initializes. + System.setProperty("prism.dirtyopts", "false"); setLocaleFromSettings(); ArgumentParser argParser = new ArgumentParser(args); inputFile = CityDoctorValidation.getInputFile(argParser, true); @@ -302,7 +293,7 @@ public class MainWindow extends Application { FXMLLoader loader = new FXMLLoader(MainWindow.class.getResource("MainWindow.fxml")); loader.setController(this); BorderPane bp = loader.load(); - highlightController = new HighlightController(world); + highlightController = new HighlightController(glViewport); renderer = new Renderer(this, highlightController); clickHandler = new VertexClickHandler(errorView, renderer, stage, this); controller = new CityDoctorController(this, highlightController, renderer); @@ -845,36 +836,31 @@ public class MainWindow extends Application { } private void setup3dView() { - Group root = new Group(); - geomScene = new SubScene(root, 500, 300, true, SceneAntialiasing.BALANCED); - geomScene.heightProperty().bind(meshView.heightProperty()); - geomScene.widthProperty().bind(meshView.widthProperty()); - geomScene.setFill(Color.AZURE); - meshView.getChildren().add(geomScene); - - geomScene.addEventFilter(MouseEvent.MOUSE_PRESSED, me -> { + glViewport = new GLViewport(); + Region canvas = glViewport.getNode(); + canvas.prefWidthProperty().bind(meshView.widthProperty()); + canvas.prefHeightProperty().bind(meshView.heightProperty()); + meshView.getChildren().add(canvas); + + canvas.addEventFilter(MouseEvent.MOUSE_PRESSED, me -> { clickStart[0] = me.getScreenX(); clickStart[1] = me.getScreenY(); }); - geomScene.addEventFilter(MouseEvent.MOUSE_RELEASED, me -> { + canvas.addEventFilter(MouseEvent.MOUSE_RELEASED, me -> { if (Math.abs(clickStart[0] - me.getScreenX()) > 3 || Math.abs(clickStart[1] - me.getScreenY()) > 3) { // skip when mouse moved too much return; } - Node node = me.getPickResult().getIntersectedNode(); - if (node != null) { - Object o = node.getUserData(); - if (o instanceof ClickDispatcher cd) { - cd.click(me, clickHandler); + glViewport.requestPick(me.getX(), me.getY(), target -> { + if (target instanceof Polygon p) { + new PolygonClickDispatcher(p).click(me, clickHandler); } - } + }); }); - world = new Group(); - root.getChildren().add(world); - meshGroup = new Group(); - world.getChildren().add(meshGroup); + // keep the north arrow aligned with the camera after orbit drags + canvas.addEventFilter(MouseEvent.MOUSE_DRAGGED, me -> alignNorthArrow()); north = new Image(getClass().getResourceAsStream("icons/north.png"), 50, 50, true, true); northFlip = new Image(getClass().getResourceAsStream("icons/north_flip.png"), 50, 50, true, true); @@ -885,81 +871,13 @@ public class MainWindow extends Application { northArrow.setY(10); meshView.getChildren().add(northArrow); northArrow.setVisible(false); - - AmbientLight al = new AmbientLight(Color.WHITE); - root.getChildren().add(al); - buildCamera(); - cameraXRotation.setAxis(Rotate.X_AXIS); - cameraZRotation.setAxis(Rotate.Z_AXIS); - world.getTransforms().add(cameraXRotation); - world.getTransforms().add(cameraZRotation); - root.getChildren().add(camera); - geomScene.setCamera(camera); - - setupMeshViewControls(); - } - - private void setupMeshViewControls() { - meshView.setOnMousePressed(me -> { - if (me.getButton() == MouseButton.PRIMARY) { - dragX = me.getScreenX(); - dragY = me.getScreenY(); - } - if (me.getButton() == MouseButton.SECONDARY) { - dragX = me.getScreenX(); - dragY = me.getScreenY(); - } - }); - - meshView.setOnScroll(se -> { - if (se.getDeltaY() < 0) { - translateZ += translateZ * 0.05; - } else { - translateZ -= translateZ * 0.05; - } - camera.setTranslateZ(translateZ); - highlightController.changeScaling(translateZ); - }); - - meshView.setOnMouseDragged(me -> { - if (me.getButton() == MouseButton.PRIMARY) { - double deltaX = me.getScreenX() - dragX; - double deltaY = me.getScreenY() - dragY; - dragX = me.getScreenX(); - dragY = me.getScreenY(); - - cameraXRot += (deltaX / 3d) % 360; - cameraYRot += (deltaY / 3d) % 360; - - cameraZRotation.setAngle(cameraXRot); - cameraXRotation.setAngle(cameraYRot); - alignNorthArrow(); - } - if (me.getButton() == MouseButton.SECONDARY) { - double translationSpeed = Math.abs(camera.getTranslateZ()) / 1000; - double deltaX = me.getScreenX() - dragX; - double deltaY = me.getScreenY() - dragY; - dragX = me.getScreenX(); - dragY = me.getScreenY(); - - camera.setTranslateX(camera.getTranslateX() - deltaX * translationSpeed); - camera.setTranslateY(camera.getTranslateY() - deltaY * translationSpeed); - } - }); - } - - private void buildCamera() { - camera = new PerspectiveCamera(true); - camera.setNearClip(0.1); - camera.setFarClip(10000d); - camera.setTranslateZ(translateZ); - cameraZRotation.setAngle(cameraXRot); - cameraXRotation.setAngle(cameraYRot); } public void alignNorthArrow() { - double absRotZ = cameraZRotation.getAngle() - 360 * Math.floor(cameraZRotation.getAngle() / 360); - double absRotX = cameraXRotation.getAngle() - 360 * Math.floor(cameraXRotation.getAngle() / 360); + double azimuth = glViewport.camera().azimuthDeg(); + double elevation = glViewport.camera().elevationDeg(); + double absRotZ = azimuth - 360 * Math.floor(azimuth / 360); + double absRotX = elevation - 360 * Math.floor(elevation / 360); double northRot; if (80 <= absRotX && absRotX <= 260) { @@ -978,16 +896,11 @@ public class MainWindow extends Application { } public void resetCamera() { - cameraXRot = CAMERA_INITIAL_X_ANGLE; - cameraYRot = CAMERA_INITIAL_Y_ANGLE; - cameraZRotation.setAngle(cameraXRot); - cameraXRotation.setAngle(cameraYRot); - alignNorthArrow(); - camera.setTranslateX(0); - camera.setTranslateY(0); + glViewport.resetCamera(); if (controller.getOriginBB() != null) { zoomOutForBoundingBox(controller.getOriginBB()); } + alignNorthArrow(); } public void addFileNameToTitle(String fileName) { @@ -1055,8 +968,14 @@ public class MainWindow extends Application { return mainToolBar.getGridButton(); } - public Group getMeshGroup() { - return meshGroup; + public GLViewport getGlViewport() { + return glViewport; + } + + /** Clears whatever geometry is currently displayed in the 3D view. */ + public void clearMeshView() { + glViewport.setScene(null); + glViewport.setIndexSet(new int[0]); } public ToggleButton getCullingButton() { @@ -1137,20 +1056,16 @@ public class MainWindow extends Application { } public void takeViewScreenshot() throws IOException { - WritableImage snapshot = geomScene.snapshot(null, null); + WritableImage snapshot = glViewport.getNode().snapshot(null, null); File outputFile = new File("img.png"); BufferedImage bImage = SwingFXUtils.fromFXImage(snapshot, null); ImageIO.write(bImage, "png", outputFile); } public void zoomOutForBoundingBox(BoundingBox b) { - double longestSide = b.getDiagonalLength() * 0.4; - double d = longestSide / Math.tan(Math.toRadians(30) / 2); - translateZ = -d; - camera.setTranslateZ(translateZ); - camera.setTranslateX(0); - camera.setTranslateY(0); - highlightController.changeScaling(-translateZ); + glViewport.camera().zoomOutForBoundingBox(b.getDiagonalLength()); + glViewport.getNode().repaint(); + highlightController.changeScaling(glViewport.camera().distance()); controller.setOriginBB(b); } diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/Renderer.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/Renderer.java index 7c2279b3a04e39c85a2a047245e2226dc2f909fa..d584bca9acdc4eb75e113595de8370e0f2f7ba51 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/Renderer.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/Renderer.java @@ -4,7 +4,6 @@ import de.hft.stuttgart.citydoctor2.check.CheckError; import de.hft.stuttgart.citydoctor2.check.Checkable; import de.hft.stuttgart.citydoctor2.utils.visitors.CityObjectCollector; import de.hft.stuttgart.citydoctor2.datastructure.*; -import de.hft.stuttgart.citydoctor2.gui.filter.ViewFilter; import de.hft.stuttgart.citydoctor2.gui.tree.*; import de.hft.stuttgart.citydoctor2.gui.tree.node.EdgeNode; import de.hft.stuttgart.citydoctor2.gui.tree.node.ErrorNode; @@ -12,12 +11,15 @@ import de.hft.stuttgart.citydoctor2.gui.tree.node.GenericAttributeNode; import de.hft.stuttgart.citydoctor2.gui.tree.node.LinearRingNode; import de.hft.stuttgart.citydoctor2.gui.tree.node.PolygonNode; import de.hft.stuttgart.citydoctor2.gui.tree.node.VertexNode; +import de.hft.stuttgart.citydoctor2.gui.gl.GLViewport; +import de.hft.stuttgart.citydoctor2.gui.gl.IndexSetBuilder; +import de.hft.stuttgart.citydoctor2.gui.gl.SceneBuilder; +import de.hft.stuttgart.citydoctor2.gui.gl.SceneData; +import de.hft.stuttgart.citydoctor2.gui.gl.TriangleMeta; import de.hft.stuttgart.citydoctor2.math.Triangle3d; import javafx.application.Platform; import javafx.scene.control.TreeItem; import javafx.scene.paint.Color; -import javafx.scene.shape.CullFace; -import javafx.scene.shape.DrawMode; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,11 +30,15 @@ public class Renderer { private static final Logger logger = LogManager.getLogger(Renderer.class); - private TriangulatedGeometry currentTriGeom; + private SceneData currentScene; private Geometry currentGeometry; - private CullFace currentCulling = CullFace.NONE; - private DrawMode currentDrawMode = DrawMode.FILL; + private boolean culling = false; + private boolean wireframe = false; + + // LOD visibility, indexed by Lod.ordinal() (LOD0..LOD4); all enabled by default + private final boolean[] lodEnabled = {true, true, true, true, true}; + private boolean roofsHidden = false; private MainWindow mainWindow; private HighlightController highlightController; @@ -40,8 +46,6 @@ public class Renderer { private LoadingInfoDialog loadingDialog; - private List lodFilters = new ArrayList<>(); - private ViewFilter roofFilter; private Runnable refresher; private Runnable errorUpdater; @@ -50,138 +54,74 @@ public class Renderer { this.highlightController = highlightController; loadingDialog = new LoadingInfoDialog(mainWindow.getMainStage()); errVisitor = new ListErrorVisitor(highlightController); - setupLodFilters(); } - private void setupLodFilters() { - lodFilters.add(new ViewFilter() { - - @Override - public boolean useGeometry(CityObject co, Geometry geom) { - return geom.getLod() == Lod.LOD1; - } - - }); - lodFilters.add(new ViewFilter() { - - @Override - public boolean useGeometry(CityObject co, Geometry geom) { - return geom.getLod() == Lod.LOD2; - } - - }); - lodFilters.add(new ViewFilter() { - - @Override - public boolean useGeometry(CityObject co, Geometry geom) { - return geom.getLod() == Lod.LOD3; - } - - }); - lodFilters.add(new ViewFilter() { - - @Override - protected boolean useGeometry(CityObject co, Geometry geom) { - return geom.getLod() == Lod.LOD0; - } - - }); - lodFilters.add(new ViewFilter() { - - @Override - public boolean useGeometry(CityObject co, Geometry geom) { - return geom.getLod() == Lod.LOD4; - } - }); - roofFilter = new ViewFilter() { - @Override - public boolean allowedToUse(CityObject co, Geometry geom) { - if (!this.isEnabled()) { - return true; - } - return this.useGeometry(co, geom); - } - - @Override - public boolean useGeometry(CityObject co, Geometry geom) { - if (co instanceof BoundarySurface bs) { - return bs.getType() != BoundarySurfaceType.ROOF; - } - return true; - } + private boolean triangleVisible(TriangleMeta m) { + int lod = m.lod(); + boolean lodOk = lod < 0 || lod >= lodEnabled.length || lodEnabled[lod]; + if (!lodOk) { + return false; + } + return !roofsHidden || !m.roof(); + } - }; - roofFilter.setEnable(false); + private void rebuildIndexSet() { + if (currentScene == null) { + return; + } + mainWindow.getGlViewport().setIndexSet( + IndexSetBuilder.build(currentScene.triangleMetas(), this::triangleVisible)); } public void enableLod1() { - lodFilters.get(0).enable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD1.ordinal(), true); } public void disableLod1() { - lodFilters.get(0).disable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD1.ordinal(), false); } public void enableLod2() { - lodFilters.get(1).enable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD2.ordinal(), true); } public void disableLod2() { - lodFilters.get(1).disable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD2.ordinal(), false); } public void enableLod3() { - lodFilters.get(2).enable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD3.ordinal(), true); } public void disableLod3() { - lodFilters.get(2).disable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD3.ordinal(), false); } public void enableLod4() { - lodFilters.get(3).enable(); - if (refresher != null) { - refresher.run(); - } + setLodEnabled(Lod.LOD4.ordinal(), true); } public void disableLod4() { - lodFilters.get(3).disable(); - if (refresher != null) { - refresher.run(); + setLodEnabled(Lod.LOD4.ordinal(), false); + } + + private void setLodEnabled(int lodOrdinal, boolean enabled) { + // legacy LOD0 followed LOD3 button; keep LOD0 tied to LOD3 toggle for parity + lodEnabled[lodOrdinal] = enabled; + if (lodOrdinal == Lod.LOD3.ordinal()) { + lodEnabled[Lod.LOD0.ordinal()] = enabled; } + Platform.runLater(this::rebuildIndexSet); } public void hideRoofs() { - roofFilter.setEnable(true); - if (refresher != null) { - refresher.run(); - } + roofsHidden = true; + Platform.runLater(this::rebuildIndexSet); } public void showRoofs() { - roofFilter.setEnable(false); - if (refresher != null) { - refresher.run(); - } + roofsHidden = false; + Platform.runLater(this::rebuildIndexSet); } public void setupRefresher(CityObject co) { @@ -207,18 +147,7 @@ public class Renderer { private void addPolygons(CityObject co, Set polygons) { for (Geometry geom : co.getGeometries()) { - boolean used = false; - for (ViewFilter filter : lodFilters) { - if (filter.allowedToUse(co, geom)) { - used = true; - break; - } - } - if (used) { - if (roofFilter.allowedToUse(co, geom)) { - addConcretePolygons(polygons, geom); - } - } + addConcretePolygons(polygons, geom); } } @@ -244,15 +173,14 @@ public class Renderer { public void render(Geometry geom) { refresher = () -> { Platform.runLater(this::clearGeometryTrees); - currentTriGeom = TriangulatedGeometry.of(geom); + SceneData scene = SceneBuilder.fromPolygons(geom.getPolygons(), Color.WHITE); if (geom.getEdges() == null && currentGeometry != null) { // if there are no edges available low memory mode is enabled // clear the old geometry of all meta information currentGeometry.clearMetaData(); } - errVisitor.setGeometry(currentTriGeom); Platform.runLater(() -> { - setupRenderState(); + applyScene(scene); if (geom.getEdges() == null) { // create edges and vertices so they can be listed in the gui geom.prepareForChecking(); @@ -349,9 +277,8 @@ public class Renderer { private void render(Collection polygons) { Platform.runLater(this::clearGeometryTrees); - currentTriGeom = TriangulatedGeometry.of(polygons); - errVisitor.setGeometry(currentTriGeom); - setupRenderState(); + SceneData scene = SceneBuilder.fromPolygons(polygons, Color.WHITE); + Platform.runLater(() -> applyScene(scene)); } private void clearGeometryTrees() { @@ -375,10 +302,9 @@ public class Renderer { clearAttributes(); }); Thread t = new Thread(() -> { - currentTriGeom = TriangulatedGeometry.of(model, lodFilters); - errVisitor.setGeometry(currentTriGeom); + SceneData scene = SceneBuilder.fromModel(model); Platform.runLater(() -> { - setupRenderState(); + applyScene(scene); mainWindow.zoomOutForBoundingBox(BoundingBox.of(model)); loadingDialog.hide(); }); @@ -430,14 +356,10 @@ public class Renderer { CityObjectCollector collector = new CityObjectCollector(); cos.forEach(cityObject -> cityObject.accept(collector)); collector.getCityObjects().forEach(cityObject -> addPolygons(cityObject, polygons)); - if (baseColor != null) { - currentTriGeom = TriangulatedGeometry.of(polygons, baseColor); - } else { - currentTriGeom = TriangulatedGeometry.of(polygons); - } - errVisitor.setGeometry(currentTriGeom); + Color base = baseColor != null ? baseColor : Color.WHITE; + SceneData scene = SceneBuilder.fromPolygons(polygons, base); Platform.runLater(() -> { - setupRenderState(); + applyScene(scene); mainWindow.zoomOutForBoundingBox(BoundingBox.of(polygons)); loadingDialog.hide(); }); @@ -455,49 +377,44 @@ public class Renderer { private void addConcretePolygons(Set polygons, Geometry geom) { for (Polygon p : geom.getPolygons()) { - if (p.getOriginal().getPartOfSurface() != null && - !roofFilter.allowedToUse(p.getOriginal().getPartOfSurface(), p.getParent())) continue; polygons.add(p.getOriginal()); } } - private void setupRenderState() { - currentTriGeom.setCullFace(currentCulling); - currentTriGeom.setDrawMode(currentDrawMode); - Platform.runLater(() -> { - mainWindow.getMeshGroup().getChildren().clear(); - mainWindow.getMeshGroup().getChildren().addAll(currentTriGeom.getMeshes()); - }); + /** + * Installs the freshly built scene into the viewport (FX thread), wires the highlight center + * supplier, applies the current wireframe/cull state and rebuilds the visible index set. + */ + private void applyScene(SceneData scene) { + currentScene = scene; + GLViewport viewport = mainWindow.getGlViewport(); + viewport.setScene(scene); + highlightController.setCenterSupplier( + () -> currentScene == null ? new double[3] : currentScene.center()); + viewport.setWireframe(wireframe); + viewport.setCulling(culling); + rebuildIndexSet(); mainWindow.getGridButton().setDisable(false); mainWindow.getCullingButton().setDisable(false); } public void showWireFrame(boolean show) { - if (currentTriGeom != null) { - if (show) { - currentDrawMode = DrawMode.LINE; - } else { - currentDrawMode = DrawMode.FILL; - } - currentTriGeom.setDrawMode(currentDrawMode); - } + wireframe = show; + mainWindow.getGlViewport().setWireframe(show); } public void enableCulling(boolean enable) { - if (currentTriGeom != null) { - if (enable) { - currentCulling = CullFace.BACK; - } else { - currentCulling = CullFace.NONE; - } - currentTriGeom.setCullFace(currentCulling); - } + culling = enable; + mainWindow.getGlViewport().setCulling(enable); } public void clearCurrentRender() { // don't render anything + currentScene = null; Platform.runLater(() -> { - mainWindow.getMeshGroup().getChildren().clear(); + GLViewport viewport = mainWindow.getGlViewport(); + viewport.setScene(null); + viewport.setIndexSet(new int[0]); clearGeometryTrees(); clearAttributes(); }); @@ -508,35 +425,35 @@ public class Renderer { } public void highlight(Polygon p) { - highlightController.highlight(p, currentTriGeom); + highlightController.highlight(p); } public void highlight(LinearRing lr) { - highlightController.highlight(lr, currentTriGeom); + highlightController.highlight(lr); } public void highlight(Edge e) { - highlightController.highlight(e, currentTriGeom); + highlightController.highlight(e); } public void highlight(Vertex v) { - highlightController.highlight(v, currentTriGeom); + highlightController.highlight(v); } public void highlight(List highlightedRings) { - highlightController.highlight(highlightedRings, currentTriGeom); + highlightController.highlight(highlightedRings); } public void highlightEdges(List edges) { - highlightController.highlightEdges(edges, currentTriGeom); + highlightController.highlightEdges(edges); } public void highlightPolygons(List> components) { - highlightController.highlightPolygons(components, currentTriGeom); + highlightController.highlightPolygons(components); } public void addHighlight(Vertex vertex, Color c) { - highlightController.addHighlight(vertex, currentTriGeom, c); + highlightController.addHighlight(vertex, c); } public void highlight(CheckError err) { @@ -560,11 +477,11 @@ public class Renderer { } public void addHighlight(Polygon p) { - highlightController.addHighlight(p, currentTriGeom); + highlightController.addHighlight(p); } public void addHighlight(Triangle3d t) { - highlightController.highlight(t, currentTriGeom); + highlightController.highlight(t); } public void reset() { diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/TriangulatedGeometry.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/TriangulatedGeometry.java deleted file mode 100644 index 83a556d32f0189588d14008983c994b0d8ec565f..0000000000000000000000000000000000000000 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/TriangulatedGeometry.java +++ /dev/null @@ -1,251 +0,0 @@ -package de.hft.stuttgart.citydoctor2.gui; - -import de.hft.stuttgart.citydoctor2.utils.visitors.CityObjectCollector; -import de.hft.stuttgart.citydoctor2.datastructure.Polygon; -import de.hft.stuttgart.citydoctor2.datastructure.*; -import de.hft.stuttgart.citydoctor2.gui.filter.ViewFilter; -import de.hft.stuttgart.citydoctor2.math.Triangle3d; -import de.hft.stuttgart.citydoctor2.math.UnitVector3d; -import de.hft.stuttgart.citydoctor2.math.Vector3d; -import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; -import javafx.scene.paint.Color; -import javafx.scene.paint.PhongMaterial; -import javafx.scene.shape.*; - -import java.util.*; -import java.util.stream.Stream; - -public class TriangulatedGeometry { - - private static final PhongMaterial GRID_MAT = new PhongMaterial(Color.BLACK); - - // random vector for calculating normal angles, for color determination - private static final UnitVector3d AXIS = new Vector3d(19, 0.8, 1.5).normalize(); - - private Vector3d movedBy; - private List meshes; - private List materials; - - public static TriangulatedGeometry of(Geometry geom) { - return of(geom.getPolygons()); - } - - public static TriangulatedGeometry of(Collection polygons, Color basePolygonColor) { - TriangulatedGeometry triGeom = new TriangulatedGeometry(); - triGeom.materials = new ArrayList<>(); - triGeom.meshes = new ArrayList<>(); - List points = new ArrayList<>(); - for (Polygon p : polygons) { - points.addAll(p.getExteriorRing().getVertices()); - } - triGeom.movedBy = triGeom.findCenter(points); - - addPolygonDataToTriGeom(polygons, basePolygonColor, triGeom); - return triGeom; - } - - private static void addPolygonDataToTriGeom(Collection polygons, Color basePolygonColor, - TriangulatedGeometry triGeom) { - for (Polygon p : polygons) { - TesselatedPolygon tp = p.tesselate(); - TriangleMesh triMesh = new TriangleMesh(VertexFormat.POINT_TEXCOORD); - Map indexMap = new HashMap<>(); - List vertices = new ArrayList<>(); - int index = 0; - for (Triangle3d t : tp.getTriangles()) { - index = triGeom.filterDuplicates(triMesh, indexMap, vertices, index, t.getP1()); - index = triGeom.filterDuplicates(triMesh, indexMap, vertices, index, t.getP2()); - index = triGeom.filterDuplicates(triMesh, indexMap, vertices, index, t.getP3()); - } - - for (Vector3d point : vertices) { - float x = (float) (point.getX() - triGeom.movedBy.getX()); - float y = (float) (point.getY() - triGeom.movedBy.getY()); - float z = (float) (point.getZ() - triGeom.movedBy.getZ()); - triMesh.getPoints().addAll(x, y, z); - } - triMesh.getTexCoords().addAll(0, 0); - - MeshView view = new MeshView(triMesh); - view.setUserData(new PolygonClickDispatcher(p)); - - PhongMaterial mat = triGeom.calculateMaterial(p, basePolygonColor); - triGeom.materials.add(mat); - triGeom.meshes.add(view); - } - } - - public static TriangulatedGeometry of(CityDoctorModel model, List filters) { - List points = new ArrayList<>(); - addPointsFromCityObject(model.createFeatureStream().toList(), points); - - TriangulatedGeometry triGeom = new TriangulatedGeometry(); - triGeom.materials = new ArrayList<>(); - triGeom.meshes = new ArrayList<>(); - triGeom.movedBy = triGeom.findCenter(points); - - addPolygonDataFromCityObjects(model.getBuildings(), triGeom, Color.WHITE, filters); - addPolygonDataFromCityObjects(model.getBridges(), triGeom, Color.LIGHTSTEELBLUE, filters); - addPolygonDataFromCityObjects(model.getTunnels(), triGeom, Color.SLATEGRAY, filters); - addPolygonDataFromCityObjects(model.getLand(), triGeom, Color.TAN, filters); - addPolygonDataFromCityObjects(model.getTransportation(), triGeom, Color.DIMGRAY, filters); - addPolygonDataFromCityObjects(model.getVegetation(), triGeom, Color.LIGHTGREEN, filters); - addPolygonDataFromCityObjects(model.getWater(), triGeom, Color.LIGHTSKYBLUE, filters); - addPolygonDataFromCityObjects(model.getCityFurniture(), triGeom, Color.BLUEVIOLET, filters); - addPolygonDataFromCityObjects(model.getOtherCityObjects(), triGeom, Color.BLACK, filters); - - return triGeom; - } - - - - private static void addPolygonDataFromCityObjects(Stream cos, - TriangulatedGeometry triGeom, Color color, List filters) { - CityObjectCollector collector = new CityObjectCollector(); - cos.forEach(cityObject -> cityObject.accept(collector)); - collector.getCityObjects().forEach(cityObject -> addPolygonData(cityObject, triGeom, color, filters)); - } - - private static void addPolygonData(CityObject co, TriangulatedGeometry triGeom, Color color, List filters) { - for (Geometry geom : co.getGeometries()) { - if (isGeometryFiltered(co, geom, filters)) { - continue; - } - List polygons = new ArrayList<>(); - for (Polygon p : geom.getPolygons()) { - if (p.isLink()) { - continue; - } - polygons.add(p); - } - addPolygonDataToTriGeom(polygons, color, triGeom); - } - } - - private static boolean isGeometryFiltered(CityObject co, Geometry geom, List filters) { - for (ViewFilter filter : filters) { - if (filter.allowedToUse(co, geom)) { - return false; - } - } - return true; - } - - private static void addPointsFromCityObject(List cos, List points) { - CityObjectCollector collector = new CityObjectCollector(); - cos.forEach(cityObject -> cityObject.accept(collector)); - collector.getCityObjects().forEach(cityObject -> addPoints(cityObject, points)); - } - - private static void addPoints(CityObject co, List points) { - for (Geometry geom : co.getGeometries()) { - for (Polygon p : geom.getPolygons()) { - if (p.isLink()) { - continue; - } - points.addAll(p.getExteriorRing().getVertices()); - } - } - } - - public static TriangulatedGeometry of(Collection polygons) { - return of(polygons, Color.WHITE); - } - - private int filterDuplicates(TriangleMesh triMesh, Map indexMap, List vertices, - int index, Vector3d v) { - Integer vertexIndex = indexMap.get(v); - if (vertexIndex == null) { - indexMap.put(v, index); - vertices.add(v); - vertexIndex = index; - index++; - } - triMesh.getFaces().addAll(vertexIndex, 0); - return index; - } - - private PhongMaterial calculateMaterial(Polygon p, Color baseColor) { - Vector3d normal = p.calculateNormalNormalized(); - - if (p.getRenderColor() != null && p.getRenderColor() != Color.WHITE) { - baseColor = p.getRenderColor(); - } - - double cos = normal.dot(AXIS); - double acos = Math.acos(cos); - // normalize to range [0.3, 0.9] - acos = acos / Math.PI; - acos = acos * 0.6 + 0.3; - - Color derivedColor = baseColor.deriveColor(0, 1.0, acos, 1.0); - return new PhongMaterial(derivedColor); - } - - - private Vector3d findCenter(List points) { - double xMin = Double.MAX_VALUE; - double yMin = Double.MAX_VALUE; - double zMin = Double.MAX_VALUE; - - double xMax = Double.NEGATIVE_INFINITY; - double yMax = Double.NEGATIVE_INFINITY; - double zMax = Double.NEGATIVE_INFINITY; - - for (Vector3d point : points) { - if (point.getX() < xMin) { - xMin = point.getX(); - } - if (point.getX() > xMax) { - xMax = point.getX(); - } - if (point.getY() < yMin) { - yMin = point.getY(); - } - if (point.getY() > yMax) { - yMax = point.getY(); - } - if (point.getZ() < zMin) { - zMin = point.getZ(); - } - if (point.getZ() > zMax) { - zMax = point.getZ(); - } - } - - // center - double x = (xMax - xMin) / 2 + xMin; - double y = (yMax - yMin) / 2 + yMin; - double z = (zMax - zMin) / 2 + zMin; - return new Vector3d(x, y, z); - } - - public Vector3d getMovedBy() { - return movedBy; - } - - public void setCullFace(CullFace currentCulling) { - for (MeshView mesh : meshes) { - mesh.setCullFace(currentCulling); - } - } - - public void setDrawMode(DrawMode currentDrawMode) { - if (currentDrawMode == DrawMode.LINE) { - for (MeshView mesh : meshes) { - mesh.setDrawMode(currentDrawMode); - mesh.setMaterial(GRID_MAT); - } - } else if (currentDrawMode == DrawMode.FILL) { - for (int i = 0; i < meshes.size(); i++) { - MeshView mesh = meshes.get(i); - mesh.setDrawMode(currentDrawMode); - mesh.setMaterial(materials.get(i)); - } - } - } - - public List getMeshes() { - return meshes; - } -} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ValidationView.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ValidationView.java index 2699d1c712ff1fb855e98c88b71d84c92f2d264f..900294280e4ef5d1cd71ba3c95ee934954425df9 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ValidationView.java +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ValidationView.java @@ -46,7 +46,7 @@ public class ValidationView extends View { public void onHide() { Platform.runLater(() -> { mainWindow.unselectEverything(); - mainWindow.getMeshGroup().getChildren().clear(); + mainWindow.clearMeshView(); mainWindow.clearHighlights(); mainWindow.getErrorTree().getRoot().getChildren().clear(); mainWindow.getPolygonsView().getRoot().getChildren().clear(); diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java new file mode 100644 index 0000000000000000000000000000000000000000..fa26973ff969f09a3bf9a87e7bcfeecabf687c46 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java @@ -0,0 +1,41 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.Arrays; + +/** Growable primitive float array to avoid boxing when building large vertex buffers. */ +public class FloatList { + private float[] data; + private int size; + + public FloatList() { this(1024); } + public FloatList(int initialCapacity) { data = new float[Math.max(1, initialCapacity)]; } + + public void add(float v) { + ensure(size + 1); + data[size++] = v; + } + + public void add(float a, float b, float c) { + ensure(size + 3); + data[size++] = a; + data[size++] = b; + data[size++] = c; + } + + private void ensure(int capacity) { + if (capacity > data.length) { + int newCap = data.length; + while (newCap < capacity) { + newCap <<= 1; + } + data = Arrays.copyOf(data, newCap); + } + } + + public int size() { return size; } + + /** Resets the list to empty without releasing the backing array. */ + public void clear() { size = 0; } + + public float[] toArray() { return Arrays.copyOf(data, size); } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java new file mode 100644 index 0000000000000000000000000000000000000000..47b9e16066b9b24dec38fffb58910f215c0c099b --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java @@ -0,0 +1,293 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.huskerdev.grapl.gl.GLProfile; +import com.huskerdev.openglfx.canvas.GLCanvas; +import com.huskerdev.openglfx.jogl.JOGLExecutor; +import com.huskerdev.openglfx.jogl.events.JOGLRenderEvent; +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import javafx.animation.AnimationTimer; +import javafx.application.Platform; +import javafx.scene.input.MouseButton; +import javafx.scene.paint.Color; + +/** OpenGL viewport embedded in JavaFX via openglfx. Replaces the legacy SubScene. */ +public class GLViewport { + + private static final Color BACKGROUND = Color.AZURE; + + private final GLCanvas canvas; + private final OrbitCamera camera = new OrbitCamera(); + private final GlScene scene = new GlScene(); + private final PickingFramebuffer picking = new PickingFramebuffer(); + private final HighlightOverlay overlay = new HighlightOverlay(); + + private ShaderProgram sceneProgram; + private ShaderProgram pickProgram; + private ShaderProgram pointProgram; + private boolean initialized; + + // state set off-thread, consumed in render callback + private final AtomicReference pendingScene = new AtomicReference<>(); + private final AtomicReference pendingIndexSet = new AtomicReference<>(); + // last index set seen; retained so it can be (re)applied after the scene is uploaded or the GL + // context is recreated (the index buffer only exists once the scene's buffers have been generated) + private volatile int[] lastIndices; + private volatile boolean wireframe = false; + private volatile boolean cull = false; + private volatile float pointScale = 20f; + private volatile float lineScale = 4f; + + // highlight marker data, uploaded on the GL thread when dirty + private volatile float[] hlPoints = new float[0]; + private volatile float[] hlPointColors = new float[0]; + private volatile float[] hlLines = new float[0]; + private volatile float[] hlLineColors = new float[0]; + private volatile boolean highlightsDirty = false; + + // pick request: logical (JavaFX) screen coords + callback (invoked on FX thread with resolved Object) + private volatile double[] pickRequest; + private volatile Consumer pickCallback; + private SceneData currentScene; + + // drag state + private double lastX; + private double lastY; + + // optional hook invoked after a camera interaction (orbit/pan/zoom) so a coupled viewport can be + // kept in sync and redrawn (used by the dual-viewport healer) + private Runnable interactionListener; + + // on-demand render request. openglfx can drop a repaint() that arrives while a frame is rendering + // (its requestRepaint is an unconditional notify with no pending flag), so a single repaint is not + // reliable. This flag + pump re-deliver repaint() each pulse until a frame consumes the request, + // then the pump stops itself so an idle viewport has no per-pulse cost. + private volatile boolean renderRequested; + private final AnimationTimer repaintPump = new AnimationTimer() { + @Override + public void handle(long now) { + if (renderRequested) { + canvas.repaint(); + } else { + stop(); + } + } + }; + + public GLViewport() { + // args: executor, profile, flipY=false, msaa=4, fps=0.0 + // fps=0 -> on-demand rendering: a frame is drawn only when repaint() is called (every state + // change / input does so), so an idle viewport allocates and renders nothing. + canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE, false, 4, 0.0); + canvas.addOnInitEvent(ev -> { + // GL context (re)created: shaders/buffers from any previous context are gone. Drop our + // handles and re-queue the current scene so it is re-uploaded into the new context. + initialized = false; + scene.markContextLost(); + if (currentScene != null) { + pendingScene.set(currentScene); + } + }); + canvas.addOnRenderEvent(ev -> render((JOGLRenderEvent) ev)); + canvas.addOnReshapeEvent(ev -> { /* aspect handled per-frame from width/height */ }); + // on-demand mode: repaint when the canvas is resized so the framebuffer tracks the new size + canvas.widthProperty().addListener((obs, ov, nv) -> requestRender()); + canvas.heightProperty().addListener((obs, ov, nv) -> requestRender()); + installInputHandlers(); + } + + public GLCanvas getNode() { return canvas; } + + // ---- public API (called from FX / worker threads) ---- + + public void setScene(SceneData data) { pendingScene.set(data); requestRender(); } + + public void setIndexSet(int[] indices) { pendingIndexSet.set(indices); requestRender(); } + + public void setWireframe(boolean on) { wireframe = on; requestRender(); } + + public void setCulling(boolean on) { cull = on; requestRender(); } + + public void setPointScale(float scale) { pointScale = scale; requestRender(); } + + /** points: xyz, pointColors rgb-per-point; lines: xyz pairs, lineColors rgb-per-line-vertex. */ + public void setHighlights(float[] points, float[] pointColors, float[] lines, float[] lineColors) { + this.hlPoints = points; + this.hlPointColors = pointColors; + this.hlLines = lines; + this.hlLineColors = lineColors; + this.highlightsDirty = true; + requestRender(); + } + + public OrbitCamera camera() { return camera; } + + /** Hook invoked after a camera interaction so a coupled viewport can sync and redraw. */ + public void setInteractionListener(Runnable listener) { this.interactionListener = listener; } + + /** Public redraw trigger (e.g. for a coupled viewport). */ + public void requestRedraw() { requestRender(); } + + /** Clears the displayed geometry (draws nothing) until a new scene is set. */ + public void clearScene() { + currentScene = null; + lastIndices = new int[0]; + setIndexSet(new int[0]); + } + + /** + * Requests a redraw. Sets the render flag, wakes the canvas immediately (best effort), and starts + * the pump so the request is re-delivered until a frame actually consumes it. Call on the FX thread. + */ + private void requestRender() { + renderRequested = true; + canvas.repaint(); + repaintPump.start(); // idempotent while running + } + + public void requestPick(double x, double y, Consumer callback) { + pickRequest = new double[]{x, y}; + pickCallback = callback; + requestRender(); + } + + // ---- render callback (GL thread) ---- + + private void render(JOGLRenderEvent ev) { + // consume the render request; if new state arrives during this frame, requestRender re-arms it + renderRequested = false; + GL3 gl = ev.getGl(); + int w = ev.width; + int h = ev.height; + int defaultFbo = ev.fbo; + + if (!initialized) { + sceneProgram = new ShaderProgram(gl, "scene.vert", "scene.frag"); + pickProgram = new ShaderProgram(gl, "pick.vert", "pick.frag"); + pointProgram = new ShaderProgram(gl, "point.vert", "point.geom", "point.frag"); + overlay.init(gl); + gl.glEnable(GL.GL_DEPTH_TEST); + gl.glEnable(GL.GL_MULTISAMPLE); // anti-aliasing for the multisampled framebuffer + initialized = true; + } + + SceneData newScene = pendingScene.getAndSet(null); + boolean uploadedNow = false; + if (newScene != null) { + scene.upload(gl, newScene); + currentScene = newScene; + uploadedNow = true; + } + int[] newIndices = pendingIndexSet.getAndSet(null); + if (newIndices != null) { + lastIndices = newIndices; + } + // Apply the index set only once the scene's buffers exist (the element buffer is created by + // upload). Re-apply after a fresh upload so the indices survive a scene swap or context loss. + if (scene.hasData() && lastIndices != null && (newIndices != null || uploadedNow)) { + scene.setIndexSet(gl, lastIndices); + } + if (highlightsDirty) { + overlay.setPoints(gl, hlPoints, hlPointColors); + overlay.setLines(gl, hlLines, hlLineColors); + highlightsDirty = false; + } + + camera.setAspect(h == 0 ? 1f : (float) w / h); + float[] mvp = camera.viewProjection(); + + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, defaultFbo); + gl.glViewport(0, 0, w, h); + gl.glClearColor((float) BACKGROUND.getRed(), (float) BACKGROUND.getGreen(), + (float) BACKGROUND.getBlue(), 1f); + gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT); + if (cull) { + gl.glEnable(GL.GL_CULL_FACE); + } else { + gl.glDisable(GL.GL_CULL_FACE); + } + gl.glPolygonMode(GL.GL_FRONT_AND_BACK, wireframe ? GL3.GL_LINE : GL3.GL_FILL); + + sceneProgram.use(gl); + gl.glUniformMatrix4fv(sceneProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); + scene.draw(gl); + // wireframe applies to the geometry only; highlight markers always render filled + gl.glPolygonMode(GL.GL_FRONT_AND_BACK, GL3.GL_FILL); + // highlight markers sit at the same depth as geometry edges (and the wireframe lines); use + // LEQUAL so the later-drawn markers win depth ties instead of being rejected by GL_LESS + gl.glDepthFunc(GL.GL_LEQUAL); + // edge markers reuse the flat scene program (pos + colour) + overlay.drawLines(gl, lineScale); + // vertex markers use the sphere-impostor program (geometry shader -> camera-facing quads) + pointProgram.use(gl); + gl.glUniformMatrix4fv(pointProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); + gl.glUniform2f(pointProgram.uniform(gl, "uViewport"), (float) w, (float) h); + gl.glUniform1f(pointProgram.uniform(gl, "uPointSize"), pointScale); + overlay.drawPoints(gl); + gl.glDepthFunc(GL.GL_LESS); // restore for the next frame's scene pass + + double[] req = pickRequest; + if (req != null && currentScene != null) { + picking.resize(gl, w, h); + picking.bind(gl); + gl.glClearColor(0, 0, 0, 1); + gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT); + gl.glPolygonMode(GL.GL_FRONT_AND_BACK, GL3.GL_FILL); + pickProgram.use(gl); + gl.glUniformMatrix4fv(pickProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); + scene.draw(gl); + // click coords are logical (JavaFX) px; the picking buffer is physical px. Scale by the + // framebuffer/canvas ratio so HiDPI displays read the pixel actually under the cursor. + double cw = canvas.getWidth(); + double ch = canvas.getHeight(); + float sx = cw > 0 ? (float) (w / cw) : 1f; + float sy = ch > 0 ? (float) (h / ch) : 1f; + int px = Math.round((float) req[0] * sx); + int py = Math.round((float) req[1] * sy); + int id = picking.readId(gl, px, py); + Object target = currentScene.pickRegistry().resolve(id); + Consumer cb = pickCallback; + pickRequest = null; + pickCallback = null; + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, defaultFbo); + if (cb != null) { + Platform.runLater(() -> cb.accept(target)); + } + } + } + + // ---- input ---- + + private void installInputHandlers() { + canvas.setOnMousePressed(e -> { lastX = e.getX(); lastY = e.getY(); }); + canvas.setOnMouseDragged(e -> { + double dx = e.getX() - lastX; + double dy = e.getY() - lastY; + lastX = e.getX(); + lastY = e.getY(); + if (e.getButton() == MouseButton.PRIMARY) { + camera.orbit(dx / 3.0, dy / 3.0); + } else if (e.getButton() == MouseButton.SECONDARY) { + double speed = Math.abs(camera.distance()) / 1000.0; + camera.pan(-dx * speed, dy * speed); + } + requestRender(); + notifyInteraction(); + }); + canvas.setOnScroll(e -> { + camera.zoom(e.getDeltaY() < 0 ? 1.05 : 0.95); + requestRender(); + notifyInteraction(); + }); + } + + public void resetCamera() { camera.reset(); requestRender(); } + + private void notifyInteraction() { + if (interactionListener != null) { + interactionListener.run(); + } + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java new file mode 100644 index 0000000000000000000000000000000000000000..115ba52089bb52f2ec279d980c2f3eca7b86f011 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java @@ -0,0 +1,117 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +/** GPU resources for the global scene mesh. Single VBO (pos+color+id), one index buffer redrawn per frame. */ +public class GlScene { + + private int vao = 0; + private int vboPos; + private int vboColor; + private int vboId; + private int ebo; + private int indexCount; + private boolean uploaded; + + public void upload(GL3 gl, SceneData data) { + if (!uploaded) { + int[] ids = new int[3]; + gl.glGenVertexArrays(1, ids, 0); + vao = ids[0]; + int[] buffers = new int[4]; + gl.glGenBuffers(4, buffers, 0); + vboPos = buffers[0]; + vboColor = buffers[1]; + vboId = buffers[2]; + ebo = buffers[3]; + } + gl.glBindVertexArray(vao); + + uploadFloat(gl, vboPos, data.positions(), 0, 3); + uploadFloat(gl, vboColor, data.colors(), 1, 3); + uploadInt(gl, vboId, data.ids(), 2); + + gl.glBindVertexArray(0); + uploaded = true; + } + + private void uploadFloat(GL3 gl, int vbo, float[] data, int location, int components) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + FloatBuffer fb = toFloatBuffer(data); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Float.BYTES, fb, GL.GL_STATIC_DRAW); + gl.glEnableVertexAttribArray(location); + gl.glVertexAttribPointer(location, components, GL.GL_FLOAT, false, 0, 0L); + } + + private void uploadInt(GL3 gl, int vbo, int[] data, int location) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + IntBuffer ib = toIntBuffer(data); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Integer.BYTES, ib, GL.GL_STATIC_DRAW); + gl.glEnableVertexAttribArray(location); + gl.glVertexAttribIPointer(location, 1, GL3.GL_INT, 0, 0L); // integer attribute, not normalized + } + + /** Re-uploads which triangles to draw (element indices). Cheap; called on filter/LOD changes. */ + public void setIndexSet(GL3 gl, int[] indices) { + if (!uploaded || vao == 0) { + // buffers not generated yet; the caller re-applies once the scene is uploaded + return; + } + gl.glBindVertexArray(vao); + gl.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo); + IntBuffer ib = toIntBuffer(indices); + gl.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, (long) indices.length * Integer.BYTES, ib, + GL.GL_DYNAMIC_DRAW); + gl.glBindVertexArray(0); + indexCount = indices.length; + } + + public void draw(GL3 gl) { + if (vao == 0 || indexCount == 0) { + return; + } + gl.glBindVertexArray(vao); + gl.glDrawElements(GL.GL_TRIANGLES, indexCount, GL.GL_UNSIGNED_INT, 0L); + gl.glBindVertexArray(0); + } + + public boolean hasData() { return uploaded; } + + /** + * Forgets GL handles after the context was lost/recreated, without issuing GL calls (the old + * handles belong to a dead context). The next {@link #upload} regenerates everything. + */ + public void markContextLost() { + vao = 0; + indexCount = 0; + uploaded = false; + } + + public void dispose(GL3 gl) { + if (vao != 0) { + gl.glDeleteVertexArrays(1, new int[]{vao}, 0); + gl.glDeleteBuffers(4, new int[]{vboPos, vboColor, vboId, ebo}, 0); + vao = 0; + uploaded = false; + } + } + + private static FloatBuffer toFloatBuffer(float[] data) { + ByteBuffer bb = ByteBuffer.allocateDirect(data.length * Float.BYTES).order(ByteOrder.nativeOrder()); + FloatBuffer fb = bb.asFloatBuffer(); + fb.put(data).flip(); + return fb; + } + + private static IntBuffer toIntBuffer(int[] data) { + ByteBuffer bb = ByteBuffer.allocateDirect(data.length * Integer.BYTES).order(ByteOrder.nativeOrder()); + IntBuffer ib = bb.asIntBuffer(); + ib.put(data).flip(); + return ib; + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java new file mode 100644 index 0000000000000000000000000000000000000000..903dcdeff0b602d5476a22ea23b07e990e677a60 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java @@ -0,0 +1,119 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +/** + * Dynamic overlay for highlight markers: points for vertices, lines for edges. Positions are in the same + * recentred world space as the scene. Colour rides in the color attribute; id attribute is 0. + */ +public class HighlightOverlay { + + private int pointVao; + private int pointPos; + private int pointColor; + private int pointId; + private int pointCount; + + private int lineVao; + private int linePos; + private int lineColor; + private int lineId; + private int lineVertexCount; + + private boolean created; + + public void init(GL3 gl) { + int[] v = new int[2]; + gl.glGenVertexArrays(2, v, 0); + pointVao = v[0]; + lineVao = v[1]; + int[] b = new int[6]; + gl.glGenBuffers(6, b, 0); + pointPos = b[0]; pointColor = b[1]; pointId = b[2]; + linePos = b[3]; lineColor = b[4]; lineId = b[5]; + created = true; + } + + /** points: xyz triplets; pointColors: rgb triplets (one per point). */ + public void setPoints(GL3 gl, float[] points, float[] pointColors) { + pointCount = points.length / 3; + configure(gl, pointVao, pointPos, points, pointColor, pointColors, pointId, pointCount); + } + + /** lines: xyz pairs of endpoints (6 floats per segment); lineColors: rgb per line vertex. */ + public void setLines(GL3 gl, float[] lines, float[] lineColors) { + lineVertexCount = lines.length / 3; + configure(gl, lineVao, linePos, lines, lineColor, lineColors, lineId, lineVertexCount); + } + + private void configure(GL3 gl, int vao, int posVbo, float[] pos, int colVbo, float[] col, + int idVbo, int vertexCount) { + gl.glBindVertexArray(vao); + bindFloat(gl, posVbo, pos, 0, 3); + bindFloat(gl, colVbo, col, 1, 3); + // id attribute all zeros so overlay never matches uSelectedId + int[] zeros = new int[vertexCount]; + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, idVbo); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) zeros.length * Integer.BYTES, toInt(zeros), + GL.GL_DYNAMIC_DRAW); + gl.glEnableVertexAttribArray(2); + gl.glVertexAttribIPointer(2, 1, GL3.GL_INT, 0, 0L); + gl.glBindVertexArray(0); + } + + private void bindFloat(GL3 gl, int vbo, float[] data, int loc, int comp) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Float.BYTES, toFloat(data), + GL.GL_DYNAMIC_DRAW); + gl.glEnableVertexAttribArray(loc); + gl.glVertexAttribPointer(loc, comp, GL.GL_FLOAT, false, 0, 0L); + } + + /** Draws the edge markers as lines. The caller must bind a program that reads pos+color. */ + public void drawLines(GL3 gl, float lineWidth) { + if (!created || lineVertexCount == 0) { + return; + } + gl.glLineWidth(lineWidth); + gl.glBindVertexArray(lineVao); + gl.glDrawArrays(GL.GL_LINES, 0, lineVertexCount); + gl.glBindVertexArray(0); + } + + /** + * Draws the vertex markers as GL_POINTS. The caller must bind the sphere-impostor program (a + * geometry shader expands each point into a camera-facing quad shaded as a sphere). + */ + public void drawPoints(GL3 gl) { + if (!created || pointCount == 0) { + return; + } + gl.glBindVertexArray(pointVao); + gl.glDrawArrays(GL.GL_POINTS, 0, pointCount); + gl.glBindVertexArray(0); + } + + public void clear() { + pointCount = 0; + lineVertexCount = 0; + } + + private static FloatBuffer toFloat(float[] d) { + FloatBuffer fb = ByteBuffer.allocateDirect(Math.max(1, d.length) * Float.BYTES) + .order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(d).flip(); + return fb; + } + + private static IntBuffer toInt(int[] d) { + IntBuffer ib = ByteBuffer.allocateDirect(Math.max(1, d.length) * Integer.BYTES) + .order(ByteOrder.nativeOrder()).asIntBuffer(); + ib.put(d).flip(); + return ib; + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilder.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..325068e86f71742a5f68d7ca765d12f27a87e3b8 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilder.java @@ -0,0 +1,23 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.List; +import java.util.function.Predicate; + +/** Builds the element index set (triangle vertex indices) for the triangles passing the filter. */ +public final class IndexSetBuilder { + + private IndexSetBuilder() {} + + public static int[] build(List metas, Predicate include) { + IntList out = new IntList(); + for (TriangleMeta m : metas) { + if (include.test(m)) { + int base = m.triangleIndex() * 3; + out.add(base); + out.add(base + 1); + out.add(base + 2); + } + } + return out.toArray(); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IntList.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IntList.java new file mode 100644 index 0000000000000000000000000000000000000000..bc1d2f407568ef67c649450a12823d57fbe7f715 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IntList.java @@ -0,0 +1,25 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.Arrays; + +/** Growable primitive int array. */ +public class IntList { + private int[] data; + private int size; + + public IntList() { this(1024); } + public IntList(int initialCapacity) { data = new int[Math.max(1, initialCapacity)]; } + + public void add(int v) { + if (size + 1 > data.length) { + data = Arrays.copyOf(data, data.length << 1); + } + data[size++] = v; + } + + public int get(int index) { return data[index]; } + + public int size() { return size; } + + public int[] toArray() { return Arrays.copyOf(data, size); } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4.java new file mode 100644 index 0000000000000000000000000000000000000000..cd3f976ff06cee7df999022f22740badde9e08b4 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4.java @@ -0,0 +1,79 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** + * Minimal column-major 4x4 float matrix helpers, layout-compatible with OpenGL uniform uploads + * (element index = column*4 + row). + */ +public final class Mat4 { + + private Mat4() {} + + public static float[] identity() { + float[] m = new float[16]; + m[0] = m[5] = m[10] = m[15] = 1f; + return m; + } + + public static float[] translation(float x, float y, float z) { + float[] m = identity(); + m[12] = x; + m[13] = y; + m[14] = z; + return m; + } + + /** Returns a * b (column-major). */ + public static float[] multiply(float[] a, float[] b) { + float[] r = new float[16]; + for (int col = 0; col < 4; col++) { + for (int row = 0; row < 4; row++) { + float sum = 0f; + for (int k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + r[col * 4 + row] = sum; + } + } + return r; + } + + /** Returns m * (x,y,z,w) as a length-4 array. */ + public static float[] transform(float[] m, float x, float y, float z, float w) { + return new float[] { + m[0] * x + m[4] * y + m[8] * z + m[12] * w, + m[1] * x + m[5] * y + m[9] * z + m[13] * w, + m[2] * x + m[6] * y + m[10] * z + m[14] * w, + m[3] * x + m[7] * y + m[11] * z + m[15] * w + }; + } + + public static float[] rotationX(float rad) { + float c = (float) Math.cos(rad); + float s = (float) Math.sin(rad); + float[] m = identity(); + m[5] = c; m[9] = -s; + m[6] = s; m[10] = c; + return m; + } + + public static float[] rotationZ(float rad) { + float c = (float) Math.cos(rad); + float s = (float) Math.sin(rad); + float[] m = identity(); + m[0] = c; m[4] = -s; + m[1] = s; m[5] = c; + return m; + } + + /** Right-handed perspective projection (camera looks down -Z). */ + public static float[] perspective(float fovyRad, float aspect, float near, float far) { + float f = (float) (1.0 / Math.tan(fovyRad / 2.0)); + float[] m = new float[16]; + m[0] = f / aspect; + m[5] = f; + m[10] = (far + near) / (near - far); + m[11] = -1f; + m[14] = (2f * far * near) / (near - far); + return m; + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulator.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulator.java new file mode 100644 index 0000000000000000000000000000000000000000..f56bea760cc658e946e5b0d4fe29013381f5103e --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulator.java @@ -0,0 +1,45 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; + +/** + * Builds interleaved-by-attribute vertex arrays from triangles. Vertices are stored per triangle corner + * (3 per triangle) so a triangle's vertices occupy a contiguous, predictable range — which lets the + * draw index set be built by triangle without dedup bookkeeping. + */ +public class MeshAccumulator { + + private final Vector3d center; + private final FloatList positions = new FloatList(); + private final FloatList colors = new FloatList(); + private final IntList ids = new IntList(); + private int vertexCount; + + public MeshAccumulator(Vector3d center) { + this.center = center; + } + + public void addTriangle(Triangle3d t, int id, float r, float g, float b) { + addVertex(t.getP1(), id, r, g, b); + addVertex(t.getP2(), id, r, g, b); + addVertex(t.getP3(), id, r, g, b); + } + + private void addVertex(Vector3d p, int id, float r, float g, float b) { + positions.add((float) (p.getX() - center.getX()), + (float) (p.getY() - center.getY()), + (float) (p.getZ() - center.getZ())); + colors.add(r, g, b); + ids.add(id); + vertexCount++; + } + + public int vertexCount() { return vertexCount; } + + public float[] positions() { return positions.toArray(); } + + public float[] colors() { return colors.toArray(); } + + public int[] ids() { return ids.toArray(); } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java new file mode 100644 index 0000000000000000000000000000000000000000..ed276845a52f51fcc3612a1578557b6f59b1a59e --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java @@ -0,0 +1,86 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** + * Orbit camera reproducing the legacy JavaFX interaction model: world is rotated about Z then X, the + * camera sits at {@code distance} along -Z (negative distance = farther away) and pans in X/Y. + */ +public class OrbitCamera { + + private static final double INITIAL_AZIMUTH = 180.0; + private static final double INITIAL_ELEVATION = -60.0; + private static final double INITIAL_DISTANCE = 100.0; + private static final double FOVY_RAD = Math.toRadians(30); + private static final float NEAR = 0.1f; + private static final float FAR = 10000f; + + private double azimuthDeg = INITIAL_AZIMUTH; + private double elevationDeg = INITIAL_ELEVATION; + private double distance = INITIAL_DISTANCE; + private double panX = 0; + private double panY = 0; + private float aspect = 1.0f; + + public void setAspect(float aspect) { + if (aspect > 0) { + this.aspect = aspect; + } + } + + public void orbit(double deltaAzimuthDeg, double deltaElevationDeg) { + azimuthDeg += deltaAzimuthDeg; + elevationDeg += deltaElevationDeg; + } + + public void pan(double dx, double dy) { + panX += dx; + panY += dy; + } + + /** factor > 1 moves farther, < 1 moves closer. */ + public void zoom(double factor) { + distance *= factor; + } + + /** Matches legacy MainWindow.zoomOutForBoundingBox distance computation. */ + public void zoomOutForBoundingBox(double diagonalLength) { + double longestSide = diagonalLength * 0.4; + distance = longestSide / Math.tan(FOVY_RAD / 2.0); + panX = 0; + panY = 0; + } + + public void reset() { + azimuthDeg = INITIAL_AZIMUTH; + elevationDeg = INITIAL_ELEVATION; + distance = INITIAL_DISTANCE; + panX = 0; + panY = 0; + } + + /** Copies the orbit/zoom/pan state from another camera (but not its aspect ratio). */ + public void copyOrbitFrom(OrbitCamera other) { + this.azimuthDeg = other.azimuthDeg; + this.elevationDeg = other.elevationDeg; + this.distance = other.distance; + this.panX = other.panX; + this.panY = other.panY; + } + + public double distance() { return distance; } + + /** Current azimuth (rotation about Z) in degrees. */ + public double azimuthDeg() { return azimuthDeg; } + + /** Current elevation (rotation about X) in degrees. */ + public double elevationDeg() { return elevationDeg; } + + /** Combined projection * view * model-rotation, column-major, ready for a GL uniform. */ + public float[] viewProjection() { + float[] proj = Mat4.perspective((float) FOVY_RAD, aspect, NEAR, FAR); + float[] view = Mat4.translation((float) -panX, (float) -panY, (float) -distance); + float[] rot = Mat4.multiply( + Mat4.rotationX((float) Math.toRadians(elevationDeg)), + Mat4.rotationZ((float) Math.toRadians(azimuthDeg))); + return Mat4.multiply(proj, Mat4.multiply(view, rot)); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistry.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistry.java new file mode 100644 index 0000000000000000000000000000000000000000..3e40f01ced83912caa16281574b9e8f1a1ad8fe1 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistry.java @@ -0,0 +1,26 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.ArrayList; +import java.util.List; + +/** Maps picking ids (1-based) to the model objects they represent. */ +public class PickRegistry { + + private final List targets = new ArrayList<>(); + + /** Registers a target and returns its non-zero picking id. */ + public int register(Object target) { + targets.add(target); + return targets.size(); // id = index + 1 + } + + /** Resolves a picking id to its target, or null for background / unknown id. */ + public Object resolve(int id) { + if (id <= 0 || id > targets.size()) { + return null; + } + return targets.get(id - 1); + } + + public int size() { return targets.size(); } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodec.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodec.java new file mode 100644 index 0000000000000000000000000000000000000000..30bb6adfe904d4fb66b30166de89f25140fa2162 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodec.java @@ -0,0 +1,15 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** Encodes integer ids into RGB bytes for GPU color-picking. Id 0 is reserved for background. */ +public final class PickingCodec { + + private PickingCodec() {} + + public static int red(int id) { return id & 0xFF; } + public static int green(int id) { return (id >> 8) & 0xFF; } + public static int blue(int id) { return (id >> 16) & 0xFF; } + + public static int decode(int r, int g, int b) { + return (r & 0xFF) | ((g & 0xFF) << 8) | ((b & 0xFF) << 16); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingFramebuffer.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingFramebuffer.java new file mode 100644 index 0000000000000000000000000000000000000000..d173c5b7f65ec16333cce33da0ff61c0cd52b703 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingFramebuffer.java @@ -0,0 +1,80 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** Off-screen framebuffer that stores picking ids as RGBA8; supports single-pixel readback. */ +public class PickingFramebuffer { + + private int fbo; + private int colorTex; + private int depthRbo; + private int width; + private int height; + private boolean created; + + public void resize(GL3 gl, int w, int h) { + if (w <= 0 || h <= 0 || (w == width && h == height && created)) { + return; + } + dispose(gl); + width = w; + height = h; + + int[] tmp = new int[1]; + gl.glGenFramebuffers(1, tmp, 0); + fbo = tmp[0]; + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + + gl.glGenTextures(1, tmp, 0); + colorTex = tmp[0]; + gl.glBindTexture(GL.GL_TEXTURE_2D, colorTex); + gl.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, w, h, 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, null); + gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST); + gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST); + gl.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, colorTex, 0); + + gl.glGenRenderbuffers(1, tmp, 0); + depthRbo = tmp[0]; + gl.glBindRenderbuffer(GL.GL_RENDERBUFFER, depthRbo); + gl.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, w, h); + gl.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, depthRbo); + + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0); + created = true; + } + + public void bind(GL3 gl) { + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + gl.glViewport(0, 0, width, height); + } + + /** + * Reads the picking id at canvas pixel (x, y) measured from the top-left. GL's origin is bottom-left, + * so the row is flipped. + */ + public int readId(GL3 gl, int x, int y) { + if (!created || x < 0 || y < 0 || x >= width || y >= height) { + return 0; + } + ByteBuffer buf = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder()); + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + gl.glReadPixels(x, height - 1 - y, 1, 1, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, buf); + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0); + int r = buf.get(0) & 0xFF; + int g = buf.get(1) & 0xFF; + int b = buf.get(2) & 0xFF; + return PickingCodec.decode(r, g, b); + } + + public void dispose(GL3 gl) { + if (created) { + gl.glDeleteFramebuffers(1, new int[]{fbo}, 0); + gl.glDeleteTextures(1, new int[]{colorTex}, 0); + gl.glDeleteRenderbuffers(1, new int[]{depthRbo}, 0); + created = false; + } + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..43329f29fd2d3751726f52b9ada78fda203746ae --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java @@ -0,0 +1,176 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.datastructure.BoundarySurfaceType; +import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel; +import de.hft.stuttgart.citydoctor2.datastructure.CityObject; +import de.hft.stuttgart.citydoctor2.datastructure.Geometry; +import de.hft.stuttgart.citydoctor2.datastructure.Lod; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; +import de.hft.stuttgart.citydoctor2.utils.visitors.CityObjectCollector; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; +import javafx.scene.paint.Color; + +/** + * Builds {@link SceneData} from model polygons: tesselate, bake shade colour, assign picking ids, + * accumulate vertices and per-triangle metadata, recentre by the bounding-box centre. + * Intended to run on a worker thread. + */ +public final class SceneBuilder { + + private SceneBuilder() {} + + public static SceneData fromPolygons(Collection polygons, Color baseColor) { + return build(polygons, baseColor, Lod.LOD2.ordinal(), false); + } + + /** General entry: caller supplies polygons already chosen, plus their lod/roof attributes. */ + public static SceneData build(Collection polygons, Color baseColor, + int lod, boolean roof) { + Vector3d center = findCenter(polygons); + MeshAccumulator acc = new MeshAccumulator(center); + PickRegistry registry = new PickRegistry(); + List metas = new ArrayList<>(); + int triangleIndex = 0; + + for (Polygon p : polygons) { + Color base = chooseBase(p, baseColor); + Color shaded = ShadeColor.shade(p.calculateNormalNormalized(), base); + float r = (float) shaded.getRed(); + float g = (float) shaded.getGreen(); + float b = (float) shaded.getBlue(); + int id = registry.register(p); + + TesselatedPolygon tp = p.tesselate(); + for (Triangle3d t : tp.getTriangles()) { + acc.addTriangle(t, id, r, g, b); + metas.add(new TriangleMeta(triangleIndex++, p, lod, roof)); + } + } + + double[] centerArr = {center.getX(), center.getY(), center.getZ()}; + return new SceneData(acc.positions(), acc.colors(), acc.ids(), metas, registry, centerArr); + } + + /** + * Builds ALL features of the model into a single {@link SceneData} with one shared center, + * one {@link PickRegistry} and continuous triangle indices. Filtering by LOD/roof happens later + * via the index set, not at build time. Replicates the per-feature base colours of the legacy + * {@code TriangulatedGeometry.of(model, filters)}. + */ + public static SceneData fromModel(CityDoctorModel model) { + Vector3d center = findCenterOfModel(model); + MeshAccumulator acc = new MeshAccumulator(center); + PickRegistry registry = new PickRegistry(); + List metas = new ArrayList<>(); + int[] triangleIndex = {0}; + + appendFeatures(model.getBuildings(), Color.WHITE, acc, registry, metas, triangleIndex); + appendFeatures(model.getBridges(), Color.LIGHTSTEELBLUE, acc, registry, metas, triangleIndex); + appendFeatures(model.getTunnels(), Color.SLATEGRAY, acc, registry, metas, triangleIndex); + appendFeatures(model.getLand(), Color.TAN, acc, registry, metas, triangleIndex); + appendFeatures(model.getTransportation(), Color.DIMGRAY, acc, registry, metas, triangleIndex); + appendFeatures(model.getVegetation(), Color.LIGHTGREEN, acc, registry, metas, triangleIndex); + appendFeatures(model.getWater(), Color.LIGHTSKYBLUE, acc, registry, metas, triangleIndex); + appendFeatures(model.getCityFurniture(), Color.BLUEVIOLET, acc, registry, metas, triangleIndex); + appendFeatures(model.getOtherCityObjects(), Color.BLACK, acc, registry, metas, triangleIndex); + + double[] centerArr = {center.getX(), center.getY(), center.getZ()}; + return new SceneData(acc.positions(), acc.colors(), acc.ids(), metas, registry, centerArr); + } + + private static void appendFeatures(Stream cos, Color color, + MeshAccumulator acc, PickRegistry registry, + List metas, int[] triangleIndex) { + CityObjectCollector collector = new CityObjectCollector(); + cos.forEach(cityObject -> cityObject.accept(collector)); + for (CityObject co : collector.getCityObjects()) { + for (Geometry geom : co.getGeometries()) { + int lod = geom.getLod() == null ? Lod.LOD2.ordinal() : geom.getLod().ordinal(); + for (Polygon p : geom.getPolygons()) { + if (p.isLink()) { + continue; + } + boolean roof = p.getPartOfSurface() != null + && p.getPartOfSurface().getType() == BoundarySurfaceType.ROOF; + appendPolygon(p, color, lod, roof, acc, registry, metas, triangleIndex); + } + } + } + } + + private static void appendPolygon(Polygon p, Color baseColor, int lod, boolean roof, + MeshAccumulator acc, PickRegistry registry, + List metas, int[] triangleIndex) { + Color base = chooseBase(p, baseColor); + Color shaded = ShadeColor.shade(p.calculateNormalNormalized(), base); + float r = (float) shaded.getRed(); + float g = (float) shaded.getGreen(); + float b = (float) shaded.getBlue(); + int id = registry.register(p); + + TesselatedPolygon tp = p.tesselate(); + for (Triangle3d t : tp.getTriangles()) { + acc.addTriangle(t, id, r, g, b); + metas.add(new TriangleMeta(triangleIndex[0]++, p, lod, roof)); + } + } + + private static Vector3d findCenterOfModel(CityDoctorModel model) { + double xMin = Double.MAX_VALUE, yMin = Double.MAX_VALUE, zMin = Double.MAX_VALUE; + double xMax = -Double.MAX_VALUE, yMax = -Double.MAX_VALUE, zMax = -Double.MAX_VALUE; + boolean any = false; + for (CityObject co : model.createFeatureStream().toList()) { + CityObjectCollector collector = new CityObjectCollector(); + co.accept(collector); + for (CityObject inner : collector.getCityObjects()) { + for (Geometry geom : inner.getGeometries()) { + for (Polygon p : geom.getPolygons()) { + if (p.isLink()) { + continue; + } + for (Vector3d v : p.getExteriorRing().getVertices()) { + xMin = Math.min(xMin, v.getX()); xMax = Math.max(xMax, v.getX()); + yMin = Math.min(yMin, v.getY()); yMax = Math.max(yMax, v.getY()); + zMin = Math.min(zMin, v.getZ()); zMax = Math.max(zMax, v.getZ()); + any = true; + } + } + } + } + } + if (!any) { + return new Vector3d(0, 0, 0); + } + return new Vector3d((xMax - xMin) / 2 + xMin, (yMax - yMin) / 2 + yMin, (zMax - zMin) / 2 + zMin); + } + + private static Color chooseBase(Polygon p, Color fallback) { + Color rc = p.getRenderColor(); + if (rc != null && !rc.equals(Color.WHITE)) { + return rc; + } + return fallback; + } + + private static Vector3d findCenter(Collection polygons) { + double xMin = Double.MAX_VALUE, yMin = Double.MAX_VALUE, zMin = Double.MAX_VALUE; + double xMax = -Double.MAX_VALUE, yMax = -Double.MAX_VALUE, zMax = -Double.MAX_VALUE; + for (Polygon p : polygons) { + for (Vector3d v : p.getExteriorRing().getVertices()) { + xMin = Math.min(xMin, v.getX()); xMax = Math.max(xMax, v.getX()); + yMin = Math.min(yMin, v.getY()); yMax = Math.max(yMax, v.getY()); + zMin = Math.min(zMin, v.getZ()); zMax = Math.max(zMax, v.getZ()); + } + } + if (xMin > xMax) { + return new Vector3d(0, 0, 0); + } + return new Vector3d((xMax - xMin) / 2 + xMin, (yMax - yMin) / 2 + yMin, (zMax - zMin) / 2 + zMin); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneData.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneData.java new file mode 100644 index 0000000000000000000000000000000000000000..5550c84bd6e161a7cf0cf2ee83046f2869587e45 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneData.java @@ -0,0 +1,32 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.List; + +/** Immutable CPU-side result of building the scene; uploaded to the GPU by the GL layer. */ +public class SceneData { + + private final float[] positions; + private final float[] colors; + private final int[] ids; + private final List triangleMetas; + private final PickRegistry pickRegistry; + private final double[] center; + + public SceneData(float[] positions, float[] colors, int[] ids, + List triangleMetas, PickRegistry pickRegistry, double[] center) { + this.positions = positions; + this.colors = colors; + this.ids = ids; + this.triangleMetas = triangleMetas; + this.pickRegistry = pickRegistry; + this.center = center; + } + + public float[] positions() { return positions; } + public float[] colors() { return colors; } + public int[] ids() { return ids; } + public List triangleMetas() { return triangleMetas; } + public PickRegistry pickRegistry() { return pickRegistry; } + public double[] center() { return center; } + public int triangleCount() { return triangleMetas.size(); } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColor.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColor.java new file mode 100644 index 0000000000000000000000000000000000000000..5bc415ff9dbf0c46366de65abee3ef62439eac37 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColor.java @@ -0,0 +1,25 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.math.UnitVector3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import javafx.scene.paint.Color; + +/** + * Computes the surface-differentiation shade used by the legacy renderer: brightness derived from the + * angle between the polygon normal and a fixed axis. Not physical lighting; only makes adjacent faces + * distinguishable. Ported from {@code TriangulatedGeometry.calculateMaterial}. + */ +public final class ShadeColor { + + private static final UnitVector3d AXIS = new Vector3d(19, 0.8, 1.5).normalize(); + + private ShadeColor() {} + + public static Color shade(Vector3d normal, Color base) { + double cos = normal.dot(AXIS); + // clamp to acos domain; raw dot can drift slightly outside [-1,1] for near-parallel normals + cos = Math.max(-1.0, Math.min(1.0, cos)); + double brightness = Math.acos(cos) / Math.PI * 0.6 + 0.3; // -> [0.3, 0.9] + return base.deriveColor(0, 1.0, brightness, 1.0); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java new file mode 100644 index 0000000000000000000000000000000000000000..cdcaa96e0b4e3ca8051bf4b92c2077930d9571c5 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java @@ -0,0 +1,95 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL3; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +/** Compiles and links a GLSL program from classpath resources next to this class. */ +public class ShaderProgram { + + private final int program; + + public ShaderProgram(GL3 gl, String vertexResource, String fragmentResource) { + this(gl, vertexResource, null, fragmentResource); + } + + /** Builds a program with an optional geometry stage ({@code geometryResource} may be null). */ + public ShaderProgram(GL3 gl, String vertexResource, String geometryResource, String fragmentResource) { + int vs = compile(gl, GL3.GL_VERTEX_SHADER, read(vertexResource)); + int gs = geometryResource != null ? compile(gl, GL3.GL_GEOMETRY_SHADER, read(geometryResource)) : 0; + int fs = compile(gl, GL3.GL_FRAGMENT_SHADER, read(fragmentResource)); + program = gl.glCreateProgram(); + gl.glAttachShader(program, vs); + if (gs != 0) { + gl.glAttachShader(program, gs); + } + gl.glAttachShader(program, fs); + gl.glLinkProgram(program); + checkLink(gl, program); + gl.glDeleteShader(vs); + if (gs != 0) { + gl.glDeleteShader(gs); + } + gl.glDeleteShader(fs); + } + + public void use(GL3 gl) { gl.glUseProgram(program); } + + public int uniform(GL3 gl, String name) { return gl.glGetUniformLocation(program, name); } + + public void dispose(GL3 gl) { gl.glDeleteProgram(program); } + + private static int compile(GL3 gl, int type, String src) { + int shader = gl.glCreateShader(type); + gl.glShaderSource(shader, 1, new String[]{src}, new int[]{src.length()}, 0); + gl.glCompileShader(shader); + int[] status = new int[1]; + gl.glGetShaderiv(shader, GL3.GL_COMPILE_STATUS, status, 0); + if (status[0] == 0) { + throw new IllegalStateException("Shader compile failed: " + infoLog(gl, shader, true)); + } + return shader; + } + + private static void checkLink(GL3 gl, int program) { + int[] status = new int[1]; + gl.glGetProgramiv(program, GL3.GL_LINK_STATUS, status, 0); + if (status[0] == 0) { + throw new IllegalStateException("Program link failed: " + infoLog(gl, program, false)); + } + } + + private static String infoLog(GL3 gl, int handle, boolean shader) { + int[] len = new int[1]; + if (shader) { + gl.glGetShaderiv(handle, GL3.GL_INFO_LOG_LENGTH, len, 0); + } else { + gl.glGetProgramiv(handle, GL3.GL_INFO_LOG_LENGTH, len, 0); + } + byte[] log = new byte[Math.max(1, len[0])]; + if (shader) { + gl.glGetShaderInfoLog(handle, log.length, new int[1], 0, log, 0); + } else { + gl.glGetProgramInfoLog(handle, log.length, new int[1], 0, log, 0); + } + return new String(log, StandardCharsets.UTF_8); + } + + private static String read(String resource) { + try (InputStream in = ShaderProgram.class.getResourceAsStream(resource)) { + if (in == null) { + throw new IllegalStateException("Shader resource not found: " + resource); + } + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return r.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java new file mode 100644 index 0000000000000000000000000000000000000000..b0e37256a19d92e1f40201d2afa6c1f1e92e870a --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java @@ -0,0 +1,12 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; + +/** + * Metadata for one triangle in the global buffer. + * @param triangleIndex index of this triangle (vertices are 3*triangleIndex .. +2) + * @param polygon source polygon (for picking/highlight resolution); may be null in tests + * @param lod LOD ordinal (Lod.ordinal()) of the owning geometry + * @param roof true if the triangle belongs to a ROOF boundary surface + */ +public record TriangleMeta(int triangleIndex, Polygon polygon, int lod, boolean roof) {} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.frag b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.frag new file mode 100644 index 0000000000000000000000000000000000000000..4b63a8ab08ae656dd2570031367d581c7c615e78 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.frag @@ -0,0 +1,10 @@ +#version 330 core +flat in int vId; +out vec4 fragColor; +void main() { + int id = vId; + float r = float( id & 0xFF) / 255.0; + float g = float((id >> 8) & 0xFF) / 255.0; + float b = float((id >> 16) & 0xFF) / 255.0; + fragColor = vec4(r, g, b, 1.0); +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.vert b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.vert new file mode 100644 index 0000000000000000000000000000000000000000..a07d1bfadb400b60e265dd8ff33939304169d371 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.vert @@ -0,0 +1,9 @@ +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 2) in int aId; +uniform mat4 uMVP; +flat out int vId; +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vId = aId; +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.frag b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.frag new file mode 100644 index 0000000000000000000000000000000000000000..291c4b40341f565e9e3a2b46c0f04d9ae6a3b303 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.frag @@ -0,0 +1,12 @@ +#version 330 core +in vec2 vQuad; +in vec3 vColor; +out vec4 fragColor; + +void main() { + float r2 = dot(vQuad, vQuad); + if (r2 > 1.0) { + discard; // outside the sphere's silhouette + } + fragColor = vec4(vColor, 1.0); +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.geom b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.geom new file mode 100644 index 0000000000000000000000000000000000000000..577258fb9a3cf81936a42bd779447fb34096e020 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.geom @@ -0,0 +1,27 @@ +#version 330 core +layout(points) in; +layout(triangle_strip, max_vertices = 4) out; + +in vec3 vColorG[]; + +uniform vec2 uViewport; // framebuffer size in pixels +uniform float uPointSize; // sphere diameter in pixels + +out vec2 vQuad; // billboard-local coords in [-1, 1] +out vec3 vColor; + +void main() { + vec4 c = gl_in[0].gl_Position; + if (c.w <= 0.0) { + return; // behind the camera + } + // half-extent in clip space for a constant on-screen pixel size + vec2 h = vec2(uPointSize / uViewport.x, uPointSize / uViewport.y) * c.w; + vColor = vColorG[0]; + + gl_Position = c + vec4(-h.x, -h.y, 0.0, 0.0); vQuad = vec2(-1.0, -1.0); EmitVertex(); + gl_Position = c + vec4( h.x, -h.y, 0.0, 0.0); vQuad = vec2( 1.0, -1.0); EmitVertex(); + gl_Position = c + vec4(-h.x, h.y, 0.0, 0.0); vQuad = vec2(-1.0, 1.0); EmitVertex(); + gl_Position = c + vec4( h.x, h.y, 0.0, 0.0); vQuad = vec2( 1.0, 1.0); EmitVertex(); + EndPrimitive(); +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.vert b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.vert new file mode 100644 index 0000000000000000000000000000000000000000..861e4ae9591decb52b696ac3593c32e4e62994d9 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.vert @@ -0,0 +1,9 @@ +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aColor; +uniform mat4 uMVP; +out vec3 vColorG; +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vColorG = aColor; +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag new file mode 100644 index 0000000000000000000000000000000000000000..e24db2327fa1ca82cade6df89a83c7ce1a67d308 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag @@ -0,0 +1,6 @@ +#version 330 core +in vec3 vColor; +out vec4 fragColor; +void main() { + fragColor = vec4(vColor, 1.0); +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert new file mode 100644 index 0000000000000000000000000000000000000000..58a19d1387488e34ce145e0f6f55386bba053f3b --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert @@ -0,0 +1,9 @@ +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aColor; +uniform mat4 uMVP; +out vec3 vColor; +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vColor = aColor; +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f7271974bd3899a64964ced5a7613c77a33bd33d --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java @@ -0,0 +1,36 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class FloatListTest { + + @Test + public void growsBeyondInitialCapacityAndPreservesOrder() { + FloatList list = new FloatList(2); + for (int i = 0; i < 10; i++) { + list.add(i); + } + assertEquals(10, list.size()); + float[] expected = {0,1,2,3,4,5,6,7,8,9}; + assertArrayEquals(expected, list.toArray(), 0.0f); + } + + @Test + public void addThreeAddsAllInOrder() { + FloatList list = new FloatList(1); + list.add(1f, 2f, 3f); + assertArrayEquals(new float[]{1,2,3}, list.toArray(), 0.0f); + } + + @Test + public void clearResetsSizeAndArray() { + FloatList list = new FloatList(4); + list.add(1f, 2f, 3f); + list.add(4f); + list.clear(); + assertEquals(0, list.size()); + assertEquals(0, list.toArray().length); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilderTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..3b073b234f896d792626082c6e61506c831c6b65 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilderTest.java @@ -0,0 +1,28 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; +import org.junit.Test; + +public class IndexSetBuilderTest { + + @Test + public void includesOnlyTrianglesMatchingThePredicate() { + List metas = Arrays.asList( + new TriangleMeta(0, null, 1, false), + new TriangleMeta(1, null, 2, false), + new TriangleMeta(2, null, 1, false)); + Predicate onlyGroup1 = m -> m.lod() == 1; + int[] indices = IndexSetBuilder.build(metas, onlyGroup1); + assertArrayEquals(new int[]{0, 1, 2, 6, 7, 8}, indices); + } + + @Test + public void emptyWhenNothingMatches() { + List metas = Arrays.asList(new TriangleMeta(0, null, 1, false)); + int[] indices = IndexSetBuilder.build(metas, m -> false); + assertArrayEquals(new int[]{}, indices); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4Test.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4Test.java new file mode 100644 index 0000000000000000000000000000000000000000..11b77cd44a0bd48f9ebb5da4996a912a07f7ab98 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4Test.java @@ -0,0 +1,45 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class Mat4Test { + + @Test + public void identityTimesVectorIsUnchanged() { + float[] v = Mat4.transform(Mat4.identity(), 2, 3, 4, 1); + assertArrayEquals(new float[]{2, 3, 4, 1}, v, 1e-6f); + } + + @Test + public void translationMovesPoint() { + float[] m = Mat4.translation(5, -2, 1); + float[] v = Mat4.transform(m, 1, 1, 1, 1); + assertArrayEquals(new float[]{6, -1, 2, 1}, v, 1e-6f); + } + + @Test + public void multiplyIsIdentityNeutral() { + float[] t = Mat4.translation(1, 2, 3); + assertArrayEquals(t, Mat4.multiply(Mat4.identity(), t), 1e-6f); + assertArrayEquals(t, Mat4.multiply(t, Mat4.identity()), 1e-6f); + } + + @Test + public void rotationZ90MapsXAxisToYAxis() { + float[] m = Mat4.rotationZ((float) (Math.PI / 2)); + float[] v = Mat4.transform(m, 1, 0, 0, 1); + assertEquals(0f, v[0], 1e-6f); + assertEquals(1f, v[1], 1e-6f); + } + + @Test + public void perspectiveHasExpectedDiagonalStructure() { + float[] p = Mat4.perspective((float) Math.toRadians(60), 1.0f, 1f, 100f); + float f = (float) (1.0 / Math.tan(Math.toRadians(60) / 2)); + assertEquals(f, p[0], 1e-5f); + assertEquals(f, p[5], 1e-5f); + assertEquals(-1f, p[11], 1e-6f); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulatorTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulatorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..31582d581c4b9378d64607418e4110052b22f06a --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulatorTest.java @@ -0,0 +1,36 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import org.junit.Test; + +public class MeshAccumulatorTest { + + @Test + public void emitsNineFloatsAndThreeIdsPerTriangleRecentered() { + MeshAccumulator acc = new MeshAccumulator(new Vector3d(1, 0, 0)); + Triangle3d t = new Triangle3d( + new Vector3d(1, 0, 0), new Vector3d(2, 0, 0), new Vector3d(1, 1, 0)); + acc.addTriangle(t, 7, 0.5f, 0.25f, 0.125f); + + assertEquals(3, acc.vertexCount()); + assertArrayEquals(new float[]{0,0,0, 1,0,0, 0,1,0}, acc.positions(), 1e-6f); + assertArrayEquals(new float[]{0.5f,0.25f,0.125f, 0.5f,0.25f,0.125f, 0.5f,0.25f,0.125f}, + acc.colors(), 1e-6f); + assertArrayEquals(new int[]{7, 7, 7}, acc.ids()); + } + + @Test + public void accumulatesMultipleTriangles() { + MeshAccumulator acc = new MeshAccumulator(new Vector3d(0, 0, 0)); + Triangle3d t = new Triangle3d( + new Vector3d(0, 0, 0), new Vector3d(1, 0, 0), new Vector3d(0, 1, 0)); + acc.addTriangle(t, 1, 1, 1, 1); + acc.addTriangle(t, 2, 0, 0, 0); + assertEquals(6, acc.vertexCount()); + assertEquals(18, acc.positions().length); + assertArrayEquals(new int[]{1, 1, 1, 2, 2, 2}, acc.ids()); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCameraTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCameraTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5b664bcd059813f971240ee18e9889fe3977a986 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCameraTest.java @@ -0,0 +1,51 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class OrbitCameraTest { + + private static float[] ndc(OrbitCamera cam, float x, float y, float z) { + float[] clip = Mat4.transform(cam.viewProjection(), x, y, z, 1); + return new float[]{clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]}; + } + + @Test + public void focusPointProjectsToScreenCentre() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.zoomOutForBoundingBox(50.0); + float[] p = ndc(cam, 0, 0, 0); + assertEquals(0f, p[0], 1e-4f); + assertEquals(0f, p[1], 1e-4f); + assertTrue("origin in front of camera (within clip)", p[2] > -1f && p[2] < 1f); + } + + @Test + public void zoomingKeepsFocusCentredButChangesDepth() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.zoomOutForBoundingBox(50.0); + float depthBefore = ndc(cam, 0, 0, 0)[2]; + cam.zoom(1.5); + float[] p = ndc(cam, 0, 0, 0); + assertEquals(0f, p[0], 1e-4f); + assertEquals(0f, p[1], 1e-4f); + assertTrue("depth changes when zooming", Math.abs(p[2] - depthBefore) > 1e-4f); + } + + @Test + public void resetRestoresInitialDistanceAndAngles() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.orbit(40, 25); + cam.pan(10, -5); + cam.zoom(2.0); + cam.reset(); + float[] before = cam.viewProjection(); + OrbitCamera fresh = new OrbitCamera(); + fresh.setAspect(1.0f); + org.junit.Assert.assertArrayEquals(fresh.viewProjection(), before, 1e-5f); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistryTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistryTest.java new file mode 100644 index 0000000000000000000000000000000000000000..51135fafef12d6a2cb49098f62e240eb2e566701 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistryTest.java @@ -0,0 +1,29 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import org.junit.Test; + +public class PickRegistryTest { + + @Test + public void assignsSequentialNonZeroIdsAndResolvesThem() { + PickRegistry reg = new PickRegistry(); + Object a = "a"; + Object b = "b"; + int idA = reg.register(a); + int idB = reg.register(b); + assertEquals(1, idA); + assertEquals(2, idB); + assertEquals(a, reg.resolve(idA)); + assertEquals(b, reg.resolve(idB)); + } + + @Test + public void resolvesBackgroundAndOutOfRangeToNull() { + PickRegistry reg = new PickRegistry(); + reg.register("a"); + assertNull(reg.resolve(0)); + assertNull(reg.resolve(99)); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodecTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodecTest.java new file mode 100644 index 0000000000000000000000000000000000000000..c61d95e753859f8a5b62f4fe346a12ef8f5a306d --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodecTest.java @@ -0,0 +1,23 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class PickingCodecTest { + + @Test + public void roundTripsIdsThroughRgbBytes() { + int[] ids = {1, 255, 256, 65535, 65536, 16777215}; + for (int id : ids) { + int r = PickingCodec.red(id); + int g = PickingCodec.green(id); + int b = PickingCodec.blue(id); + assertEquals(id, PickingCodec.decode(r, g, b)); + } + } + + @Test + public void blackDecodesToZeroBackground() { + assertEquals(0, PickingCodec.decode(0, 0, 0)); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilderTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e1ddfe45fbeb8355091573f4917706f18fea253c --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilderTest.java @@ -0,0 +1,64 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel; +import de.hft.stuttgart.citydoctor2.datastructure.Geometry; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; +import de.hft.stuttgart.citydoctor2.parser.CityGmlParser; +import de.hft.stuttgart.citydoctor2.parser.ParserConfiguration; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.paint.Color; +import org.junit.BeforeClass; +import org.junit.Test; + +public class SceneBuilderTest { + + private static List polygons; + + @BeforeClass + public static void loadFixture() throws Exception { + ParserConfiguration config = new ParserConfiguration(8, false); + CityDoctorModel model = CityGmlParser.parseCityGmlFile( + "src/test/resources/SimpleSolid_SrefBS-GE-gml-LR-0001-T0001.gml", config); + polygons = new ArrayList<>(); + model.getBuildings().forEach(b -> { + for (Geometry g : b.getGeometries()) { + for (Polygon p : g.getPolygons()) { + if (!p.isLink()) { + polygons.add(p); + } + } + } + for (de.hft.stuttgart.citydoctor2.datastructure.BoundarySurface bs : b.getBoundarySurfaces()) { + for (Geometry g : bs.getGeometries()) { + for (Polygon p : g.getPolygons()) { + if (!p.isLink()) { + polygons.add(p); + } + } + } + } + }); + assertTrue("fixture should contain polygons", !polygons.isEmpty()); + } + + @Test + public void buildsConsistentBufferLengths() { + SceneData data = SceneBuilder.fromPolygons(polygons, Color.WHITE); + int vertices = data.positions().length / 3; + assertEquals(vertices * 3, data.colors().length); + assertEquals(vertices, data.ids().length); + assertEquals(vertices, data.triangleCount() * 3); + } + + @Test + public void everyPolygonGetsAPickingIdResolvingToAPolygon() { + SceneData data = SceneBuilder.fromPolygons(polygons, Color.WHITE); + for (int id : data.ids()) { + assertTrue(data.pickRegistry().resolve(id) instanceof Polygon); + } + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneDataTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneDataTest.java new file mode 100644 index 0000000000000000000000000000000000000000..48632ccde686deb7c1289a9db6a7736059d4fb59 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneDataTest.java @@ -0,0 +1,24 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import java.util.List; +import org.junit.Test; + +public class SceneDataTest { + + @Test + public void exposesItsBuffersAndRegistry() { + float[] pos = {0,0,0, 1,0,0, 0,1,0}; + float[] col = {1,1,1, 1,1,1, 1,1,1}; + int[] ids = {1,1,1}; + PickRegistry reg = new PickRegistry(); + List metas = List.of(new TriangleMeta(0, null, 2, false)); + SceneData data = new SceneData(pos, col, ids, metas, reg, new double[]{5, 6, 7}); + + assertSame(pos, data.positions()); + assertEquals(1, data.triangleMetas().size()); + assertEquals(6.0, data.center()[1], 0.0); + assertSame(reg, data.pickRegistry()); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColorTest.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColorTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b537085041754dd9341fdae8a5a1d64542124c77 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColorTest.java @@ -0,0 +1,37 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import javafx.scene.paint.Color; +import org.junit.Test; + +public class ShadeColorTest { + + @Test + public void brightnessStaysWithinExpectedBand() { + // Shade brightness is acos(normal.AXIS)/PI * 0.6 + 0.3 -> always within [0.3, 0.9]. + Vector3d[] normals = { + new Vector3d(1, 0, 0), new Vector3d(0, 1, 0), new Vector3d(0, 0, 1), + new Vector3d(-1, 0, 0), new Vector3d(0.3, -0.7, 0.5) + }; + for (Vector3d n : normals) { + Color c = ShadeColor.shade(n.normalize(), Color.WHITE); + assertTrue("brightness >= 0.3", c.getBrightness() >= 0.3 - 1e-6); + assertTrue("brightness <= 0.9", c.getBrightness() <= 0.9 + 1e-6); + } + } + + @Test + public void matchesLegacyFormulaForKnownNormal() { + Vector3d axis = new Vector3d(19, 0.8, 1.5).normalize(); + Vector3d normal = new Vector3d(0, 0, 1).normalize(); + double cos = Math.max(-1.0, Math.min(1.0, normal.dot(axis))); + double shade = Math.acos(cos) / Math.PI * 0.6 + 0.3; + Color expected = Color.WHITE.deriveColor(0, 1.0, shade, 1.0); + Color actual = ShadeColor.shade(normal, Color.WHITE); + assertEquals(expected.getRed(), actual.getRed(), 1e-9); + assertEquals(expected.getGreen(), actual.getGreen(), 1e-9); + assertEquals(expected.getBlue(), actual.getBlue(), 1e-9); + } +} diff --git a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerController.java b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerController.java index 31627f7aa8863d641edb5ff6374c3f66047e0d9c..8680551f41a601a9e1295643de15619bdb4599a3 100644 --- a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerController.java +++ b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerController.java @@ -31,15 +31,16 @@ import de.hft.stuttgart.citydoctor2.datastructure.Opening; import de.hft.stuttgart.citydoctor2.datastructure.Polygon; import de.hft.stuttgart.citydoctor2.gui.HighlightController; import de.hft.stuttgart.citydoctor2.gui.ListErrorVisitor; -import de.hft.stuttgart.citydoctor2.gui.TriangulatedGeometry; +import de.hft.stuttgart.citydoctor2.gui.gl.IndexSetBuilder; +import de.hft.stuttgart.citydoctor2.gui.gl.SceneBuilder; +import de.hft.stuttgart.citydoctor2.gui.gl.SceneData; import de.hft.stuttgart.citydoctor2.healer.Healer; import de.hft.stuttgart.citydoctor2.optimization.MeshGenerator; import de.hft.stuttgart.citydoctor2.utils.Copy; import javafx.collections.ObservableList; import javafx.scene.control.TextInputDialog; import javafx.scene.control.TreeItem; -import javafx.scene.shape.CullFace; -import javafx.scene.shape.DrawMode; +import javafx.scene.paint.Color; public class HealerController { @@ -53,11 +54,11 @@ public class HealerController { private Geometry nextGeometry; private Checker checker; - private CullFace culling = CullFace.BACK; - private DrawMode drawMode = DrawMode.FILL; + private boolean culling = false; + private boolean wireframe = false; - private TriangulatedGeometry nextTriangulatedGeometry; - private TriangulatedGeometry currentTriangulatedGeometry; + private SceneData currentScene; + private SceneData nextScene; private ListErrorVisitor currentErrorVisitor; private ListErrorVisitor nextErrorVisitor; @@ -69,14 +70,54 @@ public class HealerController { public HealerController(HealerView healerView) { this.healerView = healerView; - this.currentHighlights = new HighlightController(healerView.getCurrentWorld()); - this.nextHighlights = new HighlightController(healerView.getNextWorld()); + this.currentHighlights = new HighlightController(healerView.getCurrentViewport()); + this.nextHighlights = new HighlightController(healerView.getNextViewport()); + currentHighlights.setCenterSupplier(() -> currentScene == null ? new double[3] : currentScene.center()); + nextHighlights.setCenterSupplier(() -> nextScene == null ? new double[3] : nextScene.center()); currentErrorVisitor = new ListErrorVisitor(currentHighlights); nextErrorVisitor = new ListErrorVisitor(nextHighlights); - healerView.translateZProperty().addListener((obs, oldV, newV) -> { - currentHighlights.changeScaling(-newV.doubleValue()); - nextHighlights.changeScaling(-newV.doubleValue()); - }); + } + + /** Installs the current scene into the current viewport and applies the current view options. */ + private void showCurrentScene() { + currentHighlights.clearHighlights(); + healerView.getCurrentViewport().setScene(currentScene); + // the viewport draws nothing until it is told which triangles are visible; the healer has no + // LOD/roof filtering, so show every triangle of the scene + healerView.getCurrentViewport().setIndexSet(allTriangles(currentScene)); + healerView.getCurrentViewport().setWireframe(wireframe); + healerView.getCurrentViewport().setCulling(culling); + } + + /** Installs the next scene into the next viewport and applies the current view options. */ + private void showNextScene() { + nextHighlights.clearHighlights(); + healerView.getNextViewport().setScene(nextScene); + // the viewport draws nothing until it is told which triangles are visible; the healer has no + // LOD/roof filtering, so show every triangle of the scene + healerView.getNextViewport().setIndexSet(allTriangles(nextScene)); + healerView.getNextViewport().setWireframe(wireframe); + healerView.getNextViewport().setCulling(culling); + } + + /** Builds an index set covering every triangle of the scene (no filtering). */ + private static int[] allTriangles(SceneData scene) { + if (scene == null) { + return new int[0]; + } + return IndexSetBuilder.build(scene.triangleMetas(), m -> true); + } + + private void clearCurrentScene() { + currentScene = null; + currentHighlights.clearHighlights(); + healerView.getCurrentViewport().clearScene(); + } + + private void clearNextScene() { + nextScene = null; + nextHighlights.clearHighlights(); + healerView.getNextViewport().clearScene(); } public void showSimplyfication() { @@ -91,8 +132,8 @@ public class HealerController { public void setCurrentModel(CityDoctorModel model) { if (this.model != model) { - healerView.getCurrentMeshGroup().getChildren().clear(); - healerView.getNextMeshGroup().getChildren().clear(); + clearCurrentScene(); + clearNextScene(); healerView.getCurrentErrorList().getItems().clear(); } this.model = model; @@ -274,18 +315,14 @@ public class HealerController { SelectableGeometry selectedGeometry = (SelectableGeometry) selectedItem; currentGeometry = selectedGeometry.getGeometry(); currentFeature = selectedGeometry.getFeature(); - currentTriangulatedGeometry = TriangulatedGeometry.of(currentGeometry); - currentErrorVisitor.setGeometry(currentTriangulatedGeometry); - currentTriangulatedGeometry.setCullFace(culling); - currentTriangulatedGeometry.setDrawMode(drawMode); + currentScene = SceneBuilder.fromPolygons(currentGeometry.getPolygons(), Color.WHITE); healerView.setSelectedFeatureText(currentFeature.getGmlId().getGmlString()); healerView.setFeatureTypeText(currentFeature.getFeatureType().toString()); healerView.setGeometryText(currentGeometry.getLod().toString() + ", " + currentGeometry.getType().toString()); updateCurrentErrors(); healerView.zoomOutForBoundingBox(currentGeometry.calculateBoundingBox()); - healerView.getCurrentMeshGroup().getChildren().clear(); - healerView.getNextMeshGroup().getChildren().clear(); - healerView.getCurrentMeshGroup().getChildren().addAll(currentTriangulatedGeometry.getMeshes()); + showCurrentScene(); + clearNextScene(); healerView.getNextStepBtn().setDisable(false); healerView.getHealCompleteBtn().setDisable(false); healerView.getAcceptBtn().setDisable(true); @@ -322,12 +359,8 @@ public class HealerController { } private void updateNextGeometryView() { - nextTriangulatedGeometry = TriangulatedGeometry.of(nextGeometry); - nextErrorVisitor.setGeometry(nextTriangulatedGeometry); - nextTriangulatedGeometry.setCullFace(culling); - nextTriangulatedGeometry.setDrawMode(drawMode); - healerView.getNextMeshGroup().getChildren().clear(); - healerView.getNextMeshGroup().getChildren().addAll(nextTriangulatedGeometry.getMeshes()); + nextScene = SceneBuilder.fromPolygons(nextGeometry.getPolygons(), Color.WHITE); + showNextScene(); healerView.getAcceptBtn().setDisable(false); healerView.getCancelBtn().setDisable(false); healerView.getSolidInjectorBtn().setDisable(true); @@ -346,12 +379,8 @@ public class HealerController { Healer healer = new Healer(checker); healer.heal(nextGeometry, nextFeature); updateNextErrors(); - nextTriangulatedGeometry = TriangulatedGeometry.of(nextGeometry); - nextErrorVisitor.setGeometry(nextTriangulatedGeometry); - nextTriangulatedGeometry.setCullFace(culling); - nextTriangulatedGeometry.setDrawMode(drawMode); - healerView.getNextMeshGroup().getChildren().clear(); - healerView.getNextMeshGroup().getChildren().addAll(nextTriangulatedGeometry.getMeshes()); + nextScene = SceneBuilder.fromPolygons(nextGeometry.getPolygons(), Color.WHITE); + showNextScene(); healerView.getAcceptBtn().setDisable(false); healerView.getNextStepBtn().setDisable(true); healerView.getHealCompleteBtn().setDisable(true); @@ -414,14 +443,9 @@ public class HealerController { currentGeometry = nextGeometry; updateCurrentErrors(); healerView.getNextErrorList().getItems().clear(); - healerView.getCurrentMeshGroup().getChildren().clear(); - healerView.getCurrentMeshGroup().getChildren().addAll(nextTriangulatedGeometry.getMeshes()); - currentTriangulatedGeometry = nextTriangulatedGeometry; - currentErrorVisitor.setGeometry(currentTriangulatedGeometry); - nextTriangulatedGeometry = null; -// nextHighlights = new HighlightController(healerView.getNextWorld()); -// nextErrorVisitor = new ListErrorVisitor(nextHighlights); - healerView.getNextMeshGroup().getChildren().clear(); + currentScene = nextScene; + showCurrentScene(); + clearNextScene(); healerView.getHealCompleteBtn().setDisable(false); healerView.getNextStepBtn().setDisable(false); healerView.getAcceptBtn().setDisable(true); @@ -429,27 +453,15 @@ public class HealerController { } public void setWireframe(boolean b) { - if (b) { - drawMode = DrawMode.LINE; - } else { - drawMode = DrawMode.FILL; - } - if (currentTriangulatedGeometry != null) { - currentTriangulatedGeometry.setDrawMode(drawMode); - } - if (nextTriangulatedGeometry != null) { - nextTriangulatedGeometry.setDrawMode(drawMode); - } + this.wireframe = b; + healerView.getCurrentViewport().setWireframe(b); + healerView.getNextViewport().setWireframe(b); } - public void setCulling(CullFace culling) { + public void setCulling(boolean culling) { this.culling = culling; - if (currentTriangulatedGeometry != null) { - currentTriangulatedGeometry.setCullFace(culling); - } - if (nextTriangulatedGeometry != null) { - nextTriangulatedGeometry.setCullFace(culling); - } + healerView.getCurrentViewport().setCulling(culling); + healerView.getNextViewport().setCulling(culling); } public void cancelClicked() { @@ -457,14 +469,13 @@ public class HealerController { return; } healerView.getNextErrorList().getItems().clear(); - healerView.getNextMeshGroup().getChildren().clear(); + clearNextScene(); healerView.getAcceptBtn().setDisable(true); healerView.getCancelBtn().setDisable(true); healerView.getNextStepBtn().setDisable(false); healerView.getHealCompleteBtn().setDisable(false); nextFeature = null; nextGeometry = null; - nextTriangulatedGeometry = null; } public void currentHighlight(CheckError err) { @@ -527,8 +538,8 @@ public class HealerController { healerView.getHealCompleteBtn().setDisable(true); healerView.getCurrentErrorList().getItems().clear(); healerView.getNextErrorList().getItems().clear(); - healerView.getCurrentMeshGroup().getChildren().clear(); - healerView.getNextMeshGroup().getChildren().clear(); + clearCurrentScene(); + clearNextScene(); healerView.setSelectedFeatureText(""); healerView.setFeatureTypeText(""); healerView.setGeometryText(""); diff --git a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerToolbar.java b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerToolbar.java index c94cfe66344596b8db05a0bd89491ea1a2e433ea..bb4b6d047d54b0960910ada6e8bed73fe7b7b44c 100644 --- a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerToolbar.java +++ b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerToolbar.java @@ -13,7 +13,6 @@ import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; -import javafx.scene.shape.CullFace; public class HealerToolbar { @@ -63,13 +62,7 @@ public class HealerToolbar { wireframeImage.setImage(new Image(MainToolBar.class.getResourceAsStream("icons/wireframe32x32.png"))); simplyImageView.setImage(new Image(HealerToolbar.class.getResourceAsStream("autopro32x32.png"))); wireframeBtn.selectedProperty().addListener((obs, oldV, newV) -> controller.setWireframe(newV)); - cullingBtn.selectedProperty().addListener((obs, oldV, newV) -> { - if (Boolean.TRUE.equals(newV)) { - controller.setCulling(CullFace.BACK); - } else { - controller.setCulling(CullFace.NONE); - } - }); + cullingBtn.selectedProperty().addListener((obs, oldV, newV) -> controller.setCulling(Boolean.TRUE.equals(newV))); saveBtn.setOnAction(ae -> cdController.askAndSave()); saveBtn.setDisable(true); saveImage.setImage(new Image(MainWindow.class.getResourceAsStream("icons/save.png"))); diff --git a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerView.java b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerView.java index d91de2a4aece2aac87145445377ab9712ffc784c..25556f175b87b5a4c3fe4411415170490f218e1e 100644 --- a/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerView.java +++ b/CityDoctorParent/Extensions/CityDoctorHealerGUI/src/main/java/de/hft/stuttgart/citydoctor2/healer/gui/HealerView.java @@ -13,16 +13,10 @@ import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel; import de.hft.stuttgart.citydoctor2.gui.FilterPane; import de.hft.stuttgart.citydoctor2.gui.MainWindow; import de.hft.stuttgart.citydoctor2.gui.View; -import javafx.beans.property.DoubleProperty; -import javafx.beans.property.SimpleDoubleProperty; +import de.hft.stuttgart.citydoctor2.gui.gl.GLViewport; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; -import javafx.scene.AmbientLight; -import javafx.scene.Group; import javafx.scene.Node; -import javafx.scene.PerspectiveCamera; -import javafx.scene.SceneAntialiasing; -import javafx.scene.SubScene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ListView; @@ -30,11 +24,9 @@ import javafx.scene.control.Tooltip; import javafx.scene.control.cell.TextFieldListCell; import javafx.scene.image.Image; import javafx.scene.image.ImageView; -import javafx.scene.input.MouseButton; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; -import javafx.scene.paint.Color; -import javafx.scene.transform.Rotate; +import javafx.scene.layout.Region; import javafx.stage.Window; import javafx.util.StringConverter; @@ -42,10 +34,6 @@ public class HealerView extends View { private static final Logger logger = LogManager.getLogger(HealerView.class); - private static final double CAMERA_TRANSLATE_Z = -100.0; - private static final double CAMERA_INITIAL_X_ANGLE = 20.0; - private static final double CAMERA_INITIAL_Y_ANGLE = 120.0; - private Image image; private Node healerWindow; @@ -55,13 +43,6 @@ public class HealerView extends View { private ChooseFeatureDialog chooseFeatureDialog; private HealerToolbar toolBar; - private DoubleProperty translateZ; - private DoubleProperty cameraXRot; - private DoubleProperty cameraYRot; - - private double dragX; - private double dragY; - @FXML private Button fixBtn; @@ -121,11 +102,9 @@ public class HealerView extends View { @FXML private Button meshBtn; - - private Group currentWorld; - private Group nextWorld; - private Group currentMeshGroup; - private Group nextMeshGroup; + + private GLViewport currentViewport; + private GLViewport nextViewport; public HealerView() { image = new Image(getClass().getResourceAsStream("healing.png")); @@ -198,104 +177,40 @@ public class HealerView extends View { } private void setup3dViews() { - translateZ = new SimpleDoubleProperty(CAMERA_TRANSLATE_Z); - cameraXRot = new SimpleDoubleProperty(CAMERA_INITIAL_X_ANGLE); - cameraYRot = new SimpleDoubleProperty(CAMERA_INITIAL_Y_ANGLE); - - buildCurrent3dView(); - buildNext3dView(); - } - - private void buildCurrent3dView() { - Group currentRoot = new Group(); - SubScene currentScene = new SubScene(currentRoot, 500, 300, true, SceneAntialiasing.BALANCED); - currentScene.heightProperty().bind(currentCanvas.heightProperty()); - currentScene.widthProperty().bind(currentCanvas.widthProperty()); - currentScene.setFill(Color.AZURE); - currentCanvas.getChildren().add(currentScene); - currentWorld = new Group(); - currentRoot.getChildren().add(currentWorld); - currentMeshGroup = new Group(); - currentWorld.getChildren().add(currentMeshGroup); - - AmbientLight al = new AmbientLight(Color.WHITE); - currentRoot.getChildren().add(al); - - PerspectiveCamera currentCamera = new PerspectiveCamera(true); - currentCamera.setNearClip(0.1); - currentCamera.setFarClip(10000d); - currentCamera.translateZProperty().bind(translateZ); - Rotate cameraZRotation = new Rotate(); - Rotate cameraXRotation = new Rotate(); - cameraXRotation.setAxis(Rotate.X_AXIS); - cameraZRotation.setAxis(Rotate.Z_AXIS); - cameraZRotation.angleProperty().bind(cameraXRot); - cameraXRotation.angleProperty().bind(cameraYRot); - currentWorld.getTransforms().add(cameraXRotation); - currentWorld.getTransforms().add(cameraZRotation); - currentRoot.getChildren().add(currentCamera); - currentScene.setCamera(currentCamera); - setupMeshViewControls(currentCanvas); - } - - private void buildNext3dView() { - Group nextRoot = new Group(); - SubScene nextScene = new SubScene(nextRoot, 500, 300, true, SceneAntialiasing.BALANCED); - nextScene.heightProperty().bind(nextCanvas.heightProperty()); - nextScene.widthProperty().bind(nextCanvas.widthProperty()); - nextScene.setFill(Color.AZURE); - nextCanvas.getChildren().add(nextScene); - nextWorld = new Group(); - nextRoot.getChildren().add(nextWorld); - nextMeshGroup = new Group(); - nextWorld.getChildren().add(nextMeshGroup); - - AmbientLight al = new AmbientLight(Color.WHITE); - nextRoot.getChildren().add(al); - - PerspectiveCamera nextCamera = new PerspectiveCamera(true); - nextCamera.setNearClip(0.1); - nextCamera.setFarClip(10000d); - nextCamera.translateZProperty().bind(translateZ); - Rotate cameraZRotation = new Rotate(); - Rotate cameraXRotation = new Rotate(); - cameraXRotation.setAxis(Rotate.X_AXIS); - cameraZRotation.setAxis(Rotate.Z_AXIS); - cameraZRotation.angleProperty().bind(cameraXRot); - cameraXRotation.angleProperty().bind(cameraYRot); - nextWorld.getTransforms().add(cameraXRotation); - nextWorld.getTransforms().add(cameraZRotation); - nextRoot.getChildren().add(nextCamera); - nextScene.setCamera(nextCamera); - setupMeshViewControls(nextCanvas); + currentViewport = new GLViewport(); + nextViewport = new GLViewport(); + attachViewport(currentCanvas, currentViewport); + attachViewport(nextCanvas, nextViewport); + coupleViewports(); + } + + private void attachViewport(Pane canvas, GLViewport viewport) { + Region node = viewport.getNode(); + node.prefWidthProperty().bind(canvas.widthProperty()); + node.prefHeightProperty().bind(canvas.heightProperty()); + canvas.getChildren().add(node); + } + + /** + * Keeps the two viewports' cameras in sync: orbiting/panning/zooming one mirrors the camera into + * the other and redraws it, reproducing the legacy shared-camera behaviour of the dual SubScenes. + */ + private void coupleViewports() { + currentViewport.setInteractionListener(() -> { + nextViewport.camera().copyOrbitFrom(currentViewport.camera()); + nextViewport.requestRedraw(); + }); + nextViewport.setInteractionListener(() -> { + currentViewport.camera().copyOrbitFrom(nextViewport.camera()); + currentViewport.requestRedraw(); + }); } public void zoomOutForBoundingBox(BoundingBox b) { - double longestSide = b.getDiagonalLength() * 0.4; - double d = longestSide / Math.tan(Math.toRadians(30) / 2); - translateZ.set(-d); - } - - private void setupMeshViewControls(Pane canvas) { - canvas.setOnMousePressed(me -> { - if (me.getButton() == MouseButton.PRIMARY) { - dragX = me.getScreenX(); - dragY = me.getScreenY(); - } - }); - - canvas.setOnScroll(se -> translateZ.set(translateZ.get() + se.getDeltaY() / 10d)); - - canvas.setOnMouseDragged(me -> { - if (me.getButton() == MouseButton.PRIMARY) { - double deltaX = me.getScreenX() - dragX; - double deltaY = me.getScreenY() - dragY; - dragX = me.getScreenX(); - dragY = me.getScreenY(); - cameraXRot.set(cameraXRot.get() + ((deltaX / 3d) % 360)); - cameraYRot.set(cameraYRot.get() + ((deltaY / 3d) % 360)); - } - }); + currentViewport.camera().zoomOutForBoundingBox(b.getDiagonalLength()); + nextViewport.camera().copyOrbitFrom(currentViewport.camera()); + currentViewport.requestRedraw(); + nextViewport.requestRedraw(); } @Override @@ -330,12 +245,12 @@ public class HealerView extends View { return chooseFeatureDialog; } - public Group getCurrentWorld() { - return currentWorld; + public GLViewport getCurrentViewport() { + return currentViewport; } - public Group getNextWorld() { - return nextWorld; + public GLViewport getNextViewport() { + return nextViewport; } public void setSelectedFeatureText(String text) { @@ -378,18 +293,6 @@ public class HealerView extends View { return cancelButton; } - public DoubleProperty translateZProperty() { - return translateZ; - } - - public Group getNextMeshGroup() { - return nextMeshGroup; - } - - public Group getCurrentMeshGroup() { - return currentMeshGroup; - } - public Button getSolidInjectorBtn() { return solidInjectorBtn; } diff --git a/CityDoctorParent/pom.xml b/CityDoctorParent/pom.xml index 24549983e3dea94dd13b0e3ec7a9ffcde87f1921..c32b722d51f12cac4c3fa48ac57fce7284e743fe 100644 --- a/CityDoctorParent/pom.xml +++ b/CityDoctorParent/pom.xml @@ -28,6 +28,11 @@ sonartype https://oss.sonatype.org/content/repositories/snapshots/ + + + jogamp + https://jogamp.org/deployment/maven/ + @@ -168,7 +173,7 @@ org.jogamp.gluegen gluegen-rt-main - 2.3.2 + 2.5.0 gov.nist.math @@ -178,7 +183,7 @@ org.jogamp.jogl jogl-all-main - 2.3.2 + 2.5.0 org.apache.logging.log4j diff --git a/docs/superpowers/plans/2026-06-18-opengl-3d-view.md b/docs/superpowers/plans/2026-06-18-opengl-3d-view.md new file mode 100644 index 0000000000000000000000000000000000000000..148fedbed22ff763315e58158650e64701ecf6da --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-opengl-3d-view.md @@ -0,0 +1,2585 @@ +# OpenGL 3D View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the JavaFX `SubScene`/`MeshView` 3D view in `CityDoctorGUI` with an OpenGL-rendered viewport (openglfx-jogl, GL 3.3 core) that orbits/zooms/pans smoothly on whole-city models. + +**Architecture:** A single global static VBO holds all triangle vertices (`position`, baked-shade `color`, `polygonId`); one `glDrawElements` per frame draws an index set rebuilt on filter/LOD changes. GPU color-picking (off-screen FBO + readback) resolves clicks to polygons/vertices/edges. Selection is a shader `selectedId` tint; vertex/edge highlight markers live in a small dynamic overlay buffer. The pure math/data core is unit-tested (TDD); the GL/openglfx glue is verified by running the app. + +**Tech Stack:** Java 17, JavaFX, JUnit 4, JOGL (`com.jogamp.opengl.GL3`), openglfx 4.1.19 (`com.huskerdev.openglfx.canvas.GLCanvas`, `com.huskerdev.openglfx.jogl.JOGLExecutor`, `com.huskerdev.grapl.gl.GLProfile`), Maven. + +**Key conventions:** +- New code lives in package `de.hft.stuttgart.citydoctor2.gui.gl` under + `CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/`. +- Tests mirror under `.../src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/`. +- Shaders live under `CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/`. +- All Maven commands run from `CityDoctorParent/`. First run uses `-am` to build dependency modules; + later runs may add `-o` (offline) if local `.m2` is populated. +- Test a single class with: + `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=ClassName test` +- JUnit 4 (`org.junit.Test`, `org.junit.Assert.*`) — matches the existing `junit` dependency. + +**openglfx API reference (verified against 4.1.19 in local `.m2`):** +- `GLCanvas` extends `javafx.scene.layout.Region`. Construct: `new GLCanvas(new JOGLExecutor(), GLProfile.CORE)`. +- `canvas.addOnRenderEvent(Consumer)`, `addOnReshapeEvent`, `addOnInitEvent`, `addOnDisposeEvent`. +- In the render consumer, cast: `JOGLRenderEvent e = (JOGLRenderEvent) ev;` then `e.getGl()` → `GL3`, + `e.width`, `e.height`, `e.fbo` (public fields). **openglfx binds `e.fbo` as the draw target before + calling.** After using our own FBO (picking) we must rebind `e.fbo`. + +--- + +## Phase 1 — Pure core (TDD) + +These tasks have no GL dependency and run fully headless. + +### Task 1: Primitive growable arrays (`FloatList`, `IntList`) + +Needed so the mesh builder can accumulate millions of floats/ints without boxing. + +**Files:** +- Create: `.../gui/gl/FloatList.java` +- Create: `.../gui/gl/IntList.java` +- Test: `.../test/.../gui/gl/FloatListTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class FloatListTest { + + @Test + public void growsBeyondInitialCapacityAndPreservesOrder() { + FloatList list = new FloatList(2); + for (int i = 0; i < 10; i++) { + list.add(i); + } + assertEquals(10, list.size()); + float[] expected = {0,1,2,3,4,5,6,7,8,9}; + assertArrayEquals(expected, list.toArray(), 0.0f); + } + + @Test + public void addThreeAddsAllInOrder() { + FloatList list = new FloatList(1); + list.add(1f, 2f, 3f); + assertArrayEquals(new float[]{1,2,3}, list.toArray(), 0.0f); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=FloatListTest test` +Expected: compilation failure / FAIL (`FloatList` does not exist). + +- [ ] **Step 3: Implement `FloatList` and `IntList`** + +```java +// FloatList.java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.Arrays; + +/** Growable primitive float array to avoid boxing when building large vertex buffers. */ +public class FloatList { + private float[] data; + private int size; + + public FloatList() { this(1024); } + public FloatList(int initialCapacity) { data = new float[Math.max(1, initialCapacity)]; } + + public void add(float v) { + ensure(size + 1); + data[size++] = v; + } + + public void add(float a, float b, float c) { + ensure(size + 3); + data[size++] = a; + data[size++] = b; + data[size++] = c; + } + + private void ensure(int capacity) { + if (capacity > data.length) { + int newCap = data.length; + while (newCap < capacity) { + newCap <<= 1; + } + data = Arrays.copyOf(data, newCap); + } + } + + public int size() { return size; } + + public float[] toArray() { return Arrays.copyOf(data, size); } +} +``` + +```java +// IntList.java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.Arrays; + +/** Growable primitive int array. */ +public class IntList { + private int[] data; + private int size; + + public IntList() { this(1024); } + public IntList(int initialCapacity) { data = new int[Math.max(1, initialCapacity)]; } + + public void add(int v) { + if (size + 1 > data.length) { + data = Arrays.copyOf(data, data.length << 1); + } + data[size++] = v; + } + + public int get(int index) { return data[index]; } + + public int size() { return size; } + + public int[] toArray() { return Arrays.copyOf(data, size); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=FloatListTest test` +Expected: PASS (BUILD SUCCESS, Tests run: 2, Failures: 0). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IntList.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java +git commit -m "Add primitive growable FloatList/IntList for GL buffer building" +``` + +--- + +### Task 2: `PickingCodec` (id ↔ RGB) + +**Files:** +- Create: `.../gui/gl/PickingCodec.java` +- Test: `.../test/.../gui/gl/PickingCodecTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class PickingCodecTest { + + @Test + public void roundTripsIdsThroughRgbBytes() { + int[] ids = {1, 255, 256, 65535, 65536, 16777215}; + for (int id : ids) { + int r = PickingCodec.red(id); + int g = PickingCodec.green(id); + int b = PickingCodec.blue(id); + assertEquals(id, PickingCodec.decode(r, g, b)); + } + } + + @Test + public void blackDecodesToZeroBackground() { + assertEquals(0, PickingCodec.decode(0, 0, 0)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=PickingCodecTest test` +Expected: FAIL (`PickingCodec` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** Encodes integer ids into RGB bytes for GPU color-picking. Id 0 is reserved for background. */ +public final class PickingCodec { + + private PickingCodec() {} + + public static int red(int id) { return id & 0xFF; } + public static int green(int id) { return (id >> 8) & 0xFF; } + public static int blue(int id) { return (id >> 16) & 0xFF; } + + public static int decode(int r, int g, int b) { + return (r & 0xFF) | ((g & 0xFF) << 8) | ((b & 0xFF) << 16); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=PickingCodecTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodec.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodecTest.java +git commit -m "Add PickingCodec for color-picking id encoding" +``` + +--- + +### Task 3: `PickRegistry` (id → clicked target) + +Allocates ids and resolves a picked id back to the model object (`Polygon`, `Vertex`, or `Edge`). Id `n` +maps to list index `n-1`; id `0` is background (null). + +**Files:** +- Create: `.../gui/gl/PickRegistry.java` +- Test: `.../test/.../gui/gl/PickRegistryTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import org.junit.Test; + +public class PickRegistryTest { + + @Test + public void assignsSequentialNonZeroIdsAndResolvesThem() { + PickRegistry reg = new PickRegistry(); + Object a = "a"; + Object b = "b"; + int idA = reg.register(a); + int idB = reg.register(b); + assertEquals(1, idA); + assertEquals(2, idB); + assertEquals(a, reg.resolve(idA)); + assertEquals(b, reg.resolve(idB)); + } + + @Test + public void resolvesBackgroundAndOutOfRangeToNull() { + PickRegistry reg = new PickRegistry(); + reg.register("a"); + assertNull(reg.resolve(0)); + assertNull(reg.resolve(99)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=PickRegistryTest test` +Expected: FAIL (`PickRegistry` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.ArrayList; +import java.util.List; + +/** Maps picking ids (1-based) to the model objects they represent. */ +public class PickRegistry { + + private final List targets = new ArrayList<>(); + + /** Registers a target and returns its non-zero picking id. */ + public int register(Object target) { + targets.add(target); + return targets.size(); // id = index + 1 + } + + /** Resolves a picking id to its target, or null for background / unknown id. */ + public Object resolve(int id) { + if (id <= 0 || id > targets.size()) { + return null; + } + return targets.get(id - 1); + } + + public int size() { return targets.size(); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=PickRegistryTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistry.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistryTest.java +git commit -m "Add PickRegistry mapping picking ids to model objects" +``` + +--- + +### Task 4: `ShadeColor` (extract existing fake shading) + +Extracts the per-polygon shade math from `TriangulatedGeometry.calculateMaterial` into a pure function so +it can be baked into vertex colors and unit-tested. `javafx.scene.paint.Color` instantiates without the FX +toolkit, so this is headless-safe. + +**Files:** +- Create: `.../gui/gl/ShadeColor.java` +- Test: `.../test/.../gui/gl/ShadeColorTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import javafx.scene.paint.Color; +import org.junit.Test; + +public class ShadeColorTest { + + @Test + public void brightnessStaysWithinExpectedBand() { + // Shade brightness is acos(normal.AXIS)/PI * 0.6 + 0.3 -> always within [0.3, 0.9]. + Vector3d[] normals = { + new Vector3d(1, 0, 0), new Vector3d(0, 1, 0), new Vector3d(0, 0, 1), + new Vector3d(-1, 0, 0), new Vector3d(0.3, -0.7, 0.5) + }; + for (Vector3d n : normals) { + Color c = ShadeColor.shade(n.normalize(), Color.WHITE); + assertTrue("brightness >= 0.3", c.getBrightness() >= 0.3 - 1e-6); + assertTrue("brightness <= 0.9", c.getBrightness() <= 0.9 + 1e-6); + } + } + + @Test + public void matchesLegacyFormulaForKnownNormal() { + Vector3d axis = new Vector3d(19, 0.8, 1.5).normalize(); + Vector3d normal = new Vector3d(0, 0, 1).normalize(); + double cos = Math.max(-1.0, Math.min(1.0, normal.dot(axis))); + double shade = Math.acos(cos) / Math.PI * 0.6 + 0.3; + Color expected = Color.WHITE.deriveColor(0, 1.0, shade, 1.0); + Color actual = ShadeColor.shade(normal, Color.WHITE); + assertEquals(expected.getRed(), actual.getRed(), 1e-9); + assertEquals(expected.getGreen(), actual.getGreen(), 1e-9); + assertEquals(expected.getBlue(), actual.getBlue(), 1e-9); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=ShadeColorTest test` +Expected: FAIL (`ShadeColor` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.math.UnitVector3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import javafx.scene.paint.Color; + +/** + * Computes the surface-differentiation shade used by the legacy renderer: brightness derived from the + * angle between the polygon normal and a fixed axis. Not physical lighting; only makes adjacent faces + * distinguishable. Ported from {@code TriangulatedGeometry.calculateMaterial}. + */ +public final class ShadeColor { + + private static final UnitVector3d AXIS = new Vector3d(19, 0.8, 1.5).normalize(); + + private ShadeColor() {} + + public static Color shade(Vector3d normal, Color base) { + double cos = normal.dot(AXIS); + // clamp to acos domain; raw dot can drift slightly outside [-1,1] for near-parallel normals + cos = Math.max(-1.0, Math.min(1.0, cos)); + double brightness = Math.acos(cos) / Math.PI * 0.6 + 0.3; // -> [0.3, 0.9] + return base.deriveColor(0, 1.0, brightness, 1.0); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=ShadeColorTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColor.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColorTest.java +git commit -m "Extract fake-shading math into pure ShadeColor" +``` + +--- + +### Task 5: `Mat4` (column-major 4x4 matrix math) + +**Files:** +- Create: `.../gui/gl/Mat4.java` +- Test: `.../test/.../gui/gl/Mat4Test.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class Mat4Test { + + @Test + public void identityTimesVectorIsUnchanged() { + float[] v = Mat4.transform(Mat4.identity(), 2, 3, 4, 1); + assertArrayEquals(new float[]{2, 3, 4, 1}, v, 1e-6f); + } + + @Test + public void translationMovesPoint() { + float[] m = Mat4.translation(5, -2, 1); + float[] v = Mat4.transform(m, 1, 1, 1, 1); + assertArrayEquals(new float[]{6, -1, 2, 1}, v, 1e-6f); + } + + @Test + public void multiplyIsIdentityNeutral() { + float[] t = Mat4.translation(1, 2, 3); + assertArrayEquals(t, Mat4.multiply(Mat4.identity(), t), 1e-6f); + assertArrayEquals(t, Mat4.multiply(t, Mat4.identity()), 1e-6f); + } + + @Test + public void rotationZ90MapsXAxisToYAxis() { + float[] m = Mat4.rotationZ((float) (Math.PI / 2)); + float[] v = Mat4.transform(m, 1, 0, 0, 1); + assertEquals(0f, v[0], 1e-6f); + assertEquals(1f, v[1], 1e-6f); + } + + @Test + public void perspectiveHasExpectedDiagonalStructure() { + float[] p = Mat4.perspective((float) Math.toRadians(60), 1.0f, 1f, 100f); + // column-major: focal length on [0] and [5], -1 in the w-from-z slot [11] + float f = (float) (1.0 / Math.tan(Math.toRadians(60) / 2)); + assertEquals(f, p[0], 1e-5f); + assertEquals(f, p[5], 1e-5f); + assertEquals(-1f, p[11], 1e-6f); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=Mat4Test test` +Expected: FAIL (`Mat4` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** + * Minimal column-major 4x4 float matrix helpers, layout-compatible with OpenGL uniform uploads + * (element index = column*4 + row). + */ +public final class Mat4 { + + private Mat4() {} + + public static float[] identity() { + float[] m = new float[16]; + m[0] = m[5] = m[10] = m[15] = 1f; + return m; + } + + public static float[] translation(float x, float y, float z) { + float[] m = identity(); + m[12] = x; + m[13] = y; + m[14] = z; + return m; + } + + /** Returns a * b (column-major). */ + public static float[] multiply(float[] a, float[] b) { + float[] r = new float[16]; + for (int col = 0; col < 4; col++) { + for (int row = 0; row < 4; row++) { + float sum = 0f; + for (int k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + r[col * 4 + row] = sum; + } + } + return r; + } + + /** Returns m * (x,y,z,w) as a length-4 array. */ + public static float[] transform(float[] m, float x, float y, float z, float w) { + return new float[] { + m[0] * x + m[4] * y + m[8] * z + m[12] * w, + m[1] * x + m[5] * y + m[9] * z + m[13] * w, + m[2] * x + m[6] * y + m[10] * z + m[14] * w, + m[3] * x + m[7] * y + m[11] * z + m[15] * w + }; + } + + public static float[] rotationX(float rad) { + float c = (float) Math.cos(rad); + float s = (float) Math.sin(rad); + float[] m = identity(); + m[5] = c; m[9] = -s; + m[6] = s; m[10] = c; + return m; + } + + public static float[] rotationZ(float rad) { + float c = (float) Math.cos(rad); + float s = (float) Math.sin(rad); + float[] m = identity(); + m[0] = c; m[4] = -s; + m[1] = s; m[5] = c; + return m; + } + + /** Right-handed perspective projection (camera looks down -Z). */ + public static float[] perspective(float fovyRad, float aspect, float near, float far) { + float f = (float) (1.0 / Math.tan(fovyRad / 2.0)); + float[] m = new float[16]; + m[0] = f / aspect; + m[5] = f; + m[10] = (far + near) / (near - far); + m[11] = -1f; + m[14] = (2f * far * near) / (near - far); + return m; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=Mat4Test test` +Expected: PASS (Tests run: 5). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4Test.java +git commit -m "Add column-major Mat4 matrix helpers" +``` + +--- + +### Task 6: `OrbitCamera` (view-projection + interaction state) + +Replicates the legacy camera state from `MainWindow`: orbit via a Z rotation (`cameraXRot`) and an X +rotation (`cameraYRot`) applied to the world, zoom via `translateZ` (negative = farther), pan via +`translateX/Y`, and `zoomOutForBoundingBox` distance using the same `tan(30°/2)` math. + +> **Note on sign/handedness:** exact pixel-parity with JavaFX is not required (the spec only needs equivalent +> orbit/zoom/pan behavior). The unit tests below assert mathematical invariants (focus point lands at the +> NDC center, zooming preserves centering, aspect affects X). Final rotation *direction* (drag sign) is +> confirmed during manual verification in Phase 6 and adjusted by flipping the relevant delta signs in the +> input handlers (Task 16), not by changing this class. + +**Files:** +- Create: `.../gui/gl/OrbitCamera.java` +- Test: `.../test/.../gui/gl/OrbitCameraTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class OrbitCameraTest { + + private static float[] ndc(OrbitCamera cam, float x, float y, float z) { + float[] clip = Mat4.transform(cam.viewProjection(), x, y, z, 1); + return new float[]{clip[0] / clip[3], clip[1] / clip[3], clip[2] / clip[3]}; + } + + @Test + public void focusPointProjectsToScreenCentre() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.zoomOutForBoundingBox(50.0); // sets a sensible distance + float[] p = ndc(cam, 0, 0, 0); + assertEquals(0f, p[0], 1e-4f); + assertEquals(0f, p[1], 1e-4f); + assertTrue("origin in front of camera (within clip)", p[2] > -1f && p[2] < 1f); + } + + @Test + public void zoomingKeepsFocusCentredButChangesDepth() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.zoomOutForBoundingBox(50.0); + float depthBefore = ndc(cam, 0, 0, 0)[2]; + cam.zoom(1.5); // farther + float[] p = ndc(cam, 0, 0, 0); + assertEquals(0f, p[0], 1e-4f); + assertEquals(0f, p[1], 1e-4f); + assertTrue("depth changes when zooming", Math.abs(p[2] - depthBefore) > 1e-4f); + } + + @Test + public void resetRestoresInitialDistanceAndAngles() { + OrbitCamera cam = new OrbitCamera(); + cam.setAspect(1.0f); + cam.orbit(40, 25); + cam.pan(10, -5); + cam.zoom(2.0); + cam.reset(); + float[] before = cam.viewProjection(); + OrbitCamera fresh = new OrbitCamera(); + fresh.setAspect(1.0f); + org.junit.Assert.assertArrayEquals(fresh.viewProjection(), before, 1e-5f); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=OrbitCameraTest test` +Expected: FAIL (`OrbitCamera` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +/** + * Orbit camera reproducing the legacy JavaFX interaction model: world is rotated about Z then X, the + * camera sits at {@code distance} along -Z (negative distance = farther away) and pans in X/Y. + */ +public class OrbitCamera { + + private static final double INITIAL_AZIMUTH = 120.0; // legacy CAMERA_INITIAL_Y_ANGLE (Z rotation) + private static final double INITIAL_ELEVATION = 20.0; // legacy CAMERA_INITIAL_X_ANGLE (X rotation) + private static final double INITIAL_DISTANCE = 100.0; + private static final double FOVY_RAD = Math.toRadians(30); + private static final float NEAR = 0.1f; + private static final float FAR = 10000f; + + private double azimuthDeg = INITIAL_AZIMUTH; + private double elevationDeg = INITIAL_ELEVATION; + private double distance = INITIAL_DISTANCE; // positive magnitude; camera at z = -distance + private double panX = 0; + private double panY = 0; + private float aspect = 1.0f; + + public void setAspect(float aspect) { + if (aspect > 0) { + this.aspect = aspect; + } + } + + public void orbit(double deltaAzimuthDeg, double deltaElevationDeg) { + azimuthDeg += deltaAzimuthDeg; + elevationDeg += deltaElevationDeg; + } + + public void pan(double dx, double dy) { + panX += dx; + panY += dy; + } + + /** factor > 1 moves farther, < 1 moves closer. */ + public void zoom(double factor) { + distance *= factor; + } + + /** Matches legacy MainWindow.zoomOutForBoundingBox distance computation. */ + public void zoomOutForBoundingBox(double diagonalLength) { + double longestSide = diagonalLength * 0.4; + distance = longestSide / Math.tan(FOVY_RAD / 2.0); + panX = 0; + panY = 0; + } + + public void reset() { + azimuthDeg = INITIAL_AZIMUTH; + elevationDeg = INITIAL_ELEVATION; + distance = INITIAL_DISTANCE; + panX = 0; + panY = 0; + } + + public double distance() { return distance; } + + /** Combined projection * view * model-rotation, column-major, ready for a GL uniform. */ + public float[] viewProjection() { + float[] proj = Mat4.perspective((float) FOVY_RAD, aspect, NEAR, FAR); + // camera transform: pan in screen plane, pull back by distance along -Z + float[] view = Mat4.translation((float) -panX, (float) -panY, (float) -distance); + float[] rot = Mat4.multiply( + Mat4.rotationX((float) Math.toRadians(elevationDeg)), + Mat4.rotationZ((float) Math.toRadians(azimuthDeg))); + return Mat4.multiply(proj, Mat4.multiply(view, rot)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=OrbitCameraTest test` +Expected: PASS (Tests run: 3). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCameraTest.java +git commit -m "Add OrbitCamera reproducing legacy view interaction" +``` + +--- + +### Task 7: `MeshAccumulator` (triangles → interleaved arrays) + +Accumulates per-vertex `position` (recentered), `color` (rgb floats), and `id` (per vertex), with one +entry per triangle corner (no cross-triangle dedup — keeps triangle→metadata mapping trivial for filtering). + +**Files:** +- Create: `.../gui/gl/MeshAccumulator.java` +- Test: `.../test/.../gui/gl/MeshAccumulatorTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import org.junit.Test; + +public class MeshAccumulatorTest { + + @Test + public void emitsNineFloatsAndThreeIdsPerTriangleRecentered() { + MeshAccumulator acc = new MeshAccumulator(new Vector3d(1, 0, 0)); + Triangle3d t = new Triangle3d( + new Vector3d(1, 0, 0), new Vector3d(2, 0, 0), new Vector3d(1, 1, 0)); + acc.addTriangle(t, 7, 0.5f, 0.25f, 0.125f); + + assertEquals(3, acc.vertexCount()); + // positions recentred by (1,0,0): + assertArrayEquals(new float[]{0,0,0, 1,0,0, 0,1,0}, acc.positions(), 1e-6f); + // color repeated per vertex: + assertArrayEquals(new float[]{0.5f,0.25f,0.125f, 0.5f,0.25f,0.125f, 0.5f,0.25f,0.125f}, + acc.colors(), 1e-6f); + assertArrayEquals(new int[]{7, 7, 7}, acc.ids()); + } + + @Test + public void accumulatesMultipleTriangles() { + MeshAccumulator acc = new MeshAccumulator(new Vector3d(0, 0, 0)); + Triangle3d t = new Triangle3d( + new Vector3d(0, 0, 0), new Vector3d(1, 0, 0), new Vector3d(0, 1, 0)); + acc.addTriangle(t, 1, 1, 1, 1); + acc.addTriangle(t, 2, 0, 0, 0); + assertEquals(6, acc.vertexCount()); + assertEquals(18, acc.positions().length); + assertArrayEquals(new int[]{1, 1, 1, 2, 2, 2}, acc.ids()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=MeshAccumulatorTest test` +Expected: FAIL (`MeshAccumulator` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; + +/** + * Builds interleaved-by-attribute vertex arrays from triangles. Vertices are stored per triangle corner + * (3 per triangle) so a triangle's vertices occupy a contiguous, predictable range — which lets the + * draw index set be built by triangle without dedup bookkeeping. + */ +public class MeshAccumulator { + + private final Vector3d center; + private final FloatList positions = new FloatList(); + private final FloatList colors = new FloatList(); + private final IntList ids = new IntList(); + private int vertexCount; + + public MeshAccumulator(Vector3d center) { + this.center = center; + } + + public void addTriangle(Triangle3d t, int id, float r, float g, float b) { + addVertex(t.getP1(), id, r, g, b); + addVertex(t.getP2(), id, r, g, b); + addVertex(t.getP3(), id, r, g, b); + } + + private void addVertex(Vector3d p, int id, float r, float g, float b) { + positions.add((float) (p.getX() - center.getX()), + (float) (p.getY() - center.getY()), + (float) (p.getZ() - center.getZ())); + colors.add(r, g, b); + ids.add(id); + vertexCount++; + } + + public int vertexCount() { return vertexCount; } + + public float[] positions() { return positions.toArray(); } + + public float[] colors() { return colors.toArray(); } + + public int[] ids() { return ids.toArray(); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=MeshAccumulatorTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulator.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulatorTest.java +git commit -m "Add MeshAccumulator building interleaved vertex arrays" +``` + +--- + +### Task 8: `TriangleMeta` + `IndexSetBuilder` (filter → draw index set) + +Per-triangle metadata plus the logic that, given active filters, produces the `int[]` element index set +(triangle vertex indices) to draw. Triangle `i` occupies vertices `3i, 3i+1, 3i+2`. + +**Files:** +- Create: `.../gui/gl/TriangleMeta.java` +- Create: `.../gui/gl/IndexSetBuilder.java` +- Test: `.../test/.../gui/gl/IndexSetBuilderTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertArrayEquals; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; +import org.junit.Test; + +public class IndexSetBuilderTest { + + @Test + public void includesOnlyTrianglesMatchingThePredicate() { + // 3 triangles; meta carries an int "group" we filter on + List metas = Arrays.asList( + new TriangleMeta(0, null, 1, false), + new TriangleMeta(1, null, 2, false), + new TriangleMeta(2, null, 1, false)); + Predicate onlyGroup1 = m -> m.lod() == 1; + int[] indices = IndexSetBuilder.build(metas, onlyGroup1); + // triangles 0 and 2 -> vertices [0,1,2] and [6,7,8] + assertArrayEquals(new int[]{0, 1, 2, 6, 7, 8}, indices); + } + + @Test + public void emptyWhenNothingMatches() { + List metas = Arrays.asList(new TriangleMeta(0, null, 1, false)); + int[] indices = IndexSetBuilder.build(metas, m -> false); + assertArrayEquals(new int[]{}, indices); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=IndexSetBuilderTest test` +Expected: FAIL (`TriangleMeta` / `IndexSetBuilder` do not exist). + +- [ ] **Step 3: Implement** + +```java +// TriangleMeta.java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; + +/** + * Metadata for one triangle in the global buffer. + * @param triangleIndex index of this triangle (vertices are 3*triangleIndex .. +2) + * @param polygon source polygon (for picking/highlight resolution); may be null in tests + * @param lod LOD ordinal (Lod.ordinal()) of the owning geometry + * @param roof true if the triangle belongs to a ROOF boundary surface + */ +public record TriangleMeta(int triangleIndex, Polygon polygon, int lod, boolean roof) {} +``` + +```java +// IndexSetBuilder.java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.List; +import java.util.function.Predicate; + +/** Builds the element index set (triangle vertex indices) for the triangles passing the filter. */ +public final class IndexSetBuilder { + + private IndexSetBuilder() {} + + public static int[] build(List metas, Predicate include) { + IntList out = new IntList(); + for (TriangleMeta m : metas) { + if (include.test(m)) { + int base = m.triangleIndex() * 3; + out.add(base); + out.add(base + 1); + out.add(base + 2); + } + } + return out.toArray(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=IndexSetBuilderTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilder.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilderTest.java +git commit -m "Add TriangleMeta and IndexSetBuilder for filter-driven draw sets" +``` + +--- + +### Task 9: `SceneData` (immutable result of a build) + +Plain container produced by the builder and consumed by the GL layer. No logic worth TDD beyond a +construction smoke test. + +**Files:** +- Create: `.../gui/gl/SceneData.java` +- Test: `.../test/.../gui/gl/SceneDataTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import java.util.List; +import org.junit.Test; + +public class SceneDataTest { + + @Test + public void exposesItsBuffersAndRegistry() { + float[] pos = {0,0,0, 1,0,0, 0,1,0}; + float[] col = {1,1,1, 1,1,1, 1,1,1}; + int[] ids = {1,1,1}; + PickRegistry reg = new PickRegistry(); + List metas = List.of(new TriangleMeta(0, null, 2, false)); + SceneData data = new SceneData(pos, col, ids, metas, reg, new double[]{5, 6, 7}); + + assertSame(pos, data.positions()); + assertEquals(1, data.triangleMetas().size()); + assertEquals(6.0, data.center()[1], 0.0); + assertSame(reg, data.pickRegistry()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=SceneDataTest test` +Expected: FAIL (`SceneData` does not exist). + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import java.util.List; + +/** Immutable CPU-side result of building the scene; uploaded to the GPU by the GL layer. */ +public class SceneData { + + private final float[] positions; + private final float[] colors; + private final int[] ids; + private final List triangleMetas; + private final PickRegistry pickRegistry; + private final double[] center; + + public SceneData(float[] positions, float[] colors, int[] ids, + List triangleMetas, PickRegistry pickRegistry, double[] center) { + this.positions = positions; + this.colors = colors; + this.ids = ids; + this.triangleMetas = triangleMetas; + this.pickRegistry = pickRegistry; + this.center = center; + } + + public float[] positions() { return positions; } + public float[] colors() { return colors; } + public int[] ids() { return ids; } + public List triangleMetas() { return triangleMetas; } + public PickRegistry pickRegistry() { return pickRegistry; } + public double[] center() { return center; } + public int triangleCount() { return triangleMetas.size(); } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=SceneDataTest test` +Expected: PASS (Tests run: 1). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneData.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneDataTest.java +git commit -m "Add SceneData container for built scene buffers" +``` + +--- + +### Task 10: `SceneBuilder` (model/polygons → `SceneData`) + +Replaces `TriangulatedGeometry`. Iterates polygons, tesselates, bakes shade color (Task 4), assigns picking +ids (Task 3), accumulates vertices (Task 7), records `TriangleMeta` (Task 8), and recenters by the bounding +center. Runs on a worker thread (never the GL thread). + +Picking-id allocation uses one `PickRegistry` per build; each polygon registers once and all its triangles +share that id (matches "click polygon → select polygon"). + +**Files:** +- Create: `.../gui/gl/SceneBuilder.java` +- Test: `.../test/.../gui/gl/SceneBuilderTest.java` + +- [ ] **Step 1: Write the failing test** + +This test builds from a small list of polygons using the project's existing `Polygon` API. Use the same +GML fixture the module already ships (`src/test/resources/SimpleSolid_SrefBS-GE-gml-LR-0001-T0001.gml`) by +loading it and collecting its concrete polygons. + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel; +import de.hft.stuttgart.citydoctor2.datastructure.ConcretePolygon; +import de.hft.stuttgart.citydoctor2.datastructure.Geometry; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; +import de.hft.stuttgart.citydoctor2.parser.CityGmlParser; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.paint.Color; +import org.junit.BeforeClass; +import org.junit.Test; + +public class SceneBuilderTest { + + private static List polygons; + + @BeforeClass + public static void loadFixture() throws Exception { + // NOTE: confirm the exact CityGmlParser entry point during implementation (Step 3 notes). + CityDoctorModel model = CityGmlParser.parseCityGmlFile( + "src/test/resources/SimpleSolid_SrefBS-GE-gml-LR-0001-T0001.gml", 8); + polygons = new ArrayList<>(); + model.getBuildings().forEach(b -> { + for (Geometry g : b.getGeometries()) { + for (Polygon p : g.getPolygons()) { + if (!p.isLink()) { + polygons.add(p); + } + } + } + }); + assertTrue("fixture should contain polygons", !polygons.isEmpty()); + } + + @Test + public void buildsConsistentBufferLengths() { + SceneData data = SceneBuilder.fromPolygons(polygons, Color.WHITE); + int vertices = data.positions().length / 3; + assertEquals(vertices * 3, data.colors().length); + assertEquals(vertices, data.ids().length); + assertEquals(vertices, data.triangleCount() * 3); + } + + @Test + public void everyPolygonGetsAPickingIdResolvingToAPolygon() { + SceneData data = SceneBuilder.fromPolygons(polygons, Color.WHITE); + // each id in the id buffer resolves to a Polygon + for (int id : data.ids()) { + assertTrue(data.pickRegistry().resolve(id) instanceof Polygon); + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=SceneBuilderTest test` +Expected: FAIL (`SceneBuilder` does not exist). If the `CityGmlParser` entry point differs, adjust the +fixture-loading line — confirm the signature via +`grep -rn "public static .*parse" CityDoctorParent/CityDoctorModel/src/main/java/.../parser/CityGmlParser.java`. + +- [ ] **Step 3: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import de.hft.stuttgart.citydoctor2.datastructure.CityDoctorModel; +import de.hft.stuttgart.citydoctor2.datastructure.CityObject; +import de.hft.stuttgart.citydoctor2.datastructure.Geometry; +import de.hft.stuttgart.citydoctor2.datastructure.Lod; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; +import de.hft.stuttgart.citydoctor2.math.Triangle3d; +import de.hft.stuttgart.citydoctor2.math.Vector3d; +import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import javafx.scene.paint.Color; + +/** + * Builds {@link SceneData} from model polygons: tesselate, bake shade colour, assign picking ids, + * accumulate vertices and per-triangle metadata, recentre by the bounding-box centre. + * Intended to run on a worker thread. + */ +public final class SceneBuilder { + + private SceneBuilder() {} + + public static SceneData fromPolygons(Collection polygons, Color baseColor) { + return build(polygons, baseColor, Lod.LOD2.ordinal(), false); + } + + /** General entry: caller supplies polygons already chosen, plus their lod/roof attributes. */ + public static SceneData build(Collection polygons, Color baseColor, + int lod, boolean roof) { + Vector3d center = findCenter(polygons); + MeshAccumulator acc = new MeshAccumulator(center); + PickRegistry registry = new PickRegistry(); + List metas = new ArrayList<>(); + int triangleIndex = 0; + + for (Polygon p : polygons) { + Color base = chooseBase(p, baseColor); + Color shaded = ShadeColor.shade(p.calculateNormalNormalized(), base); + float r = (float) shaded.getRed(); + float g = (float) shaded.getGreen(); + float b = (float) shaded.getBlue(); + int id = registry.register(p); + + TesselatedPolygon tp = p.tesselate(); + for (Triangle3d t : tp.getTriangles()) { + acc.addTriangle(t, id, r, g, b); + metas.add(new TriangleMeta(triangleIndex++, p, lod, roof)); + } + } + + double[] centerArr = {center.getX(), center.getY(), center.getZ()}; + return new SceneData(acc.positions(), acc.colors(), acc.ids(), metas, registry, centerArr); + } + + private static Color chooseBase(Polygon p, Color fallback) { + Color rc = p.getRenderColor(); + if (rc != null && !rc.equals(Color.WHITE)) { + return rc; + } + return fallback; + } + + private static Vector3d findCenter(Collection polygons) { + double xMin = Double.MAX_VALUE, yMin = Double.MAX_VALUE, zMin = Double.MAX_VALUE; + double xMax = -Double.MAX_VALUE, yMax = -Double.MAX_VALUE, zMax = -Double.MAX_VALUE; + for (Polygon p : polygons) { + for (Vector3d v : p.getExteriorRing().getVertices()) { + xMin = Math.min(xMin, v.getX()); xMax = Math.max(xMax, v.getX()); + yMin = Math.min(yMin, v.getY()); yMax = Math.max(yMax, v.getY()); + zMin = Math.min(zMin, v.getZ()); zMax = Math.max(zMax, v.getZ()); + } + } + if (xMin > xMax) { // empty + return new Vector3d(0, 0, 0); + } + return new Vector3d((xMax - xMin) / 2 + xMin, (yMax - yMin) / 2 + yMin, (zMax - zMin) / 2 + zMin); + } +} +``` + +> Implementation note: `getExteriorRing().getVertices()` returns `Vertex` which extends `Vector3d` +> (confirm with `grep -n "class Vertex" .../datastructure/Vertex.java`); if not, adapt the accessor. +> A full model-driven builder (iterating `CityDoctorModel` features with per-feature colours and LOD, +> mirroring the legacy `TriangulatedGeometry.of(model, filters)`) is added when wiring the `Renderer` +> in Task 15; this task delivers the polygon-level core the `Renderer` will call. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false -Dtest=SceneBuilderTest test` +Expected: PASS (Tests run: 2). + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilderTest.java +git commit -m "Add SceneBuilder turning polygons into SceneData" +``` + +--- + +## Phase 2 — GL infrastructure (verify by running) + +These tasks touch the live GL context and openglfx; they can't be unit-tested headlessly, so each ends +with a **build + run + observe** verification and a commit. Keep a small CityGML file handy for runs. + +### Task 11: Confirm JOGL natives & openglfx load (pre-flight) + +**Files:** +- Modify: `CityDoctorParent/Extensions/CityDoctorGUI/pom.xml` (only if natives are missing) + +- [ ] **Step 1: Verify the JOGL runtime + natives are on the GUI classpath** + +Run (from `CityDoctorParent`): +`mvn -pl Extensions/CityDoctorGUI -am dependency:tree -Dincludes=org.jogamp.jogl,org.jogamp.gluegen,com.huskerdev` +Expected: `openglfx-jogl`, `jogl-all` (or `jogl-all-main`), and `gluegen-rt` appear. If `jogl-all` natives +for win/linux are absent, add to `pom.xml` dependencies: + +```xml + + org.jogamp.jogl + jogl-all-main + + + org.jogamp.gluegen + gluegen-rt-main + +``` +(Version is managed by the parent `pom.xml` `dependencyManagement` — see `CityDoctorParent/pom.xml:169,179`.) + +- [ ] **Step 2: Minimal canvas smoke test** + +Temporarily add a `main` to a scratch class (or a JUnit test guarded by `Assume.assumeFalse(GraphicsEnvironment.isHeadless())`) that constructs `new GLCanvas(new JOGLExecutor(), GLProfile.CORE)` inside a JavaFX `Application.start`, adds it to a `Scene`, and logs from an `addOnInitEvent` handler. + +Run the existing app entry instead if simpler: +`mvn -pl Extensions/CityDoctorGUI -am -Pcreate-binaries=false exec:java -Dexec.mainClass=de.hft.stuttgart.citydoctor2.gui.MainWindow` +Expected at this stage: app still launches with the current JavaFX view (no regression). The smoke goal is +only to confirm openglfx classes load without `UnsatisfiedLinkError`. + +- [ ] **Step 3: Commit (only if pom changed)** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/pom.xml +git commit -m "Ensure JOGL runtime natives available for GUI OpenGL view" +``` + +--- + +### Task 12: Shader sources + `ShaderProgram` helper + +**Files:** +- Create: `.../resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert` +- Create: `.../resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag` +- Create: `.../resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.vert` +- Create: `.../resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.frag` +- Create: `.../gui/gl/ShaderProgram.java` + +- [ ] **Step 1: Write `scene.vert`** + +```glsl +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aColor; +layout(location = 2) in int aId; +uniform mat4 uMVP; +uniform int uSelectedId; +out vec3 vColor; +flat out int vSelected; +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vColor = aColor; + vSelected = (aId == uSelectedId) ? 1 : 0; +} +``` + +- [ ] **Step 2: Write `scene.frag`** + +```glsl +#version 330 core +in vec3 vColor; +flat in int vSelected; +out vec4 fragColor; +void main() { + vec3 c = vColor; + if (vSelected == 1) { + c = mix(c, vec3(1.0, 0.0, 0.0), 0.6); + } + fragColor = vec4(c, 1.0); +} +``` + +- [ ] **Step 3: Write `pick.vert`** + +```glsl +#version 330 core +layout(location = 0) in vec3 aPos; +layout(location = 2) in int aId; +uniform mat4 uMVP; +flat out int vId; +void main() { + gl_Position = uMVP * vec4(aPos, 1.0); + vId = aId; +} +``` + +- [ ] **Step 4: Write `pick.frag`** (must mirror `PickingCodec`: id low byte → red) + +```glsl +#version 330 core +flat in int vId; +out vec4 fragColor; +void main() { + int id = vId; + float r = float( id & 0xFF) / 255.0; + float g = float((id >> 8) & 0xFF) / 255.0; + float b = float((id >> 16) & 0xFF) / 255.0; + fragColor = vec4(r, g, b, 1.0); +} +``` + +- [ ] **Step 5: Implement `ShaderProgram`** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL3; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +/** Compiles and links a GLSL program from classpath resources next to this class. */ +public class ShaderProgram { + + private final int program; + + public ShaderProgram(GL3 gl, String vertexResource, String fragmentResource) { + int vs = compile(gl, GL3.GL_VERTEX_SHADER, read(vertexResource)); + int fs = compile(gl, GL3.GL_FRAGMENT_SHADER, read(fragmentResource)); + program = gl.glCreateProgram(); + gl.glAttachShader(program, vs); + gl.glAttachShader(program, fs); + gl.glLinkProgram(program); + checkLink(gl, program); + gl.glDeleteShader(vs); + gl.glDeleteShader(fs); + } + + public void use(GL3 gl) { gl.glUseProgram(program); } + + public int uniform(GL3 gl, String name) { return gl.glGetUniformLocation(program, name); } + + public void dispose(GL3 gl) { gl.glDeleteProgram(program); } + + private static int compile(GL3 gl, int type, String src) { + int shader = gl.glCreateShader(type); + gl.glShaderSource(shader, 1, new String[]{src}, new int[]{src.length()}, 0); + gl.glCompileShader(shader); + int[] status = new int[1]; + gl.glGetShaderiv(shader, GL3.GL_COMPILE_STATUS, status, 0); + if (status[0] == 0) { + throw new IllegalStateException("Shader compile failed: " + infoLog(gl, shader, true)); + } + return shader; + } + + private static void checkLink(GL3 gl, int program) { + int[] status = new int[1]; + gl.glGetProgramiv(program, GL3.GL_LINK_STATUS, status, 0); + if (status[0] == 0) { + throw new IllegalStateException("Program link failed: " + infoLog(gl, program, false)); + } + } + + private static String infoLog(GL3 gl, int handle, boolean shader) { + int[] len = new int[1]; + if (shader) { + gl.glGetShaderiv(handle, GL3.GL_INFO_LOG_LENGTH, len, 0); + } else { + gl.glGetProgramiv(handle, GL3.GL_INFO_LOG_LENGTH, len, 0); + } + byte[] log = new byte[Math.max(1, len[0])]; + if (shader) { + gl.glGetShaderInfoLog(handle, log.length, new int[1], 0, log, 0); + } else { + gl.glGetProgramInfoLog(handle, log.length, new int[1], 0, log, 0); + } + return new String(log, StandardCharsets.UTF_8); + } + + private static String read(String resource) { + try (InputStream in = ShaderProgram.class.getResourceAsStream(resource)) { + if (in == null) { + throw new IllegalStateException("Shader resource not found: " + resource); + } + try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + return r.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} +``` + +- [ ] **Step 6: Build to confirm it compiles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. (Runtime shader compilation is exercised in Task 13.) + +- [ ] **Step 7: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/ \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java +git commit -m "Add scene/pick shaders and ShaderProgram compiler" +``` + +--- + +### Task 13: `GlScene` (VAO/VBO/EBO upload + draw) + +Owns the global VBO and index buffer; uploads `SceneData`; draws the current index set with the scene or +pick program. GL-thread only. + +**Files:** +- Create: `.../gui/gl/GlScene.java` + +- [ ] **Step 1: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +/** GPU resources for the global scene mesh. Single VBO (pos+color+id), one index buffer redrawn per frame. */ +public class GlScene { + + private int vao = 0; + private int vboPos; + private int vboColor; + private int vboId; + private int ebo; + private int indexCount; + private boolean uploaded; + + public void upload(GL3 gl, SceneData data) { + if (!uploaded) { + int[] ids = new int[3]; + gl.glGenVertexArrays(1, ids, 0); + vao = ids[0]; + int[] buffers = new int[4]; + gl.glGenBuffers(4, buffers, 0); + vboPos = buffers[0]; + vboColor = buffers[1]; + vboId = buffers[2]; + ebo = buffers[3]; + } + gl.glBindVertexArray(vao); + + uploadFloat(gl, vboPos, data.positions(), 0, 3); + uploadFloat(gl, vboColor, data.colors(), 1, 3); + uploadInt(gl, vboId, data.ids(), 2); + + gl.glBindVertexArray(0); + uploaded = true; + } + + private void uploadFloat(GL3 gl, int vbo, float[] data, int location, int components) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + FloatBuffer fb = toFloatBuffer(data); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Float.BYTES, fb, GL.GL_STATIC_DRAW); + gl.glEnableVertexAttribArray(location); + gl.glVertexAttribPointer(location, components, GL.GL_FLOAT, false, 0, 0L); + } + + private void uploadInt(GL3 gl, int vbo, int[] data, int location) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + IntBuffer ib = toIntBuffer(data); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Integer.BYTES, ib, GL.GL_STATIC_DRAW); + gl.glEnableVertexAttribArray(location); + gl.glVertexAttribIPointer(location, 1, GL3.GL_INT, 0, 0L); // integer attribute, not normalized + } + + /** Re-uploads which triangles to draw (element indices). Cheap; called on filter/LOD changes. */ + public void setIndexSet(GL3 gl, int[] indices) { + gl.glBindVertexArray(vao); + gl.glBindBuffer(GL.GL_ELEMENT_ARRAY_BUFFER, ebo); + IntBuffer ib = toIntBuffer(indices); + gl.glBufferData(GL.GL_ELEMENT_ARRAY_BUFFER, (long) indices.length * Integer.BYTES, ib, + GL.GL_DYNAMIC_DRAW); + gl.glBindVertexArray(0); + indexCount = indices.length; + } + + public void draw(GL3 gl) { + if (vao == 0 || indexCount == 0) { + return; + } + gl.glBindVertexArray(vao); + gl.glDrawElements(GL.GL_TRIANGLES, indexCount, GL.GL_UNSIGNED_INT, 0L); + gl.glBindVertexArray(0); + } + + public boolean hasData() { return uploaded; } + + public void dispose(GL3 gl) { + if (vao != 0) { + gl.glDeleteVertexArrays(1, new int[]{vao}, 0); + gl.glDeleteBuffers(4, new int[]{vboPos, vboColor, vboId, ebo}, 0); + vao = 0; + uploaded = false; + } + } + + private static FloatBuffer toFloatBuffer(float[] data) { + ByteBuffer bb = ByteBuffer.allocateDirect(data.length * Float.BYTES).order(ByteOrder.nativeOrder()); + FloatBuffer fb = bb.asFloatBuffer(); + fb.put(data).flip(); + return fb; + } + + private static IntBuffer toIntBuffer(int[] data) { + ByteBuffer bb = ByteBuffer.allocateDirect(data.length * Integer.BYTES).order(ByteOrder.nativeOrder()); + IntBuffer ib = bb.asIntBuffer(); + ib.put(data).flip(); + return ib; + } +} +``` + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java +git commit -m "Add GlScene global VBO upload and indexed draw" +``` + +--- + +### Task 14: `PickingFramebuffer` (off-screen id target + readback) + +**Files:** +- Create: `.../gui/gl/PickingFramebuffer.java` + +- [ ] **Step 1: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** Off-screen framebuffer that stores picking ids as RGBA8; supports single-pixel readback. */ +public class PickingFramebuffer { + + private int fbo; + private int colorTex; + private int depthRbo; + private int width; + private int height; + private boolean created; + + public void resize(GL3 gl, int w, int h) { + if (w <= 0 || h <= 0 || (w == width && h == height && created)) { + return; + } + dispose(gl); + width = w; + height = h; + + int[] tmp = new int[1]; + gl.glGenFramebuffers(1, tmp, 0); + fbo = tmp[0]; + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + + gl.glGenTextures(1, tmp, 0); + colorTex = tmp[0]; + gl.glBindTexture(GL.GL_TEXTURE_2D, colorTex); + gl.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, w, h, 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, null); + gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST); + gl.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST); + gl.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, colorTex, 0); + + gl.glGenRenderbuffers(1, tmp, 0); + depthRbo = tmp[0]; + gl.glBindRenderbuffer(GL.GL_RENDERBUFFER, depthRbo); + gl.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT16, w, h); + gl.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, depthRbo); + + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0); + created = true; + } + + public void bind(GL3 gl) { + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + gl.glViewport(0, 0, width, height); + } + + /** + * Reads the picking id at canvas pixel (x, y) measured from the top-left. GL's origin is bottom-left, + * so the row is flipped. + */ + public int readId(GL3 gl, int x, int y) { + if (!created || x < 0 || y < 0 || x >= width || y >= height) { + return 0; + } + ByteBuffer buf = ByteBuffer.allocateDirect(4).order(ByteOrder.nativeOrder()); + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo); + gl.glReadPixels(x, height - 1 - y, 1, 1, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, buf); + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0); + int r = buf.get(0) & 0xFF; + int g = buf.get(1) & 0xFF; + int b = buf.get(2) & 0xFF; + return PickingCodec.decode(r, g, b); + } + + public void dispose(GL3 gl) { + if (created) { + gl.glDeleteFramebuffers(1, new int[]{fbo}, 0); + gl.glDeleteTextures(1, new int[]{colorTex}, 0); + gl.glDeleteRenderbuffers(1, new int[]{depthRbo}, 0); + created = false; + } + } +} +``` + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingFramebuffer.java +git commit -m "Add PickingFramebuffer for color-pick readback" +``` + +--- + +### Task 15: `HighlightOverlay` (dynamic marker buffer) + +Holds vertex markers (drawn as `GL_POINTS`) and edge markers (drawn as `GL_LINES`) for highlights, rebuilt +when the highlight set changes. Reuses `scene.vert`/`scene.frag` (color attribute drives marker colour; id +attribute is 0 so markers never tint as "selected"). Point size is set via `glPointSize`. + +**Files:** +- Create: `.../gui/gl/HighlightOverlay.java` + +- [ ] **Step 1: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +/** + * Dynamic overlay for highlight markers: points for vertices, lines for edges. Positions are in the same + * recentred world space as the scene. Colour rides in the color attribute; id attribute is 0. + */ +public class HighlightOverlay { + + private int pointVao; + private int pointPos; + private int pointColor; + private int pointId; + private int pointCount; + + private int lineVao; + private int linePos; + private int lineColor; + private int lineId; + private int lineVertexCount; + + private boolean created; + + public void init(GL3 gl) { + int[] v = new int[2]; + gl.glGenVertexArrays(2, v, 0); + pointVao = v[0]; + lineVao = v[1]; + int[] b = new int[6]; + gl.glGenBuffers(6, b, 0); + pointPos = b[0]; pointColor = b[1]; pointId = b[2]; + linePos = b[3]; lineColor = b[4]; lineId = b[5]; + created = true; + } + + /** points: xyz triplets; pointColors: rgb triplets (one per point). */ + public void setPoints(GL3 gl, float[] points, float[] pointColors) { + pointCount = points.length / 3; + configure(gl, pointVao, pointPos, points, pointColor, pointColors, pointId, pointCount); + } + + /** lines: xyz pairs of endpoints (6 floats per segment); lineColors: rgb per line vertex. */ + public void setLines(GL3 gl, float[] lines, float[] lineColors) { + lineVertexCount = lines.length / 3; + configure(gl, lineVao, linePos, lines, lineColor, lineColors, lineId, lineVertexCount); + } + + private void configure(GL3 gl, int vao, int posVbo, float[] pos, int colVbo, float[] col, + int idVbo, int vertexCount) { + gl.glBindVertexArray(vao); + bindFloat(gl, posVbo, pos, 0, 3); + bindFloat(gl, colVbo, col, 1, 3); + // id attribute all zeros so overlay never matches uSelectedId + int[] zeros = new int[vertexCount]; + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, idVbo); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) zeros.length * Integer.BYTES, toInt(zeros), + GL.GL_DYNAMIC_DRAW); + gl.glEnableVertexAttribArray(2); + gl.glVertexAttribIPointer(2, 1, GL3.GL_INT, 0, 0L); + gl.glBindVertexArray(0); + } + + private void bindFloat(GL3 gl, int vbo, float[] data, int loc, int comp) { + gl.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo); + gl.glBufferData(GL.GL_ARRAY_BUFFER, (long) data.length * Float.BYTES, toFloat(data), + GL.GL_DYNAMIC_DRAW); + gl.glEnableVertexAttribArray(loc); + gl.glVertexAttribPointer(loc, comp, GL.GL_FLOAT, false, 0, 0L); + } + + public void draw(GL3 gl, float pointSize) { + if (!created) { + return; + } + if (lineVertexCount > 0) { + gl.glBindVertexArray(lineVao); + gl.glDrawArrays(GL.GL_LINES, 0, lineVertexCount); + } + if (pointCount > 0) { + gl.glPointSize(pointSize); + gl.glBindVertexArray(pointVao); + gl.glDrawArrays(GL.GL_POINTS, 0, pointCount); + } + gl.glBindVertexArray(0); + } + + public void clear() { + pointCount = 0; + lineVertexCount = 0; + } + + private static FloatBuffer toFloat(float[] d) { + FloatBuffer fb = ByteBuffer.allocateDirect(Math.max(1, d.length) * Float.BYTES) + .order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(d).flip(); + return fb; + } + + private static IntBuffer toInt(int[] d) { + IntBuffer ib = ByteBuffer.allocateDirect(Math.max(1, d.length) * Integer.BYTES) + .order(ByteOrder.nativeOrder()).asIntBuffer(); + ib.put(d).flip(); + return ib; + } +} +``` + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java +git commit -m "Add HighlightOverlay dynamic marker buffers" +``` + +--- + +### Task 16: `GLViewport` (canvas, render loop, input, picking, screenshot) + +The integration hub: creates the `GLCanvas`, wires init/render/reshape/dispose, owns `GlScene`, +`PickingFramebuffer`, `HighlightOverlay`, `OrbitCamera`, and both shader programs. Handles mouse +orbit/pan/zoom, exposes `setScene`, `setIndexSet`, `setSelectedId`, `requestPick`, wireframe/cull flags, and +`screenshot`. All GL work runs inside the render callback; public mutators set fields the callback reads. + +**Files:** +- Create: `.../gui/gl/GLViewport.java` + +- [ ] **Step 1: Implement** + +```java +package de.hft.stuttgart.citydoctor2.gui.gl; + +import com.huskerdev.grapl.gl.GLProfile; +import com.huskerdev.openglfx.canvas.GLCanvas; +import com.huskerdev.openglfx.jogl.JOGLExecutor; +import com.huskerdev.openglfx.jogl.events.JOGLRenderEvent; +import com.jogamp.opengl.GL; +import com.jogamp.opengl.GL3; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import javafx.application.Platform; +import javafx.scene.input.MouseButton; +import javafx.scene.paint.Color; + +/** OpenGL viewport embedded in JavaFX via openglfx. Replaces the legacy SubScene. */ +public class GLViewport { + + private static final Color BACKGROUND = Color.AZURE; + + private final GLCanvas canvas; + private final OrbitCamera camera = new OrbitCamera(); + private final GlScene scene = new GlScene(); + private final PickingFramebuffer picking = new PickingFramebuffer(); + private final HighlightOverlay overlay = new HighlightOverlay(); + + private ShaderProgram sceneProgram; + private ShaderProgram pickProgram; + private boolean initialized; + + // state set off-thread, consumed in render callback + private final AtomicReference pendingScene = new AtomicReference<>(); + private final AtomicReference pendingIndexSet = new AtomicReference<>(); + private final ConcurrentLinkedQueue pendingPoints = new ConcurrentLinkedQueue<>(); + private volatile int selectedId = 0; + private volatile boolean wireframe = false; + private volatile boolean cull = false; + private volatile float pointScale = 5f; + + // pick request: screen coords + callback (invoked on FX thread with resolved Object or null) + private volatile int[] pickRequest; // {x, y} or null + private volatile Consumer pickCallback; + private SceneData currentScene; + + // drag state + private double lastX; + private double lastY; + + public GLViewport() { + canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE); + canvas.addOnInitEvent(ev -> initialized = false); // (re)create resources lazily in render + canvas.addOnRenderEvent(ev -> render((JOGLRenderEvent) ev)); + canvas.addOnReshapeEvent(ev -> { /* aspect handled per-frame from width/height */ }); + installInputHandlers(); + } + + public GLCanvas getNode() { return canvas; } + + // ---- public API (called from FX / worker threads) ---- + + public void setScene(SceneData data) { pendingScene.set(data); canvas.repaint(); } + + public void setIndexSet(int[] indices) { pendingIndexSet.set(indices); canvas.repaint(); } + + public void setSelectedId(int id) { selectedId = id; canvas.repaint(); } + + public void setWireframe(boolean on) { wireframe = on; canvas.repaint(); } + + public void setCulling(boolean on) { cull = on; canvas.repaint(); } + + public void setPointScale(float scale) { pointScale = scale; canvas.repaint(); } + + /** points: xyz, pointColors rgb-per-point; lines: xyz pairs, lineColors rgb-per-line-vertex. */ + public void setHighlights(float[] points, float[] pointColors, float[] lines, float[] lineColors) { + pendingPoints.add(new float[0]); // wake flag; actual data passed via fields below + this.hlPoints = points; + this.hlPointColors = pointColors; + this.hlLines = lines; + this.hlLineColors = lineColors; + this.highlightsDirty = true; + canvas.repaint(); + } + + private volatile float[] hlPoints = new float[0]; + private volatile float[] hlPointColors = new float[0]; + private volatile float[] hlLines = new float[0]; + private volatile float[] hlLineColors = new float[0]; + private volatile boolean highlightsDirty = false; + + public OrbitCamera camera() { return camera; } + + public void requestPick(double x, double y, Consumer callback) { + pickRequest = new int[]{(int) Math.round(x), (int) Math.round(y)}; + pickCallback = callback; + canvas.repaint(); + } + + // ---- render callback (GL thread) ---- + + private void render(JOGLRenderEvent ev) { + GL3 gl = ev.getGl(); + int w = ev.width; + int h = ev.height; + int defaultFbo = ev.fbo; + + if (!initialized) { + sceneProgram = new ShaderProgram(gl, "scene.vert", "scene.frag"); + pickProgram = new ShaderProgram(gl, "pick.vert", "pick.frag"); + overlay.init(gl); + gl.glEnable(GL.GL_DEPTH_TEST); + initialized = true; + } + + SceneData newScene = pendingScene.getAndSet(null); + if (newScene != null) { + scene.upload(gl, newScene); + currentScene = newScene; + } + int[] newIndices = pendingIndexSet.getAndSet(null); + if (newIndices != null) { + scene.setIndexSet(gl, newIndices); + } + if (highlightsDirty) { + overlay.setPoints(gl, hlPoints, hlPointColors); + overlay.setLines(gl, hlLines, hlLineColors); + highlightsDirty = false; + } + + camera.setAspect(h == 0 ? 1f : (float) w / h); + float[] mvp = camera.viewProjection(); + + // main pass to the openglfx-provided framebuffer + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, defaultFbo); + gl.glViewport(0, 0, w, h); + gl.glClearColor((float) BACKGROUND.getRed(), (float) BACKGROUND.getGreen(), + (float) BACKGROUND.getBlue(), 1f); + gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT); + if (cull) { + gl.glEnable(GL.GL_CULL_FACE); + } else { + gl.glDisable(GL.GL_CULL_FACE); + } + gl.glPolygonMode(GL.GL_FRONT_AND_BACK, wireframe ? GL3.GL_LINE : GL3.GL_FILL); + + sceneProgram.use(gl); + gl.glUniformMatrix4fv(sceneProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); + gl.glUniform1i(sceneProgram.uniform(gl, "uSelectedId"), selectedId); + scene.draw(gl); + overlay.draw(gl, pointScale); + + // picking pass on demand + int[] req = pickRequest; + if (req != null && currentScene != null) { + picking.resize(gl, w, h); + picking.bind(gl); + gl.glClearColor(0, 0, 0, 1); + gl.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT); + gl.glPolygonMode(GL.GL_FRONT_AND_BACK, GL3.GL_FILL); + pickProgram.use(gl); + gl.glUniformMatrix4fv(pickProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); + scene.draw(gl); + int id = picking.readId(gl, req[0], req[1]); + Object target = currentScene.pickRegistry().resolve(id); + int sel = (target != null) ? id : 0; + selectedId = sel; + Consumer cb = pickCallback; + pickRequest = null; + pickCallback = null; + gl.glBindFramebuffer(GL.GL_FRAMEBUFFER, defaultFbo); + if (cb != null) { + Platform.runLater(() -> cb.accept(target)); + } + } + } + + // ---- input ---- + + private void installInputHandlers() { + canvas.setOnMousePressed(e -> { lastX = e.getX(); lastY = e.getY(); }); + canvas.setOnMouseDragged(e -> { + double dx = e.getX() - lastX; + double dy = e.getY() - lastY; + lastX = e.getX(); + lastY = e.getY(); + if (e.getButton() == MouseButton.PRIMARY) { + // signs tuned to match legacy drag direction during manual verification (Task 21) + camera.orbit(dx / 3.0, dy / 3.0); + } else if (e.getButton() == MouseButton.SECONDARY) { + double speed = Math.abs(camera.distance()) / 1000.0; + camera.pan(-dx * speed, dy * speed); + } + canvas.repaint(); + }); + canvas.setOnScroll(e -> { + camera.zoom(e.getDeltaY() < 0 ? 1.05 : 0.95); + canvas.repaint(); + }); + } + + public void resetCamera() { camera.reset(); canvas.repaint(); } +} +``` + +> Implementation notes: +> - The `setHighlights` field-plus-flag pattern keeps the marker upload on the GL thread. Keep the four +> `hl*` arrays and the `highlightsDirty` flag; the `pendingPoints` queue line is only a wake nudge and may +> be removed if `canvas.repaint()` alone reliably triggers a frame. +> - `glPolygonMode`/`GL_LINE` constants: `GL3.GL_LINE` and `GL3.GL_FILL` (from `com.jogamp.opengl.GL2GL3`) +> — confirm the constant's interface during compile and adjust the import if the symbol resolves elsewhere. +> - If `canvas.repaint()` is not the correct openglfx trigger in 4.1.19, set the canvas to continuous +> rendering via the `fps` constructor arg (e.g. `new GLCanvas(new JOGLExecutor(), GLProfile.CORE, false, 0, 60.0)`) +> and drop the explicit `repaint()` calls. + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. Fix any openglfx/JOGL symbol mismatches now (constants, `getGl()` vs field). + +- [ ] **Step 3: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java +git commit -m "Add GLViewport integrating canvas, camera, picking and overlay" +``` + +--- + +## Phase 3 — Wire into the GUI + +### Task 17: Mount `GLViewport` in `MainWindow.setup3dView` + +Replace the JavaFX 3D scene with the GL canvas; keep the north-arrow `ImageView` overlay and click gate. + +**Files:** +- Modify: `.../gui/MainWindow.java` (`setup3dView`, `setupMeshViewControls`, `buildCamera`, fields, getters) + +- [ ] **Step 1: Replace the 3D scene construction** + +In `setup3dView()`, remove the `SubScene geomScene`, `Group root/world/meshGroup`, `PerspectiveCamera`, +`AmbientLight`, and the `cameraXRotation`/`cameraZRotation` transforms. Construct and mount the viewport: + +```java +private GLViewport glViewport; + +private void setup3dView() { + glViewport = new GLViewport(); + GLCanvas canvas = glViewport.getNode(); + canvas.prefWidthProperty().bind(meshView.widthProperty()); + canvas.prefHeightProperty().bind(meshView.heightProperty()); + meshView.getChildren().add(canvas); + + // click-to-pick gate (replaces JavaFX pickResult) + canvas.addEventFilter(MouseEvent.MOUSE_PRESSED, me -> { + clickStart[0] = me.getScreenX(); + clickStart[1] = me.getScreenY(); + }); + canvas.addEventFilter(MouseEvent.MOUSE_RELEASED, me -> { + if (Math.abs(clickStart[0] - me.getScreenX()) > 3 || Math.abs(clickStart[1] - me.getScreenY()) > 3) { + return; // treated as a drag, not a click + } + double x = me.getX(); + double y = me.getY(); + glViewport.requestPick(x, y, target -> { + if (target instanceof ClickDispatcher cd) { + cd.click(me, clickHandler); + } + }); + }); + + // north arrow overlay stays on top + north = new Image(getClass().getResourceAsStream("icons/north.png"), 50, 50, true, true); + northFlip = new Image(getClass().getResourceAsStream("icons/north_flip.png"), 50, 50, true, true); + northArrow = new ImageView(north); + northArrow.setFitHeight(50); + northArrow.setFitWidth(50); + northArrow.setX(10); + northArrow.setY(10); + meshView.getChildren().add(northArrow); + northArrow.setVisible(false); +} +``` + +> `ClickDispatcher`/`PolygonClickDispatcher`/`VertexClickDispatcher` currently carry the clicked model +> object. Picking now resolves a `Polygon`/`Vertex`/`Edge` directly. Bridge it: in the pick callback, map +> the resolved object to the existing dispatch. Simplest: have `PolygonClickDispatcher`/`VertexClickDispatcher` +> remain the things stored in the `PickRegistry` instead of raw `Polygon`/`Vertex`. To do that, change +> `SceneBuilder` to `registry.register(new PolygonClickDispatcher(p))` and resolve to `ClickDispatcher`. +> Update Task 10's `instanceof Polygon` test expectation to `instanceof PolygonClickDispatcher` if you take +> this route. Pick whichever keeps `ClickDispatcher.click(...)` working and keep it consistent. + +- [ ] **Step 2: Update camera input + zoom plumbing** + +`setupMeshViewControls()` mouse/scroll handlers are superseded by `GLViewport.installInputHandlers()`. +Delete the orbit/pan/scroll bodies that referenced `camera`/`cameraXRotation`. Keep `alignNorthArrow()` and +call it from a camera-changed hook (simplest: call `alignNorthArrow()` inside the canvas drag handler after +`glViewport`'s handler runs, using `glViewport.camera()` angles — add accessors `azimuthDeg()/elevationDeg()` +to `OrbitCamera` if needed for the arrow math). + +Update `buildCamera()`, `resetCamera()`, `zoomOutForBoundingBox()`: + +```java +public void resetCamera() { + glViewport.resetCamera(); + alignNorthArrow(); +} + +public void zoomOutForBoundingBox(BoundingBox b) { + glViewport.camera().zoomOutForBoundingBox(b.getDiagonalLength()); + glViewport.getNode().repaint(); + controller.setOriginBB(b); +} +``` + +Delete the now-unused `camera`, `cameraXRotation`, `cameraZRotation`, `translateZ`, `cameraXRot`, +`cameraYRot` fields and `buildCamera()` if fully replaced (keep `clickStart`). + +- [ ] **Step 3: Replace `getMeshGroup()` and `takeViewScreenshot()`** + +Remove `getMeshGroup()` (callers updated in Task 18). Replace screenshot to read the GL framebuffer — for +now, route through the canvas snapshot which openglfx supports as a JavaFX `Region`: + +```java +public void takeViewScreenshot() throws IOException { + WritableImage snapshot = glViewport.getNode().snapshot(null, null); + File outputFile = new File("img.png"); + BufferedImage bImage = SwingFXUtils.fromFXImage(snapshot, null); + ImageIO.write(bImage, "png", outputFile); +} +``` + +- [ ] **Step 4: Build** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: compile errors only where `Renderer` still calls `getMeshGroup()` — fixed in Task 18. If you do +Task 18 in the same session, build at the end of 18 instead. + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java +git commit -m "Mount GLViewport in MainWindow, route clicks through GPU picking" +``` + +--- + +### Task 18: Rewire `Renderer` to build `SceneData` and drive `GLViewport` + +Keep `Renderer`'s public API; replace `TriangulatedGeometry`/`meshGroup` usage with `SceneBuilder` + +`GLViewport`. Filter/LOD/roof state now produces an index-set predicate. + +**Files:** +- Modify: `.../gui/Renderer.java` +- Modify: `.../gui/MainWindow.java` (add `getGlViewport()` accessor) + +- [ ] **Step 1: Add a viewport accessor to `MainWindow`** + +```java +public GLViewport getGlViewport() { return glViewport; } +``` + +- [ ] **Step 2: Replace scene construction in `Renderer`** + +Replace fields `currentTriGeom`/`meshGroup` usage. Keep the existing filter objects (`lodFilters`, +`roofFilter`). After building `SceneData`, build the initial index set from the active filters and push both +to the viewport. Core changes: + +```java +private SceneData currentScene; + +private void setupRenderState() { + GLViewport vp = mainWindow.getGlViewport(); + vp.setScene(currentScene); + rebuildIndexSet(); + vp.setCulling(currentCulling == CullFace.BACK); + vp.setWireframe(currentDrawMode == DrawMode.LINE); + mainWindow.getGridButton().setDisable(false); + mainWindow.getCullingButton().setDisable(false); +} + +private void rebuildIndexSet() { + if (currentScene == null) { + return; + } + int[] indices = IndexSetBuilder.build(currentScene.triangleMetas(), this::triangleVisible); + mainWindow.getGlViewport().setIndexSet(indices); +} + +private boolean triangleVisible(TriangleMeta m) { + // LOD filter: at least one enabled lodFilter must accept this triangle's lod + boolean lodOk = false; + for (ViewFilter f : lodFilters) { + if (f.isEnabled() && f.acceptsLod(m.lod())) { // add acceptsLod helper or compare ordinals + lodOk = true; + break; + } + } + if (!lodOk) { + return false; + } + // roof filter + return !(roofFilter.isEnabled() && m.roof()); +} +``` + +> The legacy `ViewFilter` decides on `(CityObject, Geometry)`. Here triangles already carry `lod`/`roof`. +> Add small helpers to `TriangleMeta`/filters as needed, or store the `Lod` ordinal on `TriangleMeta` +> (already present) and compare against each filter's target LOD. Keep the visible-set semantics identical +> to the legacy `addPolygons`/`isGeometryFiltered` logic (a triangle shows iff its LOD filter is enabled and, +> when roof-hiding is on, it is not a roof). + +Replace the various `render(...)` methods so they: +1. build `SceneData` via `SceneBuilder` (using the same per-feature base colors as the legacy + `TriangulatedGeometry.of(model, filters)` — port that feature/color iteration into a + `SceneBuilder.fromModel(model)` overload that tags each triangle's `lod`/`roof`), +2. assign `currentScene`, +3. call `setupRenderState()` on the FX thread. + +The LOD enable/disable and roof methods call `rebuildIndexSet()` instead of `refresher.run()` when only the +filter changed: + +```java +public void enableLod1() { lodFilters.get(0).enable(); rebuildIndexSet(); } +// ...same pattern for the other LOD toggles and hideRoofs/showRoofs... +``` + +- [ ] **Step 3: Replace highlight/wireframe/cull delegations** + +```java +public void showWireFrame(boolean show) { + currentDrawMode = show ? DrawMode.LINE : DrawMode.FILL; + mainWindow.getGlViewport().setWireframe(show); +} + +public void enableCulling(boolean enable) { + currentCulling = enable ? CullFace.BACK : CullFace.NONE; + mainWindow.getGlViewport().setCulling(enable); +} +``` + +Highlight methods delegate to `HighlightController` (rewritten in Task 19), which pushes marker arrays to +the viewport. Selection of a clicked polygon: when a pick resolves, set the selected id via +`vp.setSelectedId(id)` (the `GLViewport` already does this in its pick handler; `Renderer.highlight(Polygon)` +should additionally drive the overlay markers through `HighlightController`). + +- [ ] **Step 4: Build** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS once `HighlightController` (Task 19) compiles. If splitting sessions, stub the +highlight calls to no-ops to get a green compile, then implement Task 19. + +- [ ] **Step 5: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/Renderer.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java +git commit -m "Drive GLViewport from Renderer via SceneBuilder and index sets" +``` + +--- + +### Task 19: Rewrite `HighlightController` to feed the overlay + +Replace JavaFX `Sphere`/`Cylinder` node creation with arrays of marker points/lines pushed to +`GLViewport.setHighlights`. Keep the public method names so callers (`Renderer`, error visitors) are +unchanged. Positions are recentred using the current scene center. + +**Files:** +- Modify: `.../gui/HighlightController.java` + +- [ ] **Step 1: Replace internals** + +Constructor takes the `GLViewport` (and the current scene center supplier). Maintain growing point/line +lists; on each `highlight*`/`clearHighlights`, rebuild and call `setHighlights`. Marker color → rgb floats. +`changeScaling(translateZ)` maps to `GLViewport.setPointScale(...)` using the same clamp math as today +(`scale = clamp(|z|,10,150) * 0.01`, then point size in pixels, e.g. `scale * BASE_POINT_PX`). + +```java +public class HighlightController { + + private final GLViewport viewport; + private java.util.function.Supplier centerSupplier; // current scene center + private final FloatList points = new FloatList(); + private final FloatList pointColors = new FloatList(); + private final FloatList lines = new FloatList(); + private final FloatList lineColors = new FloatList(); + private float pointScale = 5f; + + public HighlightController(GLViewport viewport) { + this.viewport = viewport; + } + + public void setCenterSupplier(java.util.function.Supplier supplier) { + this.centerSupplier = supplier; + } + + public void clearHighlights() { + // reset lists + // (FloatList currently has no clear(); add a clear() that resets size to 0) + flush(true); + } + + public void changeScaling(double translateZ) { + double s = Math.min(150, Math.max(10, Math.abs(translateZ))) * 0.01; + pointScale = (float) (s * 8.0); // BASE_POINT_PX tuned in verification + viewport.setPointScale(pointScale); + } + // highlight(Polygon), highlight(LinearRing), highlight(Edge), highlight(Vertex), + // highlight(List), highlightEdges, highlightPolygons, addHighlight(...) : + // compute recentred xyz for each vertex -> points + color; for each edge -> 2 line endpoints. + // Then call flush(false). +} +``` + +Add `FloatList.clear()` (set `size = 0`) — update `FloatListTest` with a `clear()` case in the same commit. + +`flush(boolean cleared)` calls +`viewport.setHighlights(points.toArray(), pointColors.toArray(), lines.toArray(), lineColors.toArray());` + +Port the per-vertex recentring from the old `highlightPoint`/`highlightEdge` (subtract scene center). The +sphere→point and cylinder→line mapping preserves which vertices/edges are shown and their colors (RED/BLUE +exterior/interior, ORANGE edges, the multi-color `highlightPolygons` palette). + +- [ ] **Step 2: Update construction site** + +In `MainWindow.start(...)`, `HighlightController` is built with `world` today +(`new HighlightController(world)`). Change to `new HighlightController(glViewport)` and wire the center +supplier from the `Renderer`'s current `SceneData` (`() -> currentScene == null ? new double[3] : currentScene.center()`). + +- [ ] **Step 3: Build** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests compile` +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/HighlightController.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java \ + CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java +git commit -m "Render highlights through GL overlay instead of JavaFX nodes" +``` + +--- + +### Task 20: Delete `TriangulatedGeometry` and dead JavaFX 3D code + +**Files:** +- Delete: `.../gui/TriangulatedGeometry.java` +- Modify: any remaining references (`ListErrorVisitor`, `GlobalErrorVisitor`, `Renderer`) + +- [ ] **Step 1: Find references** + +Run: `grep -rn "TriangulatedGeometry\|getMeshGroup\|setDrawMode\|setCullFace" CityDoctorParent/Extensions/CityDoctorGUI/src/main` +Expected: a short list. `ListErrorVisitor.setGeometry(TriangulatedGeometry)` likely needs to take +`SceneData`/`GLViewport` instead, or be reworked to call `Renderer.highlight(...)` paths. Update each to the +new highlight API (the visitors ultimately call `highlightController`/`renderer.highlight(...)`). + +- [ ] **Step 2: Delete the class and fix references** + +```bash +git rm CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/TriangulatedGeometry.java +``` +Then update the references found in Step 1 so they compile against the new API. + +- [ ] **Step 3: Build + run the full module test suite** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DfailIfNoTests=false test` +Expected: BUILD SUCCESS; the Phase 1 unit tests pass. (Per project norms, ignore unrelated known-failing +charset/GUI tests noted in repo memory.) + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "Remove TriangulatedGeometry and dead JavaFX 3D rendering code" +``` + +--- + +## Phase 4 — Manual verification & packaging + +### Task 21: Run the app and verify feature parity + +**Files:** none (verification only; fixes go back into the relevant task's files) + +- [ ] **Step 1: Launch with a sample model** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -DskipTests exec:java -Dexec.mainClass=de.hft.stuttgart.citydoctor2.gui.MainWindow` +Then open `CityDoctorParent/Extensions/CityDoctorGUI/src/test/resources/SimpleSolid_SrefBS-GE-gml-LR-0001-T0001.gml` +(or a larger city model). + +- [ ] **Step 2: Verify each feature and note pass/fail** + +Check, and fix the owning class where a check fails: +- Geometry renders with surface-differentiating shades (ShadeColor). +- Orbit (left-drag), pan (right-drag), zoom (scroll) behave like before; if drag direction is inverted, + flip the delta signs in `GLViewport.installInputHandlers` (Task 16). +- Reset camera, north arrow alignment. +- Click a polygon → it highlights (selection tint) and the tree/error panels respond (picking + dispatch). +- Vertex/edge highlights appear at the right places and scale with zoom. +- LOD1–4 toggles, hide-roofs toggle change what's drawn. +- Wireframe toggle, back-face culling toggle. +- Per-polygon error coloring after a validation run. +- Screenshot button writes `img.png`. + +- [ ] **Step 3: Verify performance on a large model** + +Load a multi-building/city model; confirm orbit/zoom stays smooth (no multi-second stalls as before). +If picking causes a hitch, confirm readback only happens on click (it does, by design). + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git commit -m "Fix OpenGL view issues found during manual verification" +``` + +--- + +### Task 22: Packaging — ship JOGL/openglfx natives in win/linux bundles + +**Files:** +- Modify: `CityDoctorParent/Extensions/CityDoctorGUI/src/assembly/win/assembly.xml` +- Modify: `CityDoctorParent/Extensions/CityDoctorGUI/src/assembly/lin/assembly.xml` +- (Inspect, likely no change) `.../src/assembly/no_runtime/assembly.xml` + +- [ ] **Step 1: Inspect the assembly descriptors** + +Run: `ls CityDoctorParent/Extensions/CityDoctorGUI/src/assembly && cat CityDoctorParent/Extensions/CityDoctorGUI/src/assembly/win/assembly.xml` +Confirm how dependency jars are bundled (a `dependencySets` block). The openglfx + JOGL native jars +(`*-natives-windows-amd64.jar`, `*-natives-linux-amd64.jar`, `grapl-natives-*`, `openglfx-natives-*`) must be +included on the runtime classpath. If `dependencySets` already includes all runtime-scope deps, no change is +needed — verify the native jars appear. + +- [ ] **Step 2: Build the Windows/Linux bundles** + +Run: `mvn -pl Extensions/CityDoctorGUI -am -Pcreate-binaries install` +Expected: bundles produced under `target/`. Inspect that native jars are present in the assembled lib dir. + +- [ ] **Step 3: Smoke-launch a bundle** + +Unzip the produced `-win`/`-lin` (as applicable to your OS) bundle and launch its start script; confirm the +3D view renders (no `UnsatisfiedLinkError`). + +- [ ] **Step 4: Commit any descriptor changes** + +```bash +git add CityDoctorParent/Extensions/CityDoctorGUI/src/assembly/ +git commit -m "Bundle JOGL/openglfx natives in win/linux distributions" +``` + +--- + +### Task 23: Changelog & docs + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Add a changelog entry** + +Add under the current unreleased section: + +```markdown +- Replaced the JavaFX-based 3D view with an OpenGL renderer (openglfx/JOGL) for smooth display of + large city models. Picking is now GPU-based; the 3D view requires OpenGL 3.3 (Windows/Linux). +``` + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "Document OpenGL 3D view in changelog" +``` + +--- + +## Self-Review notes (for the executor) + +- **Spec coverage:** §3 components → Tasks 11–19; single global VBO + index set → Tasks 7,8,13,18; picking + → Tasks 2,3,14,16,17; baked fake shading → Tasks 4,10,12; selection tint → Tasks 12,16; overlay highlights + → Tasks 15,19; feature parity → Task 21; removals → Task 20; packaging → Task 22. +- **Consistency to watch:** `PickingCodec` byte order (red = low byte) must match `pick.frag`. The + `PickRegistry` stores either raw `Polygon`/`Vertex`/`Edge` or `ClickDispatcher` wrappers — pick one in + Task 10/17 and keep `SceneBuilderTest`'s `instanceof` assertion aligned. `FloatList.clear()` is introduced + in Task 19; update its test in the same commit. +- **Known soft spots needing run-time tuning (called out inline):** camera handedness/drag-sign + (Task 6/16/21), openglfx repaint vs continuous-render trigger (Task 16), `glPolygonMode` constant + interface (Task 16), screenshot via `Region.snapshot` vs `glReadPixels` (Task 17 — upgrade to framebuffer + readback if the snapshot of a GL canvas comes back blank). diff --git a/docs/superpowers/specs/2026-06-18-opengl-3d-view-design.md b/docs/superpowers/specs/2026-06-18-opengl-3d-view-design.md new file mode 100644 index 0000000000000000000000000000000000000000..6c17ba89bfce51e5985463e9c0c1f2230a8ebb63 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-opengl-3d-view-design.md @@ -0,0 +1,166 @@ +# OpenGL 3D View — Design Spec + +**Date:** 2026-06-18 +**Branch:** 107-opengl-view +**Component:** `CityDoctorParent/Extensions/CityDoctorGUI` + +## 1. Problem & Goal + +The GUI renders 3D CityGML geometry with JavaFX, tesselating **each polygon into its own +`TriangleMesh` + `MeshView` + `PhongMaterial`** (see `TriangulatedGeometry`). At city scale this +produces thousands of scene-graph nodes, each with its own material — the JavaFX node overhead, not +just the lack of shader access, makes orbit/zoom stutter or freeze. + +**Goal:** Replace the JavaFX `SubScene` 3D view with an OpenGL-rendered viewport that orbits, zooms, +and pans smoothly on whole-city models (millions of triangles), while preserving every existing view +feature (picking, highlights, filters, wireframe, culling, north arrow, screenshot). + +## 2. Decisions (settled during brainstorming) + +| Topic | Decision | +|-------|----------| +| Scale target | Whole cities, millions of triangles; smooth (30–60 fps) interaction. | +| Integration | Embed an OpenGL canvas via the already-present `openglfx-jogl` (huskerdev) dependency, in the `meshView` `Pane` where the `SubScene` lives today. | +| GL version | OpenGL **3.3 core profile**, GLSL shaders, VAO/VBO. | +| Platforms | **Windows + Linux** are hard targets. macOS bundle still builds but the GL view may degrade — accepted. | +| JavaFX 3D path | **Fully removed** (no fallback). | +| Buffer strategy | **Single global static VBO**; one `glDrawElements` per frame; filter/LOD changes rebuild the draw **index set** (EBO), not the VBO. | +| Picking | **GPU color-picking**: off-screen FBO encodes `polygonId` per fragment; `glReadPixels` on click. | +| Shading | **Keep existing fake shading** (`normal·AXIS` → brightness), **baked into per-vertex color on the CPU** at build time. No lighting in the shader. Normal attribute dropped from the VBO. | +| Selection | Shader `selectedId` uniform tints matching fragments — no buffer mutation. | +| Vertex/edge highlights | Separate small **dynamic** GL overlay buffer (replaces JavaFX `Sphere`/`Cylinder`). | + +## 3. Architecture & Components + +New/changed classes in `de.hft.stuttgart.citydoctor2.gui` (or a new `gui.gl` sub-package): + +- **`GLViewport`** *(new)* — wraps the openglfx canvas, owns the GL context, the render callback, the + `Camera`, and GL resources (shaders, VAO/VBO/EBO, picking FBO, overlay buffer). Replaces the JavaFX + `SubScene`/`world`/`meshGroup`/`PerspectiveCamera`/`AmbientLight` built in `MainWindow.setup3dView`. +- **`SceneBuilder`** *(replaces `TriangulatedGeometry`)* — flattens the model into interleaved vertex + data and the `id → Polygon/Vertex/Edge` maps. Runs on a worker thread (as `Renderer.render` already + does). Reuses existing `Polygon.tesselate()` → `TesselatedPolygon`/`Triangle3d`, the + `findCenter`/`movedBy` recentering, and the `calculateMaterial` shade math. +- **`SceneBuffers`** *(new)* — owns the global VBO + static vertex data and rebuilds/uploads the draw + index set on filter/LOD changes. GL-thread owned; uploads queued from the worker thread. +- **`Camera`** *(new)* — view/projection matrices replicating today's interaction: orbit (Z + X + rotation, today on the `world` group), right-drag pan, scroll zoom, `zoomOutForBoundingBox`, + `resetCamera`. Reuses the existing angle/translate state in `MainWindow`. +- **`PickingPass`** *(new)* — off-screen FBO sized to the canvas; renders the `polygonId` pass and + reads back the cursor pixel on click. +- **`HighlightOverlay`** *(replaces the `Sphere`/`Cylinder` rendering in `HighlightController`)* — + small dynamic buffer for vertex/edge markers, rescaled on zoom like `changeScaling` does now. +- **`Renderer`** *(kept)* — **public API unchanged** (`render(model)`, `render(Geometry)`, + `render(CityObject)`, `highlight(...)`, `showWireFrame`, `enableCulling`, `enableLodN`/`disableLodN`, + `hideRoofs`/`showRoofs`, `clearCurrentRender`, etc.) so `CityDoctorController`, `MainWindow`, and the + error visitors (`ListErrorVisitor`, `GlobalErrorVisitor`) are largely untouched. Internally it drives + `SceneBuilder`/`SceneBuffers`/`GLViewport` instead of the JavaFX `meshGroup`. + +## 4. Geometry Pipeline (single global VBO) + +At load, off the FX thread: + +1. Iterate city objects → polygons → `tesselate()` → triangles, recentered by `movedBy` + (`SceneBuilder.findCenter`, same as today). +2. Build **one interleaved static VBO** with per-vertex attributes: + - `position` — `vec3 float` + - `color` — `vec3 float`, the **baked shaded color** (see §6) + - `polygonId` — `uint` (or packed into a `vec4` for the picking pass) + (No normal attribute — shading is baked, picking/selection use the id.) +3. Build CPU-side maps **`polygonId → Polygon`** (and ids for vertices/edges) — these replace the + per-`MeshView` `userData` dispatchers (`PolygonClickDispatcher`, `VertexClickDispatcher`). +4. Build an **index buffer (EBO)** listing the triangles to draw. The existing `Renderer` filter set + (`lodFilters`, `roofFilter`) determines membership; toggling a filter rebuilds the index array on + the CPU and re-uploads it. The VBO is never touched. One `glDrawElements` per frame. + +`polygonId` is a 32-bit value (RGBA8 in the picking FBO → ~4.2B ids), more than enough. + +## 5. Render Loop & Threading + +- openglfx calls a render callback on its GL render thread with a live context. The callback only + **draws** from prepared buffers (main pass; picking pass on demand; overlay pass). +- GL resource **creation/upload** also happens on the GL thread: `SceneBuilder` runs on a worker + thread and **queues** the prepared arrays; the render callback drains the queue and uploads. +- FX-thread code (filter toggles, tree selection, camera input) sets dirty flags / state the render + callback consumes. No GL calls off the GL thread. + +## 6. Shading (baked, fake) + +Keep the current surface-differentiation shading. At build time, for each polygon compute exactly as +`TriangulatedGeometry.calculateMaterial` does today: + +``` +normal = p.calculateNormalNormalized() +base = p.getRenderColor() (if set and not WHITE) else feature base color +acos = Math.acos(normal·AXIS) / Math.PI // AXIS = normalize(19, 0.8, 1.5) +shade = acos * 0.6 + 0.3 // range [0.3, 0.9] +vertexColor = base.deriveColor(0, 1.0, shade, 1.0) +``` + +Store `vertexColor` as the per-vertex `color` attribute. The fragment shader outputs the interpolated +vertex color directly — no lighting computation in the shader. + +## 7. Picking (GPU color-picking) + +- A picking shader writes `polygonId` (as RGBA8) into an off-screen FBO sized to the canvas. +- On click, reuse `MainWindow`'s existing press/release "did the mouse move >3px?" gate. Render (or + reuse) the id pass, `glReadPixels` the cursor pixel → `polygonId` → `Polygon`/`Vertex`/`Edge` via the + CPU map → same downstream handling that `PolygonClickDispatcher`/`VertexClickDispatcher` perform now. +- Readback only on click (never per frame) to avoid GPU stalls. + +## 8. Selection & Highlights + +- **Selection tint:** the main shader takes a `selectedId` uniform (or a small id set); fragments whose + id matches are tinted. Highlighting a clicked polygon needs no buffer mutation. +- **Vertex/edge markers** (today's `HighlightController` spheres/cylinders): drawn from a separate + **dynamic** buffer after the main pass — points/instanced quads for vertices, line segments (or thin + instanced cylinders) for edges — rescaled on zoom as `changeScaling` does today. `HighlightController` + keeps its public API (`highlight(Polygon/LinearRing/Edge/Vertex/...)`, `clearHighlights`, + `changeScaling`, `highlightEdges`, `highlightPolygons`) so callers are unaffected. + +## 9. Feature Parity Checklist + +All preserved: +- LOD1–LOD4 filters, hide-roofs toggle (index-set rebuild). +- Wireframe (`DrawMode.LINE` → `glPolygonMode(GL_LINE)` with the black grid color). +- Back-face culling toggle (`glEnable/Disable(GL_CULL_FACE)`). +- Per-feature base colors and per-polygon error coloring (`Polygon.getRenderColor()`). +- Vertex/edge/polygon highlighting. +- North-arrow overlay — stays a JavaFX `ImageView` layered on top of the GL canvas in the `meshView` + `Pane`; `alignNorthArrow` logic unchanged. +- Screenshot (`MainWindow.takeViewScreenshot`) — `glReadPixels` of the framebuffer → `BufferedImage` → + PNG, replacing `SubScene.snapshot`. +- Camera orbit/pan/zoom, `resetCamera`, `zoomOutForBoundingBox`. + +## 10. Removals + +- `TriangulatedGeometry` (replaced by `SceneBuilder`/`SceneBuffers`). +- The JavaFX 3D scene in `MainWindow.setup3dView`: `SubScene geomScene`, `world`/`meshGroup` groups, + `PerspectiveCamera`, `AmbientLight`, `cameraXRotation`/`cameraZRotation` transforms, and + `getMeshGroup()`. +- `Sphere`/`Cylinder` rendering inside `HighlightController` (logic moves to `HighlightOverlay`). +- Mesh-level JavaFX picking (`pickResult.getIntersectedNode()` → replaced by color-picking). + +## 11. Risks & Mitigations + +- **Native lib packaging:** openglfx + JOGL natives must ship in the win/lin assembly descriptors + (`src/assembly/{win,lin}/assembly.xml`). Verify the assembled bundles launch and render. +- **GL context lifecycle vs JavaFX resize:** handle canvas resize (recreate picking FBO, update + viewport/projection). +- **Picking readback stalls:** mitigated by reading only on click. +- **macOS degradation:** accepted; not a hard target. + +## 12. Testing + +- **Unit (headless):** `SceneBuilder` flattening — vertex/triangle counts, `id → Polygon` mapping + correctness, recentering by `movedBy`, baked-color math matches `calculateMaterial`. +- **Manual/visual:** rendering correctness, picking accuracy (click → correct polygon/vertex/edge), + filter/LOD/roof toggles, wireframe, culling, highlights, north arrow, screenshot, camera controls — + on a large city model, confirming smooth interaction. + +## 13. Out of Scope + +- LOD-of-detail mesh simplification / frustum culling / occlusion culling (single global VBO + one draw + call is expected to suffice; revisit only if profiling shows a need). +- macOS-specific GL/threading work. +- Any change to validation, parsing, or non-GUI modules.