From 2b6c655bb63dbade0677f0fe1a63edf3b2d34330 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Wed, 29 Jan 2025 13:25:19 +0100 Subject: [PATCH 01/40] removing jogl dependency --- CityDoctorParent/CityDoctorModel/pom.xml | 8 - .../datastructure/ConcretePolygon.java | 14 +- .../hft/stuttgart/citydoctor2/math/Ray.java | 6 - .../tesselation/JoglTesselator.java | 169 ------------------ .../citydoctor2/tesselation/Primitive.java | 118 ------------ 5 files changed, 7 insertions(+), 308 deletions(-) delete mode 100644 CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java delete mode 100644 CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java diff --git a/CityDoctorParent/CityDoctorModel/pom.xml b/CityDoctorParent/CityDoctorModel/pom.xml index ae659662..e4ca0142 100644 --- a/CityDoctorParent/CityDoctorModel/pom.xml +++ b/CityDoctorParent/CityDoctorModel/pom.xml @@ -30,14 +30,6 @@ gov.nist.math jama - - org.jogamp.gluegen - gluegen-rt-main - - - org.jogamp.jogl - jogl-all-main - junit junit diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java index a654fd4c..28659da3 100644 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java @@ -18,21 +18,21 @@ */ package de.hft.stuttgart.citydoctor2.datastructure; +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import de.hft.stuttgart.citydoctor2.check.Check; import de.hft.stuttgart.citydoctor2.check.CheckError; import de.hft.stuttgart.citydoctor2.check.CheckId; import de.hft.stuttgart.citydoctor2.check.Checkable; import de.hft.stuttgart.citydoctor2.math.Vector3d; -import de.hft.stuttgart.citydoctor2.tesselation.JoglTesselator; +import de.hft.stuttgart.citydoctor2.tesselation.EarcutTesselator; import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; import de.hft.stuttgart.citydoctor2.utils.CopyHandler; import de.hft.stuttgart.citydoctor2.utils.Copyable; -import java.io.Serial; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - /** * A polygon that actually contains the rings and points representing the * geometry @@ -77,7 +77,7 @@ public class ConcretePolygon extends Polygon { */ @Override public TesselatedPolygon tesselate() { - return JoglTesselator.tesselatePolygon(this); + return EarcutTesselator.tesselatePolygon(this); } /* diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java index fb1f5d9c..0238f44e 100644 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java @@ -18,9 +18,7 @@ */ package de.hft.stuttgart.citydoctor2.math; -import de.hft.stuttgart.citydoctor2.datastructure.Polygon; import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; -import de.hft.stuttgart.citydoctor2.tesselation.JoglTesselator; /** * A ray constructed by a point and a direction. A ray has a starting point and @@ -97,8 +95,4 @@ public class Ray { } } - public Vector3d intersectsPolygon(Polygon p) { - return intersectsPolygon(JoglTesselator.tesselatePolygon(p)); - } - } diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java deleted file mode 100644 index a98c330b..00000000 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java +++ /dev/null @@ -1,169 +0,0 @@ -/*- - * Copyright 2020 Beuth Hochschule für Technik Berlin, Hochschule für Technik Stuttgart - * - * This file is part of CityDoctor2. - * - * CityDoctor2 is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * CityDoctor2 is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with CityDoctor2. If not, see . - */ -package de.hft.stuttgart.citydoctor2.tesselation; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import com.jogamp.opengl.GL; -import com.jogamp.opengl.glu.GLU; -import com.jogamp.opengl.glu.GLUtessellator; -import com.jogamp.opengl.glu.GLUtessellatorCallbackAdapter; - -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.math.Vector3d; - -/** - * Tesselator to create triangles out of polygons. Uses JOGL implementation. - * - * @author Matthias Betz - * - */ -public class JoglTesselator { - - private static final String DATA_WAS_NOT_A_LIST = "data was not a list"; - private static final GLU glu = new GLU(); - private static final GLUtessellator tess = GLU.gluNewTess(); - private static final GLUtessellatorCallbackAdapter callback; - private static final ArrayList primitives = new ArrayList<>(); - - static { - callback = new GLUtessellatorCallbackAdapter() { - - private Primitive lastPrimitive; - - @Override - public void beginData(int type, Object data) { - if (!(data instanceof ArrayList)) { - throw new IllegalStateException(DATA_WAS_NOT_A_LIST); - } - @SuppressWarnings("unchecked") - ArrayList vertices = (ArrayList) data; - lastPrimitive = new Primitive(type, vertices); - primitives.add(lastPrimitive); - } - - @Override - public void vertexData(Object vertex, Object data) { - if (!(data instanceof ArrayList)) { - throw new IllegalStateException(DATA_WAS_NOT_A_LIST); - } - if (!(vertex instanceof Integer)) { - throw new IllegalStateException("vertex has to be an Integer"); - } - lastPrimitive.addIndex((Integer) vertex); - } - - @Override - public void combineData(double[] coords, Object[] vertexIndices, float[] weights, Object[] outgoingData, - Object data) { - if (!(data instanceof ArrayList)) { - throw new IllegalStateException(DATA_WAS_NOT_A_LIST); - } - @SuppressWarnings("unchecked") - ArrayList vertices = (ArrayList) data; - // to ensure the coordinates are valid - double[] myCoords = Arrays.copyOf(coords, 3); - Vector3d newVertex = new Vector3d(myCoords); - outgoingData[0] = vertices.size(); - vertices.add(newVertex); - } - - @Override - public void errorData(int errnum, Object data) { - throw new TesselationException("GLU Error " + glu.gluErrorString(errnum)); - } - - }; - GLU.gluTessCallback(tess, GLU.GLU_TESS_BEGIN_DATA, callback); - GLU.gluTessCallback(tess, GLU.GLU_TESS_VERTEX_DATA, callback); - GLU.gluTessCallback(tess, GLU.GLU_TESS_ERROR_DATA, callback); - GLU.gluTessCallback(tess, GLU.GLU_TESS_COMBINE_DATA, callback); - GLU.gluTessCallback(tess, GLU.GLU_TESS_END_DATA, callback); - } - - public static TesselatedPolygon tesselatePolygon(Polygon p) { - return tesselatePolygon(p, true); - } - - public static TesselatedPolygon tesselatePolygon(Polygon p, boolean discardDegeneratedTriangles) { - ArrayList vertices = new ArrayList<>(); - Vector3d normal = p.calculateNormalNormalized(); - synchronized (tess) { - GLU.gluTessProperty(tess, GLU.GLU_TESS_BOUNDARY_ONLY, GL.GL_FALSE); - GLU.gluTessBeginPolygon(tess, vertices); - GLU.gluTessNormal(tess, normal.getX(), normal.getY(), normal.getZ()); - - // exterior ring - LinearRing ext = p.getExteriorRing(); - createContour(vertices, ext); - - // interior rings - for (LinearRing lr : p.getInnerRings()) { - createContour(vertices, lr); - } - - GLU.gluTessEndPolygon(tess); - ArrayList indices = new ArrayList<>(); - for (Primitive primitive : primitives) { - primitive.fillListWithIndices(indices, discardDegeneratedTriangles); - } - primitives.clear(); - return new TesselatedPolygon(vertices, indices, p); - } - } - - private static void createContour(List vertices, LinearRing lr) { - GLU.gluTessBeginContour(tess); - for (Vertex v : lr.getVertices()) { - vertices.add(v); - GLU.gluTessVertex(tess, v.getCoordinates(), 0, vertices.size() - 1); - } - GLU.gluTessEndContour(tess); - } - - public static TesselatedRing tesselateRing(LinearRing r, boolean discardDegeneratedTriangles) { - ArrayList vertices = new ArrayList<>(); - Vector3d normal = r.calculateNormal(); - synchronized (tess) { - GLU.gluTessBeginPolygon(tess, vertices); - GLU.gluTessNormal(tess, normal.getX(), normal.getY(), normal.getZ()); - createContour(vertices, r); - GLU.gluTessEndPolygon(tess); - ArrayList indices = new ArrayList<>(); - for (Primitive primitive : primitives) { - primitive.fillListWithIndices(indices, discardDegeneratedTriangles); - } - primitives.clear(); - return new TesselatedRing(vertices, indices, r); - } - } - - public static TesselatedRing tesselateRing(LinearRing r) { - return tesselateRing(r, true); - } - - private JoglTesselator() { - // only static useage - } - -} diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java deleted file mode 100644 index 4e65d890..00000000 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java +++ /dev/null @@ -1,118 +0,0 @@ -/*- - * Copyright 2020 Beuth Hochschule für Technik Berlin, Hochschule für Technik Stuttgart - * - * This file is part of CityDoctor2. - * - * CityDoctor2 is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * CityDoctor2 is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with CityDoctor2. If not, see . - */ -package de.hft.stuttgart.citydoctor2.tesselation; - -import java.util.ArrayList; -import java.util.List; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import com.jogamp.opengl.GL; - -import de.hft.stuttgart.citydoctor2.math.Vector3d; - -/** - * Result of the tesselation process. Primitives can be converted to triangles. - * - * @author Matthias Betz - * - */ -public class Primitive { - - private static final Logger logger = LogManager.getLogger(Primitive.class); - - private static final double AREA_EPSILON = 0.00001; - - private final int type; - private final List pIndices = new ArrayList<>(); - private final List vertices; - - public Primitive(int type, List vertices) { - this.vertices = vertices; - this.type = type; - } - - public void fillListWithIndices(List indices, boolean discardDegeneratedTriangles) { - switch (type) { - case GL.GL_TRIANGLES: - for (int i = 0; i < pIndices.size(); i += 3) { - addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 1), pIndices.get(i + 2), indices, - discardDegeneratedTriangles); - } - break; - case GL.GL_TRIANGLE_STRIP: - for (int i = 0; i < pIndices.size() - 2; i++) { - if (i % 2 == 0) { - addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 1), pIndices.get(i + 2), indices, - discardDegeneratedTriangles); - } else { - addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 2), pIndices.get(i + 1), indices, - discardDegeneratedTriangles); - } - } - break; - case GL.GL_TRIANGLE_FAN: - Integer first = pIndices.get(0); - for (int i = 0; i < pIndices.size() - 2; i++) { - addToListIfAreaNonNull(first, pIndices.get(i + 1), pIndices.get(i + 2), indices, - discardDegeneratedTriangles); - } - break; - default: - throw new IllegalStateException("unknown triangle type found: " + type); - } - } - - public void addIndex(Integer i) { - pIndices.add(i); - } - - private void addToListIfAreaNonNull(Integer i1, Integer i2, Integer i3, List indices, - boolean discardDegeneratedTriangles) { - Vector3d v1 = vertices.get(i1); - Vector3d v2 = vertices.get(i2); - Vector3d v3 = vertices.get(i3); - if (discardDegeneratedTriangles && Math.abs(calculateAreaOfTriangle(v1, v2, v3)) <= AREA_EPSILON) { - // switch to discard degenerated triangles is enabled and triangle has nearly no area - logger.trace("Tesselator created degenerated triangle, ignoring"); - } else { - indices.add(i1); - indices.add(i2); - indices.add(i3); - } - } - - /** - * Calculates the area of a triangle given by the 3 points - * - * @param v1 the first point of the triangle - * @param v2 the second point of the triangle - * @param v3 the last point of the triangle - * @return the area of the triangle - */ - private static double calculateAreaOfTriangle(Vector3d v1, Vector3d v2, Vector3d v3) { - double a = v1.getDistance(v2); - double b = v2.getDistance(v3); - double c = v1.getDistance(v3); - double s = 0.5d * (a + b + c); - return Math.sqrt(s * (s - a) * (s - b) * (s - c)); - } - -} -- GitLab From 7a5a91322ab18069e91fb5ac8620c207ea8e2213 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Wed, 29 Jan 2025 13:54:30 +0100 Subject: [PATCH 02/40] Revert "removing jogl dependency" This reverts commit 2b6c655bb63dbade0677f0fe1a63edf3b2d34330. --- CityDoctorParent/CityDoctorModel/pom.xml | 8 + .../datastructure/ConcretePolygon.java | 14 +- .../hft/stuttgart/citydoctor2/math/Ray.java | 6 + .../tesselation/JoglTesselator.java | 169 ++++++++++++++++++ .../citydoctor2/tesselation/Primitive.java | 118 ++++++++++++ 5 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java create mode 100644 CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java diff --git a/CityDoctorParent/CityDoctorModel/pom.xml b/CityDoctorParent/CityDoctorModel/pom.xml index e4ca0142..ae659662 100644 --- a/CityDoctorParent/CityDoctorModel/pom.xml +++ b/CityDoctorParent/CityDoctorModel/pom.xml @@ -30,6 +30,14 @@ gov.nist.math jama + + org.jogamp.gluegen + gluegen-rt-main + + + org.jogamp.jogl + jogl-all-main + junit junit diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java index 28659da3..a654fd4c 100644 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/datastructure/ConcretePolygon.java @@ -18,21 +18,21 @@ */ package de.hft.stuttgart.citydoctor2.datastructure; -import java.io.Serial; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - import de.hft.stuttgart.citydoctor2.check.Check; import de.hft.stuttgart.citydoctor2.check.CheckError; import de.hft.stuttgart.citydoctor2.check.CheckId; import de.hft.stuttgart.citydoctor2.check.Checkable; import de.hft.stuttgart.citydoctor2.math.Vector3d; -import de.hft.stuttgart.citydoctor2.tesselation.EarcutTesselator; +import de.hft.stuttgart.citydoctor2.tesselation.JoglTesselator; import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; import de.hft.stuttgart.citydoctor2.utils.CopyHandler; import de.hft.stuttgart.citydoctor2.utils.Copyable; +import java.io.Serial; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + /** * A polygon that actually contains the rings and points representing the * geometry @@ -77,7 +77,7 @@ public class ConcretePolygon extends Polygon { */ @Override public TesselatedPolygon tesselate() { - return EarcutTesselator.tesselatePolygon(this); + return JoglTesselator.tesselatePolygon(this); } /* diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java index 0238f44e..fb1f5d9c 100644 --- a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/math/Ray.java @@ -18,7 +18,9 @@ */ package de.hft.stuttgart.citydoctor2.math; +import de.hft.stuttgart.citydoctor2.datastructure.Polygon; import de.hft.stuttgart.citydoctor2.tesselation.TesselatedPolygon; +import de.hft.stuttgart.citydoctor2.tesselation.JoglTesselator; /** * A ray constructed by a point and a direction. A ray has a starting point and @@ -95,4 +97,8 @@ public class Ray { } } + public Vector3d intersectsPolygon(Polygon p) { + return intersectsPolygon(JoglTesselator.tesselatePolygon(p)); + } + } diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java new file mode 100644 index 00000000..a98c330b --- /dev/null +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/JoglTesselator.java @@ -0,0 +1,169 @@ +/*- + * Copyright 2020 Beuth Hochschule für Technik Berlin, Hochschule für Technik Stuttgart + * + * This file is part of CityDoctor2. + * + * CityDoctor2 is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * CityDoctor2 is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with CityDoctor2. If not, see . + */ +package de.hft.stuttgart.citydoctor2.tesselation; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import com.jogamp.opengl.GL; +import com.jogamp.opengl.glu.GLU; +import com.jogamp.opengl.glu.GLUtessellator; +import com.jogamp.opengl.glu.GLUtessellatorCallbackAdapter; + +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.math.Vector3d; + +/** + * Tesselator to create triangles out of polygons. Uses JOGL implementation. + * + * @author Matthias Betz + * + */ +public class JoglTesselator { + + private static final String DATA_WAS_NOT_A_LIST = "data was not a list"; + private static final GLU glu = new GLU(); + private static final GLUtessellator tess = GLU.gluNewTess(); + private static final GLUtessellatorCallbackAdapter callback; + private static final ArrayList primitives = new ArrayList<>(); + + static { + callback = new GLUtessellatorCallbackAdapter() { + + private Primitive lastPrimitive; + + @Override + public void beginData(int type, Object data) { + if (!(data instanceof ArrayList)) { + throw new IllegalStateException(DATA_WAS_NOT_A_LIST); + } + @SuppressWarnings("unchecked") + ArrayList vertices = (ArrayList) data; + lastPrimitive = new Primitive(type, vertices); + primitives.add(lastPrimitive); + } + + @Override + public void vertexData(Object vertex, Object data) { + if (!(data instanceof ArrayList)) { + throw new IllegalStateException(DATA_WAS_NOT_A_LIST); + } + if (!(vertex instanceof Integer)) { + throw new IllegalStateException("vertex has to be an Integer"); + } + lastPrimitive.addIndex((Integer) vertex); + } + + @Override + public void combineData(double[] coords, Object[] vertexIndices, float[] weights, Object[] outgoingData, + Object data) { + if (!(data instanceof ArrayList)) { + throw new IllegalStateException(DATA_WAS_NOT_A_LIST); + } + @SuppressWarnings("unchecked") + ArrayList vertices = (ArrayList) data; + // to ensure the coordinates are valid + double[] myCoords = Arrays.copyOf(coords, 3); + Vector3d newVertex = new Vector3d(myCoords); + outgoingData[0] = vertices.size(); + vertices.add(newVertex); + } + + @Override + public void errorData(int errnum, Object data) { + throw new TesselationException("GLU Error " + glu.gluErrorString(errnum)); + } + + }; + GLU.gluTessCallback(tess, GLU.GLU_TESS_BEGIN_DATA, callback); + GLU.gluTessCallback(tess, GLU.GLU_TESS_VERTEX_DATA, callback); + GLU.gluTessCallback(tess, GLU.GLU_TESS_ERROR_DATA, callback); + GLU.gluTessCallback(tess, GLU.GLU_TESS_COMBINE_DATA, callback); + GLU.gluTessCallback(tess, GLU.GLU_TESS_END_DATA, callback); + } + + public static TesselatedPolygon tesselatePolygon(Polygon p) { + return tesselatePolygon(p, true); + } + + public static TesselatedPolygon tesselatePolygon(Polygon p, boolean discardDegeneratedTriangles) { + ArrayList vertices = new ArrayList<>(); + Vector3d normal = p.calculateNormalNormalized(); + synchronized (tess) { + GLU.gluTessProperty(tess, GLU.GLU_TESS_BOUNDARY_ONLY, GL.GL_FALSE); + GLU.gluTessBeginPolygon(tess, vertices); + GLU.gluTessNormal(tess, normal.getX(), normal.getY(), normal.getZ()); + + // exterior ring + LinearRing ext = p.getExteriorRing(); + createContour(vertices, ext); + + // interior rings + for (LinearRing lr : p.getInnerRings()) { + createContour(vertices, lr); + } + + GLU.gluTessEndPolygon(tess); + ArrayList indices = new ArrayList<>(); + for (Primitive primitive : primitives) { + primitive.fillListWithIndices(indices, discardDegeneratedTriangles); + } + primitives.clear(); + return new TesselatedPolygon(vertices, indices, p); + } + } + + private static void createContour(List vertices, LinearRing lr) { + GLU.gluTessBeginContour(tess); + for (Vertex v : lr.getVertices()) { + vertices.add(v); + GLU.gluTessVertex(tess, v.getCoordinates(), 0, vertices.size() - 1); + } + GLU.gluTessEndContour(tess); + } + + public static TesselatedRing tesselateRing(LinearRing r, boolean discardDegeneratedTriangles) { + ArrayList vertices = new ArrayList<>(); + Vector3d normal = r.calculateNormal(); + synchronized (tess) { + GLU.gluTessBeginPolygon(tess, vertices); + GLU.gluTessNormal(tess, normal.getX(), normal.getY(), normal.getZ()); + createContour(vertices, r); + GLU.gluTessEndPolygon(tess); + ArrayList indices = new ArrayList<>(); + for (Primitive primitive : primitives) { + primitive.fillListWithIndices(indices, discardDegeneratedTriangles); + } + primitives.clear(); + return new TesselatedRing(vertices, indices, r); + } + } + + public static TesselatedRing tesselateRing(LinearRing r) { + return tesselateRing(r, true); + } + + private JoglTesselator() { + // only static useage + } + +} diff --git a/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java new file mode 100644 index 00000000..4e65d890 --- /dev/null +++ b/CityDoctorParent/CityDoctorModel/src/main/java/de/hft/stuttgart/citydoctor2/tesselation/Primitive.java @@ -0,0 +1,118 @@ +/*- + * Copyright 2020 Beuth Hochschule für Technik Berlin, Hochschule für Technik Stuttgart + * + * This file is part of CityDoctor2. + * + * CityDoctor2 is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * CityDoctor2 is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with CityDoctor2. If not, see . + */ +package de.hft.stuttgart.citydoctor2.tesselation; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.jogamp.opengl.GL; + +import de.hft.stuttgart.citydoctor2.math.Vector3d; + +/** + * Result of the tesselation process. Primitives can be converted to triangles. + * + * @author Matthias Betz + * + */ +public class Primitive { + + private static final Logger logger = LogManager.getLogger(Primitive.class); + + private static final double AREA_EPSILON = 0.00001; + + private final int type; + private final List pIndices = new ArrayList<>(); + private final List vertices; + + public Primitive(int type, List vertices) { + this.vertices = vertices; + this.type = type; + } + + public void fillListWithIndices(List indices, boolean discardDegeneratedTriangles) { + switch (type) { + case GL.GL_TRIANGLES: + for (int i = 0; i < pIndices.size(); i += 3) { + addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 1), pIndices.get(i + 2), indices, + discardDegeneratedTriangles); + } + break; + case GL.GL_TRIANGLE_STRIP: + for (int i = 0; i < pIndices.size() - 2; i++) { + if (i % 2 == 0) { + addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 1), pIndices.get(i + 2), indices, + discardDegeneratedTriangles); + } else { + addToListIfAreaNonNull(pIndices.get(i), pIndices.get(i + 2), pIndices.get(i + 1), indices, + discardDegeneratedTriangles); + } + } + break; + case GL.GL_TRIANGLE_FAN: + Integer first = pIndices.get(0); + for (int i = 0; i < pIndices.size() - 2; i++) { + addToListIfAreaNonNull(first, pIndices.get(i + 1), pIndices.get(i + 2), indices, + discardDegeneratedTriangles); + } + break; + default: + throw new IllegalStateException("unknown triangle type found: " + type); + } + } + + public void addIndex(Integer i) { + pIndices.add(i); + } + + private void addToListIfAreaNonNull(Integer i1, Integer i2, Integer i3, List indices, + boolean discardDegeneratedTriangles) { + Vector3d v1 = vertices.get(i1); + Vector3d v2 = vertices.get(i2); + Vector3d v3 = vertices.get(i3); + if (discardDegeneratedTriangles && Math.abs(calculateAreaOfTriangle(v1, v2, v3)) <= AREA_EPSILON) { + // switch to discard degenerated triangles is enabled and triangle has nearly no area + logger.trace("Tesselator created degenerated triangle, ignoring"); + } else { + indices.add(i1); + indices.add(i2); + indices.add(i3); + } + } + + /** + * Calculates the area of a triangle given by the 3 points + * + * @param v1 the first point of the triangle + * @param v2 the second point of the triangle + * @param v3 the last point of the triangle + * @return the area of the triangle + */ + private static double calculateAreaOfTriangle(Vector3d v1, Vector3d v2, Vector3d v3) { + double a = v1.getDistance(v2); + double b = v2.getDistance(v3); + double c = v1.getDistance(v3); + double s = 0.5d * (a + b + c); + return Math.sqrt(s * (s - a) * (s - b) * (s - c)); + } + +} -- GitLab From 63b7b8f7e63aea38f6ef0a9ac9497b0aaf598729 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Thu, 27 Feb 2025 14:03:14 +0100 Subject: [PATCH 03/40] progress for opengl --- .../Extensions/CityDoctorGUI/pom.xml | 7 + .../stuttgart/citydoctor2/gui/MainWindow.java | 103 ++++++++++-- .../citydoctor2/gui/opengl/OpenGLCamera.java | 150 ++++++++++++++++++ .../gui/opengl/UniformLocation.java | 34 ++++ CityDoctorParent/pom.xml | 8 +- 5 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml index 150b533e..ff6e84f2 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml +++ b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml @@ -59,6 +59,13 @@ org.openjfx javafx-swing + + + com.huskerdev + openglfx-jogl + 4.1.19 + + 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 d6f77f6d..2ae81c88 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 @@ -1,5 +1,28 @@ package de.hft.stuttgart.citydoctor2.gui; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Timer; +import java.util.TimerTask; + +import javax.imageio.ImageIO; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.huskerdev.openglfx.GLExecutor; +import com.huskerdev.openglfx.canvas.GLCanvas; +import com.huskerdev.openglfx.jogl.JOGLExecutor; +import com.huskerdev.openglfx.jogl.events.JOGLEvent; +import com.huskerdev.openglfx.jogl.events.JOGLReshapeEvent; +import com.jogamp.opengl.GL2; +import com.jogamp.opengl.GL3; + import de.hft.stuttgart.citydoctor2.CityDoctorValidation; import de.hft.stuttgart.citydoctor2.check.ValidationConfiguration; import de.hft.stuttgart.citydoctor2.datastructure.BoundingBox; @@ -19,9 +42,35 @@ import javafx.beans.value.ChangeListener; import javafx.embed.swing.SwingFXUtils; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; -import javafx.scene.*; -import javafx.scene.control.*; +import javafx.scene.AmbientLight; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.PerspectiveCamera; +import javafx.scene.Scene; +import javafx.scene.SceneAntialiasing; +import javafx.scene.SubScene; +import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.MenuItem; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.RadioButton; +import javafx.scene.control.SplitPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.ToolBar; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.image.WritableImage; @@ -29,6 +78,8 @@ import javafx.scene.input.Dragboard; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.input.TransferMode; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; @@ -36,15 +87,6 @@ import javafx.scene.layout.Priority; import javafx.scene.paint.Color; import javafx.scene.transform.Rotate; import javafx.stage.Stage; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.*; public class MainWindow extends Application { @@ -82,7 +124,7 @@ public class MainWindow extends Application { private TreeView attributeView; @FXML - TreeView otherObjectsView; + private TreeView otherObjectsView; @FXML private TreeView polygonView; @@ -809,13 +851,48 @@ public class MainWindow extends Application { } private void setup3dView() { + JOGLExecutor exec = new JOGLExecutor(); + GLCanvas canvas = new GLCanvas(exec); + canvas.setMinHeight(300); + canvas.setMaxHeight(300); + canvas.setMinWidth(500); + canvas.setBackground(new Background(new BackgroundFill(Color.BLACK, null, null))); + canvas.setOnMouseClicked(me -> { + System.out.println("Height: " + canvas.getHeight()); + }); +// HBox box = new HBox(); +// box.getChildren().add(canvas); + + canvas.addOnInitEvent(event -> { + GL3 gl = ((JOGLEvent) event).getGl(); + + }); + canvas.addOnReshapeEvent(event -> { + GL3 gl = ((JOGLReshapeEvent) event).getGl(); + + }); + canvas.addOnRenderEvent(event -> { + GL3 gl = ((JOGLEvent) event).getGl(); + + }); + canvas.addOnDisposeEvent(event -> { + GL3 gl = ((JOGLEvent) event).getGl(); + }); + + 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); - +// meshView.getChildren().add(canvas); + + // to avoid strange behaviour of the GUI resizeing itself + meshView.setBackground(new Background(new BackgroundFill(null, null, null))); + + geomScene.addEventFilter(MouseEvent.MOUSE_PRESSED, me -> { clickStart[0] = me.getScreenX(); clickStart[1] = me.getScreenY(); diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java new file mode 100644 index 00000000..cb6cdd2e --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java @@ -0,0 +1,150 @@ +package de.hft.stuttgart.citydoctor2.gui.opengl; + +import com.jogamp.opengl.math.FloatUtil; +import com.jogamp.opengl.math.Matrix4f; +import com.jogamp.opengl.math.Vec2f; +import com.jogamp.opengl.math.Vec3f; + +public class OpenGLCamera { + + private static final float MIN_DISTANCE = 1f; + private static final float PI = (float) Math.PI; + private static final float SCROLL_PERCENTAGE = 0.1f; + + private Matrix4f projMatrix; + private Matrix4f viewMatrix; + private Matrix4f projViewMatrix; + private Matrix4f temp = new Matrix4f(); + + private UniformLocation projViewModel; + private float rotateAroundZ = 0f; + + // rotation temp variables + private Vec3f res = new Vec3f(); + private Vec3f rotAxis = new Vec3f(); + private Vec3f cross = new Vec3f(); + + // move temp variables + private Vec2f dir = new Vec2f(); + + private Vec3f eyePos = new Vec3f(); + private Vec3f centerPos = new Vec3f(); + private Vec3f up = new Vec3f(); + private float distance = 5; + + private float[] matrixBuffer = new float[16]; + + public OpenGLCamera(UniformLocation projViewModel) { + this.projViewModel = projViewModel; + projViewMatrix = new Matrix4f(); + viewMatrix = new Matrix4f(); + projMatrix = new Matrix4f(); + up.setZ(1); + eyePos.setX(distance); + } + + public void reshape(int width, int height, float distance) { + // 90 degree fow + float fow = PI / 2f; + float aspectRatio = (float) width / height; + projMatrix.setToPerspective(fow, aspectRatio, MIN_DISTANCE, distance); + updateMatrix(); + } + + private void updateMatrix() { + viewMatrix.setToLookAt(eyePos, centerPos, up, temp); + projViewMatrix.mul(projMatrix, viewMatrix); + // model matrix is identity + projViewMatrix.get(matrixBuffer); + projViewModel.uploadMat4(matrixBuffer); + } + + public void rotate(double dragDiffX, double dragDiffY) { + float rotationX = (float) (-dragDiffX / 500f); + float rotationZ = (float) (-dragDiffY / 500f); + float tempRotationValue = rotateAroundZ + rotationZ; + if (tempRotationValue < -Math.PI / 2 + 0.0001 || tempRotationValue > Math.PI / 2 - 0.0001) { + // to close to 90 degree, stop rotation + rotationZ = 0; + } + rotateAroundZ += rotationZ; + + res.minus(eyePos, centerPos); + rotateZ(res, rotationX); + + rotAxis.setX(res.x()); + rotAxis.setY(res.y()); + rotAxis.setZ(res.z() + 5); + + cross.cross(rotAxis, res); + cross.normalize(); + rotateAroundAxis(res, rotationZ, cross.x(), cross.y(), cross.z()); + + eyePos.plus(centerPos, res); + updateMatrix(); + } + + public Vec3f rotateZ(Vec3f vec, float angle) { + float sin = FloatUtil.sin(angle); + float cos = FloatUtil.cos(angle); + float x = vec.x() * cos - vec.y() * sin; + float y = vec.x() * sin + vec.y() * cos; + vec.setX(x); + vec.setY(y); + return vec; + } + + private void rotateAroundAxis(Vec3f vector, float angle, float aX, float aY, float aZ) { + float hangle = angle * 0.5f; + float sinAngle = FloatUtil.sin(hangle); + float qx = aX * sinAngle, qy = aY * sinAngle, qz = aZ * sinAngle; + float qw = FloatUtil.cos(hangle); + float w2 = qw * qw, x2 = qx * qx, y2 = qy * qy, z2 = qz * qz, zw = qz * qw; + float xy = qx * qy, xz = qx * qz, yw = qy * qw, yz = qy * qz, xw = qx * qw; + float x = vector.x(); + float y = vector.y(); + float z = vector.z(); + vector.setX((w2 + x2 - z2 - y2) * x + (-zw + xy - zw + xy) * y + (yw + xz + xz + yw) * z); + vector.setY((xy + zw + zw + xy) * x + (y2 - z2 + w2 - x2) * y + (yz + yz - xw - xw) * z); + vector.setZ((xz - yw + xz - yw) * x + (yz + yz + xw + xw) * y + (z2 - y2 - x2 + w2) * z); + } + + public void setDistance(float distance) { + this.distance = distance; + res.minus(centerPos, eyePos); + res.normalize(); + res.scale(distance); + eyePos.plus(res, centerPos); + updateMatrix(); + } + + public void scroll(float scroll) { + float addedDistance = distance * scroll * SCROLL_PERCENTAGE; + distance += addedDistance; + setDistance(distance); + } + + public void move(float dragDiffX, float dragDiffY) { + res.minus(centerPos, eyePos); + dir.setX(res.x()); + dir.setY(res.y()); + + dir.normalize(); +// var yDrag = vec2.create(); +// vec2.scale(yDrag, dir, dragDiffY * 0.5); +// +// // handle diffY +// vec3.add(this.centerPos, this.centerPos, vec3.fromValues(yDrag[0], yDrag[1], 0)); +// vec3.add(this.eyepos, this.eyepos, vec3.fromValues(yDrag[0], yDrag[1], 0)); +// // handle diffX +// var temp = dir[0]; +// dir[0] = dir[1]; +// dir[1] = -temp; +// vec2.scale(dir, dir, -dragDiffX * 0.5); +// +// vec3.add(this.centerPos, this.centerPos, vec3.fromValues(dir[0], dir[1], 0)); +// vec3.add(this.eyepos, this.eyepos, vec3.fromValues(dir[0], dir[1], 0)); + this.updateMatrix(); + } + +} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java new file mode 100644 index 00000000..c625ea9e --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java @@ -0,0 +1,34 @@ +/*- + * Copyright 2021 Hochschule für Technik Stuttgart + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.hft.stuttgart.citydoctor2.gui.opengl; + +import com.jogamp.opengl.GL3; + +public class UniformLocation { + + private int location; + private GL3 gl; + + public UniformLocation(GL3 gl, int location) { + this.location = location; + + } + + public void uploadMat4(float[] mat) { + gl.glUniformMatrix4fv(location, mat.length, false, mat, 0); + } + +} diff --git a/CityDoctorParent/pom.xml b/CityDoctorParent/pom.xml index e927a41a..add9a9e6 100644 --- a/CityDoctorParent/pom.xml +++ b/CityDoctorParent/pom.xml @@ -20,6 +20,10 @@ 17.0.12 + + jogl + https://jogamp.org/deployment/maven/ + non-maven-libs ${nonMavenLibsPath} @@ -173,7 +177,7 @@ org.jogamp.gluegen gluegen-rt-main - 2.3.2 + 2.5.0 gov.nist.math @@ -183,7 +187,7 @@ org.jogamp.jogl jogl-all-main - 2.3.2 + 2.5.0 org.apache.logging.log4j -- GitLab From 5c2847efaf4b141bce9f1e39966f2c99fd40d7a7 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Wed, 14 May 2025 09:04:38 +0200 Subject: [PATCH 04/40] add source to built jar --- CityDoctorParent/pom.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CityDoctorParent/pom.xml b/CityDoctorParent/pom.xml index add9a9e6..e4f4d6fa 100644 --- a/CityDoctorParent/pom.xml +++ b/CityDoctorParent/pom.xml @@ -51,6 +51,20 @@ + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + attach-sources + + jar-no-fork + + + + maven-compiler-plugin 3.13.0 -- GitLab From adcc12e8fe9e0a4543a289073ab2be54e500b1d6 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 07:48:08 +0200 Subject: [PATCH 05/40] Add implementation plan for OpenGL 3D view Phased TDD plan: pure math/data core (Mat4, OrbitCamera, ShadeColor, PickingCodec, MeshAccumulator, IndexSetBuilder, SceneBuilder), GL infrastructure (shaders, GlScene, PickingFramebuffer, HighlightOverlay, GLViewport), GUI wiring, manual verification, and packaging. Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-06-18-opengl-3d-view.md | 2585 +++++++++++++++++ 1 file changed, 2585 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-opengl-3d-view.md 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 00000000..148fedbe --- /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). -- GitLab From 13e3e158c2953da03d1f10463ff8705af9e7909b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 07:50:12 +0200 Subject: [PATCH 06/40] Remove abandoned gui.opengl stub before OpenGL view rewrite OpenGLCamera/UniformLocation were a partial earlier attempt; superseded by the new gui.gl package per docs/superpowers/plans/2026-06-18-opengl-3d-view.md. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/opengl/OpenGLCamera.java | 150 ------------------ .../gui/opengl/UniformLocation.java | 34 ---- 2 files changed, 184 deletions(-) delete mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java delete mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java deleted file mode 100644 index cb6cdd2e..00000000 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/OpenGLCamera.java +++ /dev/null @@ -1,150 +0,0 @@ -package de.hft.stuttgart.citydoctor2.gui.opengl; - -import com.jogamp.opengl.math.FloatUtil; -import com.jogamp.opengl.math.Matrix4f; -import com.jogamp.opengl.math.Vec2f; -import com.jogamp.opengl.math.Vec3f; - -public class OpenGLCamera { - - private static final float MIN_DISTANCE = 1f; - private static final float PI = (float) Math.PI; - private static final float SCROLL_PERCENTAGE = 0.1f; - - private Matrix4f projMatrix; - private Matrix4f viewMatrix; - private Matrix4f projViewMatrix; - private Matrix4f temp = new Matrix4f(); - - private UniformLocation projViewModel; - private float rotateAroundZ = 0f; - - // rotation temp variables - private Vec3f res = new Vec3f(); - private Vec3f rotAxis = new Vec3f(); - private Vec3f cross = new Vec3f(); - - // move temp variables - private Vec2f dir = new Vec2f(); - - private Vec3f eyePos = new Vec3f(); - private Vec3f centerPos = new Vec3f(); - private Vec3f up = new Vec3f(); - private float distance = 5; - - private float[] matrixBuffer = new float[16]; - - public OpenGLCamera(UniformLocation projViewModel) { - this.projViewModel = projViewModel; - projViewMatrix = new Matrix4f(); - viewMatrix = new Matrix4f(); - projMatrix = new Matrix4f(); - up.setZ(1); - eyePos.setX(distance); - } - - public void reshape(int width, int height, float distance) { - // 90 degree fow - float fow = PI / 2f; - float aspectRatio = (float) width / height; - projMatrix.setToPerspective(fow, aspectRatio, MIN_DISTANCE, distance); - updateMatrix(); - } - - private void updateMatrix() { - viewMatrix.setToLookAt(eyePos, centerPos, up, temp); - projViewMatrix.mul(projMatrix, viewMatrix); - // model matrix is identity - projViewMatrix.get(matrixBuffer); - projViewModel.uploadMat4(matrixBuffer); - } - - public void rotate(double dragDiffX, double dragDiffY) { - float rotationX = (float) (-dragDiffX / 500f); - float rotationZ = (float) (-dragDiffY / 500f); - float tempRotationValue = rotateAroundZ + rotationZ; - if (tempRotationValue < -Math.PI / 2 + 0.0001 || tempRotationValue > Math.PI / 2 - 0.0001) { - // to close to 90 degree, stop rotation - rotationZ = 0; - } - rotateAroundZ += rotationZ; - - res.minus(eyePos, centerPos); - rotateZ(res, rotationX); - - rotAxis.setX(res.x()); - rotAxis.setY(res.y()); - rotAxis.setZ(res.z() + 5); - - cross.cross(rotAxis, res); - cross.normalize(); - rotateAroundAxis(res, rotationZ, cross.x(), cross.y(), cross.z()); - - eyePos.plus(centerPos, res); - updateMatrix(); - } - - public Vec3f rotateZ(Vec3f vec, float angle) { - float sin = FloatUtil.sin(angle); - float cos = FloatUtil.cos(angle); - float x = vec.x() * cos - vec.y() * sin; - float y = vec.x() * sin + vec.y() * cos; - vec.setX(x); - vec.setY(y); - return vec; - } - - private void rotateAroundAxis(Vec3f vector, float angle, float aX, float aY, float aZ) { - float hangle = angle * 0.5f; - float sinAngle = FloatUtil.sin(hangle); - float qx = aX * sinAngle, qy = aY * sinAngle, qz = aZ * sinAngle; - float qw = FloatUtil.cos(hangle); - float w2 = qw * qw, x2 = qx * qx, y2 = qy * qy, z2 = qz * qz, zw = qz * qw; - float xy = qx * qy, xz = qx * qz, yw = qy * qw, yz = qy * qz, xw = qx * qw; - float x = vector.x(); - float y = vector.y(); - float z = vector.z(); - vector.setX((w2 + x2 - z2 - y2) * x + (-zw + xy - zw + xy) * y + (yw + xz + xz + yw) * z); - vector.setY((xy + zw + zw + xy) * x + (y2 - z2 + w2 - x2) * y + (yz + yz - xw - xw) * z); - vector.setZ((xz - yw + xz - yw) * x + (yz + yz + xw + xw) * y + (z2 - y2 - x2 + w2) * z); - } - - public void setDistance(float distance) { - this.distance = distance; - res.minus(centerPos, eyePos); - res.normalize(); - res.scale(distance); - eyePos.plus(res, centerPos); - updateMatrix(); - } - - public void scroll(float scroll) { - float addedDistance = distance * scroll * SCROLL_PERCENTAGE; - distance += addedDistance; - setDistance(distance); - } - - public void move(float dragDiffX, float dragDiffY) { - res.minus(centerPos, eyePos); - dir.setX(res.x()); - dir.setY(res.y()); - - dir.normalize(); -// var yDrag = vec2.create(); -// vec2.scale(yDrag, dir, dragDiffY * 0.5); -// -// // handle diffY -// vec3.add(this.centerPos, this.centerPos, vec3.fromValues(yDrag[0], yDrag[1], 0)); -// vec3.add(this.eyepos, this.eyepos, vec3.fromValues(yDrag[0], yDrag[1], 0)); -// // handle diffX -// var temp = dir[0]; -// dir[0] = dir[1]; -// dir[1] = -temp; -// vec2.scale(dir, dir, -dragDiffX * 0.5); -// -// vec3.add(this.centerPos, this.centerPos, vec3.fromValues(dir[0], dir[1], 0)); -// vec3.add(this.eyepos, this.eyepos, vec3.fromValues(dir[0], dir[1], 0)); - this.updateMatrix(); - } - -} diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java deleted file mode 100644 index c625ea9e..00000000 --- a/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/opengl/UniformLocation.java +++ /dev/null @@ -1,34 +0,0 @@ -/*- - * Copyright 2021 Hochschule für Technik Stuttgart - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package de.hft.stuttgart.citydoctor2.gui.opengl; - -import com.jogamp.opengl.GL3; - -public class UniformLocation { - - private int location; - private GL3 gl; - - public UniformLocation(GL3 gl, int location) { - this.location = location; - - } - - public void uploadMat4(float[] mat) { - gl.glUniformMatrix4fv(location, mat.length, false, mat, 0); - } - -} -- GitLab From caa2d703cc35a8d4c44364f2d1d08d476c5b1a39 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 07:55:29 +0200 Subject: [PATCH 07/40] Add primitive growable FloatList/IntList for GL buffer building --- .../citydoctor2/gui/gl/FloatList.java | 38 +++++++++++++++++++ .../stuttgart/citydoctor2/gui/gl/IntList.java | 25 ++++++++++++ .../citydoctor2/gui/gl/FloatListTest.java | 26 +++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IntList.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java 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 00000000..8a2200fd --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java @@ -0,0 +1,38 @@ +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); } +} 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 00000000..bc1d2f40 --- /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/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 00000000..c7f11f37 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatListTest.java @@ -0,0 +1,26 @@ +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); + } +} -- GitLab From e03de6453c29e6cc8415f96d580f9eb762672bac Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 07:57:24 +0200 Subject: [PATCH 08/40] Add PickingCodec for color-picking id encoding --- .../citydoctor2/gui/gl/PickingCodec.java | 15 ++++++++++++ .../citydoctor2/gui/gl/PickingCodecTest.java | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodec.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingCodecTest.java 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 00000000..30bb6adf --- /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/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 00000000..c61d95e7 --- /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)); + } +} -- GitLab From 3087a8d7f42a6d080b78256b3a53086b01211c3a Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 07:58:39 +0200 Subject: [PATCH 09/40] Add PickRegistry mapping picking ids to model objects --- .../citydoctor2/gui/gl/PickRegistry.java | 26 +++++++++++++++++ .../citydoctor2/gui/gl/PickRegistryTest.java | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistry.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/PickRegistryTest.java 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 00000000..3e40f01c --- /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/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 00000000..51135faf --- /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)); + } +} -- GitLab From 137676820816841eb5145ca5a1d27fef0050af7e Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:00:22 +0200 Subject: [PATCH 10/40] Extract fake-shading math into pure ShadeColor --- .../citydoctor2/gui/gl/ShadeColor.java | 25 +++++++++++++ .../citydoctor2/gui/gl/ShadeColorTest.java | 37 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColor.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/ShadeColorTest.java 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 00000000..5bc415ff --- /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/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 00000000..b5370850 --- /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); + } +} -- GitLab From 1050806675e632631804be3cccd80ab20b9e1e6b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:02:01 +0200 Subject: [PATCH 11/40] Add column-major Mat4 matrix helpers --- .../stuttgart/citydoctor2/gui/gl/Mat4.java | 79 +++++++++++++++++++ .../citydoctor2/gui/gl/Mat4Test.java | 45 +++++++++++ 2 files changed, 124 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/Mat4Test.java 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 00000000..cd3f976f --- /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/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 00000000..11b77cd4 --- /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); + } +} -- GitLab From 24c5c3551181752b1d95f7e9779089bacdbd63cd Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:03:49 +0200 Subject: [PATCH 12/40] Add OrbitCamera reproducing legacy view interaction --- .../citydoctor2/gui/gl/OrbitCamera.java | 71 +++++++++++++++++++ .../citydoctor2/gui/gl/OrbitCameraTest.java | 51 +++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCameraTest.java 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 00000000..2721c9d2 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java @@ -0,0 +1,71 @@ +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; + private static final double INITIAL_ELEVATION = 20.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; + } + + 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); + 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/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 00000000..5b664bcd --- /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); + } +} -- GitLab From 8bcfc6b2c5cc1e976232567c10e263fd5c21080c Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:06:31 +0200 Subject: [PATCH 13/40] Add MeshAccumulator building interleaved vertex arrays --- .../citydoctor2/gui/gl/MeshAccumulator.java | 45 +++++++++++++++++++ .../gui/gl/MeshAccumulatorTest.java | 36 +++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulator.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/MeshAccumulatorTest.java 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 00000000..f56bea76 --- /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/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 00000000..31582d58 --- /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()); + } +} -- GitLab From 8ef604ae6ef4dad58e032f7c24ca200dc58eb32b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:09:42 +0200 Subject: [PATCH 14/40] Add TriangleMeta and IndexSetBuilder for filter-driven draw sets --- .../citydoctor2/gui/gl/IndexSetBuilder.java | 23 +++++++++++++++ .../citydoctor2/gui/gl/TriangleMeta.java | 12 ++++++++ .../gui/gl/IndexSetBuilderTest.java | 28 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilder.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/IndexSetBuilderTest.java 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 00000000..325068e8 --- /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/TriangleMeta.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/TriangleMeta.java new file mode 100644 index 00000000..b0e37256 --- /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/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 00000000..3b073b23 --- /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); + } +} -- GitLab From de0fb85d82a52dc42ede135950bbbd670698c3c3 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:13:57 +0200 Subject: [PATCH 15/40] Add SceneData container for built scene buffers --- .../citydoctor2/gui/gl/SceneData.java | 32 +++++++++++++++++++ .../citydoctor2/gui/gl/SceneDataTest.java | 24 ++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneData.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneDataTest.java 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 00000000..5550c84b --- /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/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 00000000..48632ccd --- /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()); + } +} -- GitLab From 5c34fcf8143692cdd28cca308156d83a30aa8b74 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:18:48 +0200 Subject: [PATCH 16/40] Add SceneBuilder turning polygons into SceneData Implements SceneBuilder with fromPolygons() and build() entry points, plus private helpers for center computation and base color selection. Test loads real GML fixture (building with BoundarySurfaces) to verify buffer length consistency and that all picking ids resolve to Polygons. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/gl/SceneBuilder.java | 77 +++++++++++++++++++ .../citydoctor2/gui/gl/SceneBuilderTest.java | 64 +++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/test/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilderTest.java 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 00000000..ee82bc32 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/SceneBuilder.java @@ -0,0 +1,77 @@ +package de.hft.stuttgart.citydoctor2.gui.gl; + +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) { + 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/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 00000000..e1ddfe45 --- /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); + } + } +} -- GitLab From cbc859ff9017c9aac72951b28b4c4721768314ca Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:23:12 +0200 Subject: [PATCH 17/40] Add explicit JOGL runtime dependencies to GUI module openglfx-jogl does not declare JOGL; the GUI now uses com.jogamp.opengl directly, so depend on jogl-all-main + gluegen-rt-main explicitly (versions managed by parent; -main artifacts bundle platform natives). Co-Authored-By: Claude Opus 4.8 --- CityDoctorParent/Extensions/CityDoctorGUI/pom.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml index d06d0edf..e9d152c9 100644 --- a/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml +++ b/CityDoctorParent/Extensions/CityDoctorGUI/pom.xml @@ -65,6 +65,17 @@ openglfx-jogl 4.1.19 + + + org.jogamp.jogl + jogl-all-main + + + org.jogamp.gluegen + gluegen-rt-main + -- GitLab From 65b6eb392c8c23282463af5e431b66fea06b16b7 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:25:04 +0200 Subject: [PATCH 18/40] Add scene/pick shaders and ShaderProgram compiler --- .../citydoctor2/gui/gl/ShaderProgram.java | 83 +++++++++++++++++++ .../stuttgart/citydoctor2/gui/gl/pick.frag | 10 +++ .../stuttgart/citydoctor2/gui/gl/pick.vert | 9 ++ .../stuttgart/citydoctor2/gui/gl/scene.frag | 11 +++ .../stuttgart/citydoctor2/gui/gl/scene.vert | 13 +++ 5 files changed, 126 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.frag create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/pick.vert create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert 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 00000000..d9ba7791 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/ShaderProgram.java @@ -0,0 +1,83 @@ +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); + } + } +} 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 00000000..4b63a8ab --- /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 00000000..a07d1bfa --- /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/scene.frag b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag new file mode 100644 index 00000000..4051422d --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.frag @@ -0,0 +1,11 @@ +#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); +} 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 00000000..5f6fd390 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/scene.vert @@ -0,0 +1,13 @@ +#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; +} -- GitLab From 20b56607dc1ceb602af8bae6d83f12067b54d52b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:26:30 +0200 Subject: [PATCH 19/40] Add GlScene global VBO upload and indexed draw --- .../stuttgart/citydoctor2/gui/gl/GlScene.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java 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 00000000..10884175 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GlScene.java @@ -0,0 +1,103 @@ +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; + } +} -- GitLab From 861dafdf6725607e90d8eead558f60ae204f1363 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:27:41 +0200 Subject: [PATCH 20/40] Add PickingFramebuffer for color-pick readback --- .../gui/gl/PickingFramebuffer.java | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/PickingFramebuffer.java 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 00000000..d173c5b7 --- /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; + } + } +} -- GitLab From e7bff9cc62ac82ab55cefb87888525291dac3056 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:28:54 +0200 Subject: [PATCH 21/40] Add HighlightOverlay dynamic marker buffers --- .../citydoctor2/gui/gl/HighlightOverlay.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java 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 00000000..4c7911c6 --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/HighlightOverlay.java @@ -0,0 +1,111 @@ +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; + } +} -- GitLab From 26d3eda9d3cc7b399082bfda565ee12514176144 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:31:16 +0200 Subject: [PATCH 22/40] Add GLViewport integrating canvas, camera, picking and overlay --- .../citydoctor2/gui/gl/GLViewport.java | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java 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 00000000..d6ea3b4a --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java @@ -0,0 +1,195 @@ +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.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 volatile int selectedId = 0; + private volatile boolean wireframe = false; + private volatile boolean cull = false; + private volatile float pointScale = 5f; + + // 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: screen coords + callback (invoked on FX thread with resolved Object or null) + private volatile int[] pickRequest; + 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); + 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) { + this.hlPoints = points; + this.hlPointColors = pointColors; + this.hlLines = lines; + this.hlLineColors = lineColors; + this.highlightsDirty = true; + canvas.repaint(); + } + + 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(); + + 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); + + 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); + selectedId = (target != null) ? id : 0; + 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); + } + canvas.repaint(); + }); + canvas.setOnScroll(e -> { + camera.zoom(e.getDeltaY() < 0 ? 1.05 : 0.95); + canvas.repaint(); + }); + } + + public void resetCamera() { camera.reset(); canvas.repaint(); } +} -- GitLab From 2877e8f0cf5deff41f98d9282a940c4c6266c423 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:49:12 +0200 Subject: [PATCH 23/40] Add SceneBuilder.fromModel, FloatList.clear and OrbitCamera angle getters Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/TriangulatedGeometry.java | 251 ------------------ .../citydoctor2/gui/gl/FloatList.java | 3 + .../citydoctor2/gui/gl/OrbitCamera.java | 6 + .../citydoctor2/gui/gl/SceneBuilder.java | 100 +++++++ .../citydoctor2/gui/gl/FloatListTest.java | 10 + 5 files changed, 119 insertions(+), 251 deletions(-) delete mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/TriangulatedGeometry.java 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 83a556d3..00000000 --- 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/gl/FloatList.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/FloatList.java index 8a2200fd..fa26973f 100644 --- 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 @@ -34,5 +34,8 @@ public class FloatList { 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/OrbitCamera.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java index 2721c9d2..58bbf5ee 100644 --- 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 @@ -59,6 +59,12 @@ public class OrbitCamera { 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); 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 index ee82bc32..999302cc 100644 --- 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 @@ -1,13 +1,19 @@ 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; /** @@ -51,6 +57,100 @@ public final class SceneBuilder { 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) { + List allPolygons = new ArrayList<>(); + 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)) { 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 index c7f11f37..f7271974 100644 --- 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 @@ -23,4 +23,14 @@ public class FloatListTest { 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); + } } -- GitLab From 419243a7fcefd5e395e43900954ad216fdde2ae6 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:49:21 +0200 Subject: [PATCH 24/40] Render highlights through GL overlay instead of JavaFX shapes Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/HighlightController.java | 379 +++++++++--------- .../citydoctor2/gui/ListErrorVisitor.java | 75 ++-- 2 files changed, 221 insertions(+), 233 deletions(-) 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 be979f5b..546d0b71 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,184 @@ 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; + } + + public void clearHighlights() { + points.clear(); + pointColors.clear(); + lines.clear(); + lineColors.clear(); + push(); + } + + private void push() { + viewport.setHighlights(points.toArray(), pointColors.toArray(), lines.toArray(), lineColors.toArray()); + } + + public void changeScaling(double translateZ) { + double scale = Math.abs(translateZ); + scale = Math.min(150, scale); + scale = Math.max(10, scale); + scale = scale * 0.01; + viewport.setPointScale((float) (scale * 8)); + } + + // ---- 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 (no TriangulatedGeometry) ---- + + public void highlight(Polygon p) { + clearHighlights(); + 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) { + clearHighlights(); + if (ring.getType() == LinearRingType.EXTERIOR) { + addHighlight(ring, Color.RED); + } else { + addHighlight(ring, Color.BLUE); + } + push(); + } + + public void highlight(List rings) { + clearHighlights(); + 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) { + clearHighlights(); + 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) { + clearHighlights(); + 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) { + clearHighlights(); + for (Edge e : errorEdges) { + addHighlight(e); + } + push(); + } + + public void highlightPolygons(List> components) { + clearHighlights(); + 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 +192,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 8412762c..0a999f7b 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 -- GitLab From 913a2941a08cf7f819d0c053848746bcbc9a6959 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 08:49:21 +0200 Subject: [PATCH 25/40] Mount GLViewport in MainWindow and rewire Renderer to SceneData Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/CityDoctorController.java | 4 +- .../stuttgart/citydoctor2/gui/MainWindow.java | 165 +++-------- .../stuttgart/citydoctor2/gui/Renderer.java | 257 ++++++------------ .../citydoctor2/gui/ValidationView.java | 2 +- 4 files changed, 126 insertions(+), 302 deletions(-) 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 57849884..f21c5c17 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/MainWindow.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java index 357f70ec..616adc00 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; @@ -302,7 +288,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 +831,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 +866,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 +891,8 @@ public class MainWindow extends Application { } public void resetCamera() { - cameraXRot = CAMERA_INITIAL_X_ANGLE; - cameraYRot = CAMERA_INITIAL_Y_ANGLE; - cameraZRotation.setAngle(cameraXRot); - cameraXRotation.setAngle(cameraYRot); + glViewport.resetCamera(); alignNorthArrow(); - camera.setTranslateX(0); - camera.setTranslateY(0); - if (controller.getOriginBB() != null) { - zoomOutForBoundingBox(controller.getOriginBB()); - } } public void addFileNameToTitle(String fileName) { @@ -1055,8 +960,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 +1048,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 7c2279b3..d584bca9 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/ValidationView.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/ValidationView.java index 2699d1c7..90029428 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(); -- GitLab From 212980dbfd1076fec9deb5a1721b7e69605b551e Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 12:41:10 +0200 Subject: [PATCH 26/40] Fix code-review findings in OpenGL view wiring - resetCamera re-fits the loaded model's bounding box (regression fix) - remove dead allPolygons local in SceneBuilder.fromModel - HighlightController: single GPU upload per highlight (no empty intermediate push) --- .../citydoctor2/gui/HighlightController.java | 22 +++++++++++-------- .../stuttgart/citydoctor2/gui/MainWindow.java | 3 +++ .../citydoctor2/gui/gl/SceneBuilder.java | 1 - 3 files changed, 16 insertions(+), 10 deletions(-) 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 546d0b71..f295643c 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 @@ -44,11 +44,15 @@ public class HighlightController { return c == null ? new double[3] : c; } - public void clearHighlights() { + private void resetBuffers() { points.clear(); pointColors.clear(); lines.clear(); lineColors.clear(); + } + + public void clearHighlights() { + resetBuffers(); push(); } @@ -83,10 +87,10 @@ public class HighlightController { lineColors.add(r, g, b); } - // ---- public highlight API (no TriangulatedGeometry) ---- + // ---- public highlight API ---- public void highlight(Polygon p) { - clearHighlights(); + resetBuffers(); addHighlight(p, Color.RED, Color.BLUE); push(); } @@ -102,7 +106,7 @@ public class HighlightController { } public void highlight(LinearRing ring) { - clearHighlights(); + resetBuffers(); if (ring.getType() == LinearRingType.EXTERIOR) { addHighlight(ring, Color.RED); } else { @@ -112,7 +116,7 @@ public class HighlightController { } public void highlight(List rings) { - clearHighlights(); + resetBuffers(); for (LinearRing lr : rings) { if (lr.getType() == LinearRingType.EXTERIOR) { addHighlight(lr, Color.RED); @@ -138,7 +142,7 @@ public class HighlightController { } public void highlight(Edge e) { - clearHighlights(); + resetBuffers(); addHighlight(e); push(); } @@ -153,7 +157,7 @@ public class HighlightController { } public void highlight(Vertex v) { - clearHighlights(); + resetBuffers(); addPoint(v, Color.RED); push(); } @@ -167,7 +171,7 @@ public class HighlightController { } public void highlightEdges(List errorEdges) { - clearHighlights(); + resetBuffers(); for (Edge e : errorEdges) { addHighlight(e); } @@ -175,7 +179,7 @@ public class HighlightController { } public void highlightPolygons(List> components) { - clearHighlights(); + resetBuffers(); for (int i = 0; i < components.size(); i++) { Color extColor; Color intColor = switch (i % 3) { 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 616adc00..a141abc9 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 @@ -892,6 +892,9 @@ public class MainWindow extends Application { public void resetCamera() { glViewport.resetCamera(); + if (controller.getOriginBB() != null) { + zoomOutForBoundingBox(controller.getOriginBB()); + } alignNorthArrow(); } 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 index 999302cc..43329f29 100644 --- 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 @@ -64,7 +64,6 @@ public final class SceneBuilder { * {@code TriangulatedGeometry.of(model, filters)}. */ public static SceneData fromModel(CityDoctorModel model) { - List allPolygons = new ArrayList<>(); Vector3d center = findCenterOfModel(model); MeshAccumulator acc = new MeshAccumulator(center); PickRegistry registry = new PickRegistry(); -- GitLab From 5298cad9736aaeb33b9fb99cc969783ccc53293b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 12:42:28 +0200 Subject: [PATCH 27/40] Document OpenGL 3D view in changelog Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cbc073..d1d70841 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 -- GitLab From 60c2c05ba92fd93f4f44f982f0b243ef82407302 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 12:49:55 +0200 Subject: [PATCH 28/40] Bump JOGL/GlueGen to 2.5.0 for openglfx compatibility openglfx-jogl 4.1.19 calls GLDrawableFactory.getFactory(AbstractGraphicsDevice), which does not exist in JOGL 2.3.2 (NoSuchMethodError at canvas init). 2.5.0 provides it; JoglTesselator's GLU tessellation API is unchanged across the bump. Co-Authored-By: Claude Opus 4.8 --- CityDoctorParent/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CityDoctorParent/pom.xml b/CityDoctorParent/pom.xml index 24549983..b441030f 100644 --- a/CityDoctorParent/pom.xml +++ b/CityDoctorParent/pom.xml @@ -168,7 +168,7 @@ org.jogamp.gluegen gluegen-rt-main - 2.3.2 + 2.5.0 gov.nist.math @@ -178,7 +178,7 @@ org.jogamp.jogl jogl-all-main - 2.3.2 + 2.5.0 org.apache.logging.log4j -- GitLab From ac4cb04cbc963bede379c804418df15fc2241c69 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 12:56:05 +0200 Subject: [PATCH 29/40] Fix EBO ordering and context-loss handling in GL viewport setIndexSet could run before the scene's buffers were generated (ebo == 0), causing GL_INVALID_OPERATION "buffer not bound". Now the index set is retained and applied only once the scene is uploaded, re-applied after a fresh upload, and GL handles are dropped/re-queued on context (re)init. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/gl/GLViewport.java | 22 +++++++++++++++++-- .../stuttgart/citydoctor2/gui/gl/GlScene.java | 14 ++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) 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 index d6ea3b4a..56713b26 100644 --- 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 @@ -30,6 +30,9 @@ public class GLViewport { // 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 int selectedId = 0; private volatile boolean wireframe = false; private volatile boolean cull = false; @@ -53,7 +56,15 @@ public class GLViewport { public GLViewport() { canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE); - canvas.addOnInitEvent(ev -> initialized = false); + 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 */ }); installInputHandlers(); @@ -110,13 +121,20 @@ public class GLViewport { } 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) { - scene.setIndexSet(gl, newIndices); + 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); 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 index 10884175..115ba520 100644 --- 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 @@ -58,6 +58,10 @@ public class GlScene { /** 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); @@ -78,6 +82,16 @@ public class GlScene { 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); -- GitLab From 1ef9f1328be999e78cc3db6d2dc4054b2d60c28e Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 13:11:06 +0200 Subject: [PATCH 30/40] Refine selection: no polygon tint, bigger markers, accurate picking - Drop the selected-polygon colour tint; selection is shown only by the highlight points and edges (scene shader outputs vertex colour directly). - Larger highlight points (glPointSize) and thicker edges (glLineWidth); markers use a constant on-screen pixel size independent of zoom. - Fix click picking on HiDPI: scale logical click coords to physical framebuffer pixels so the sampled pixel is the one under the cursor. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/HighlightController.java | 7 ++--- .../citydoctor2/gui/gl/GLViewport.java | 28 +++++++++++-------- .../citydoctor2/gui/gl/HighlightOverlay.java | 3 +- .../stuttgart/citydoctor2/gui/gl/scene.frag | 7 +---- .../stuttgart/citydoctor2/gui/gl/scene.vert | 4 --- 5 files changed, 21 insertions(+), 28 deletions(-) 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 f295643c..20c2b32e 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 @@ -61,11 +61,8 @@ public class HighlightController { } public void changeScaling(double translateZ) { - double scale = Math.abs(translateZ); - scale = Math.min(150, scale); - scale = Math.max(10, scale); - scale = scale * 0.01; - viewport.setPointScale((float) (scale * 8)); + // 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 ---- 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 index 56713b26..26b34eb0 100644 --- 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 @@ -33,10 +33,10 @@ public class GLViewport { // 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 int selectedId = 0; private volatile boolean wireframe = false; private volatile boolean cull = false; - private volatile float pointScale = 5f; + private volatile float pointScale = 14f; + private volatile float lineScale = 4f; // highlight marker data, uploaded on the GL thread when dirty private volatile float[] hlPoints = new float[0]; @@ -45,8 +45,8 @@ public class GLViewport { private volatile float[] hlLineColors = new float[0]; private volatile boolean highlightsDirty = false; - // pick request: screen coords + callback (invoked on FX thread with resolved Object or null) - private volatile int[] pickRequest; + // 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; @@ -78,8 +78,6 @@ public class GLViewport { 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(); } @@ -99,7 +97,7 @@ public class GLViewport { 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)}; + pickRequest = new double[]{x, y}; pickCallback = callback; canvas.repaint(); } @@ -159,11 +157,10 @@ public class GLViewport { 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); + overlay.draw(gl, pointScale, lineScale); - int[] req = pickRequest; + double[] req = pickRequest; if (req != null && currentScene != null) { picking.resize(gl, w, h); picking.bind(gl); @@ -173,9 +170,16 @@ public class GLViewport { 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]); + // 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); - selectedId = (target != null) ? id : 0; Consumer cb = pickCallback; pickRequest = null; pickCallback = null; 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 index 4c7911c6..d1d52cf3 100644 --- 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 @@ -74,11 +74,12 @@ public class HighlightOverlay { gl.glVertexAttribPointer(loc, comp, GL.GL_FLOAT, false, 0, 0L); } - public void draw(GL3 gl, float pointSize) { + public void draw(GL3 gl, float pointSize, float lineWidth) { if (!created) { return; } if (lineVertexCount > 0) { + gl.glLineWidth(lineWidth); gl.glBindVertexArray(lineVao); gl.glDrawArrays(GL.GL_LINES, 0, lineVertexCount); } 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 index 4051422d..e24db232 100644 --- 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 @@ -1,11 +1,6 @@ #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); + 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 index 5f6fd390..58a19d13 100644 --- 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 @@ -1,13 +1,9 @@ #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; } -- GitLab From 60d835f34edaaeb13b62d76d51cf8a9818b5f581 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 13:15:17 +0200 Subject: [PATCH 31/40] Enable 4x MSAA anti-aliasing for the GL viewport Request a multisampled framebuffer via the openglfx GLCanvas msaa parameter and enable GL_MULTISAMPLE, smoothing jagged polygon/line/point edges. The picking framebuffer stays single-sample so picked ids remain exact. Co-Authored-By: Claude Opus 4.8 --- .../java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 26b34eb0..be9a5d2a 100644 --- 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 @@ -55,7 +55,8 @@ public class GLViewport { private double lastY; public GLViewport() { - canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE); + // args: executor, profile, flipY=false, msaa=4 -> request a 4x multisampled framebuffer for AA + canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE, false, 4); 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. @@ -115,6 +116,7 @@ public class GLViewport { pickProgram = new ShaderProgram(gl, "pick.vert", "pick.frag"); overlay.init(gl); gl.glEnable(GL.GL_DEPTH_TEST); + gl.glEnable(GL.GL_MULTISAMPLE); // anti-aliasing for the multisampled framebuffer initialized = true; } -- GitLab From d0d05f4713d4d5dc8249ae21f06e86c3224bb84b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 13:25:55 +0200 Subject: [PATCH 32/40] Render highlight vertex markers as sphere impostors Add a geometry-shader program that expands each highlight point into a camera-facing quad shaded as a lit sphere (hemisphere normal per fragment, discard outside the disc). ShaderProgram gains optional geometry-stage support; HighlightOverlay splits draw into drawLines/drawPoints so points use the impostor program while edges keep the flat scene program. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/gl/GLViewport.java | 11 ++++++- .../citydoctor2/gui/gl/HighlightOverlay.java | 29 ++++++++++++------- .../citydoctor2/gui/gl/ShaderProgram.java | 12 ++++++++ .../stuttgart/citydoctor2/gui/gl/point.frag | 18 ++++++++++++ .../stuttgart/citydoctor2/gui/gl/point.geom | 27 +++++++++++++++++ .../stuttgart/citydoctor2/gui/gl/point.vert | 9 ++++++ 6 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.frag create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.geom create mode 100644 CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.vert 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 index be9a5d2a..08502155 100644 --- 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 @@ -25,6 +25,7 @@ public class GLViewport { private ShaderProgram sceneProgram; private ShaderProgram pickProgram; + private ShaderProgram pointProgram; private boolean initialized; // state set off-thread, consumed in render callback @@ -114,6 +115,7 @@ public class GLViewport { 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 @@ -160,7 +162,14 @@ public class GLViewport { sceneProgram.use(gl); gl.glUniformMatrix4fv(sceneProgram.uniform(gl, "uMVP"), 1, false, mvp, 0); scene.draw(gl); - overlay.draw(gl, pointScale, lineScale); + // 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); double[] req = pickRequest; if (req != null && currentScene != null) { 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 index d1d52cf3..903dcdef 100644 --- 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 @@ -74,20 +74,27 @@ public class HighlightOverlay { gl.glVertexAttribPointer(loc, comp, GL.GL_FLOAT, false, 0, 0L); } - public void draw(GL3 gl, float pointSize, float lineWidth) { - if (!created) { + /** 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; } - if (lineVertexCount > 0) { - gl.glLineWidth(lineWidth); - 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.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); } 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 index d9ba7791..cdcaa96e 100644 --- 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 @@ -15,14 +15,26 @@ 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); } 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 00000000..71d6d76b --- /dev/null +++ b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/resources/de/hft/stuttgart/citydoctor2/gui/gl/point.frag @@ -0,0 +1,18 @@ +#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 + } + float z = sqrt(1.0 - r2); + vec3 normal = normalize(vec3(vQuad.x, vQuad.y, z)); + vec3 lightDir = normalize(vec3(0.3, 0.3, 1.0)); + float diff = max(dot(normal, lightDir), 0.0); + float ambient = 0.45; + vec3 color = vColor * (ambient + (1.0 - ambient) * diff); + fragColor = vec4(color, 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 00000000..577258fb --- /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 00000000..861e4ae9 --- /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; +} -- GitLab From 4bf0ac99e9c186686f31bc051af6fe5ed4bb7e72 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 13:46:28 +0200 Subject: [PATCH 33/40] Disable Prism dirty-region opt to fix stale controls near GL canvas The embedded openglfx canvas breaks JavaFX partial-repaint tracking, leaving sibling controls (the log panel) with a stale/cleared background while only newly-invalidated content (log text) repaints. Setting prism.dirtyopts=false before toolkit init forces a full scene repaint per pulse, fixing it. Co-Authored-By: Claude Opus 4.8 --- .../java/de/hft/stuttgart/citydoctor2/gui/MainWindow.java | 5 +++++ 1 file changed, 5 insertions(+) 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 a141abc9..29861796 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 @@ -245,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); -- GitLab From 8111efcfb046bd05f6da134f79a8a2d21a049de7 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 13:55:03 +0200 Subject: [PATCH 34/40] Render GL viewport on demand instead of every frame openglfx with fps=-1.0 repaints every JavaFX pulse (~60fps), allocating and rendering continuously even when idle. Set fps=0.0 so a frame is drawn only when repaint() is called (every state change and input already does), and add canvas resize listeners to repaint on size changes. Idle now allocates nothing. Co-Authored-By: Claude Opus 4.8 --- .../hft/stuttgart/citydoctor2/gui/gl/GLViewport.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 08502155..79a01115 100644 --- 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 @@ -36,7 +36,7 @@ public class GLViewport { private volatile int[] lastIndices; private volatile boolean wireframe = false; private volatile boolean cull = false; - private volatile float pointScale = 14f; + private volatile float pointScale = 20f; private volatile float lineScale = 4f; // highlight marker data, uploaded on the GL thread when dirty @@ -56,8 +56,10 @@ public class GLViewport { private double lastY; public GLViewport() { - // args: executor, profile, flipY=false, msaa=4 -> request a 4x multisampled framebuffer for AA - canvas = new GLCanvas(new JOGLExecutor(), GLProfile.CORE, false, 4); + // 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. @@ -69,6 +71,9 @@ public class GLViewport { }); 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) -> canvas.repaint()); + canvas.heightProperty().addListener((obs, ov, nv) -> canvas.repaint()); installInputHandlers(); } -- GitLab From 1f89374998d1de0deda02bd6263d4d9476c0032b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 14:02:53 +0200 Subject: [PATCH 35/40] Guarantee redraw delivery for on-demand GL rendering openglfx's requestRepaint is an unconditional notify with no pending flag, so a repaint() arriving while a frame is mid-render is dropped. When a scene was built, the setScene repaint rendered an index-less (empty) frame and the following setIndexSet repaint was lost, so the scene only appeared after the next rotate/zoom. Route all repaints through requestRender(), which re-delivers via a small AnimationTimer pump until a frame consumes the request, then stops itself (idle stays allocation-free). Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/gl/GLViewport.java | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) 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 index 79a01115..9e682b10 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -55,6 +56,22 @@ public class GLViewport { private double lastX; private double lastY; + // 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 @@ -72,8 +89,8 @@ public class GLViewport { 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) -> canvas.repaint()); - canvas.heightProperty().addListener((obs, ov, nv) -> canvas.repaint()); + canvas.widthProperty().addListener((obs, ov, nv) -> requestRender()); + canvas.heightProperty().addListener((obs, ov, nv) -> requestRender()); installInputHandlers(); } @@ -81,15 +98,15 @@ public class GLViewport { // ---- public API (called from FX / worker threads) ---- - public void setScene(SceneData data) { pendingScene.set(data); canvas.repaint(); } + public void setScene(SceneData data) { pendingScene.set(data); requestRender(); } - public void setIndexSet(int[] indices) { pendingIndexSet.set(indices); canvas.repaint(); } + public void setIndexSet(int[] indices) { pendingIndexSet.set(indices); requestRender(); } - public void setWireframe(boolean on) { wireframe = on; canvas.repaint(); } + public void setWireframe(boolean on) { wireframe = on; requestRender(); } - public void setCulling(boolean on) { cull = on; canvas.repaint(); } + public void setCulling(boolean on) { cull = on; requestRender(); } - public void setPointScale(float scale) { pointScale = scale; canvas.repaint(); } + 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) { @@ -98,20 +115,32 @@ public class GLViewport { this.hlLines = lines; this.hlLineColors = lineColors; this.highlightsDirty = true; - canvas.repaint(); + requestRender(); } public OrbitCamera camera() { return camera; } + /** + * 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; - canvas.repaint(); + 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; @@ -221,13 +250,13 @@ public class GLViewport { double speed = Math.abs(camera.distance()) / 1000.0; camera.pan(-dx * speed, dy * speed); } - canvas.repaint(); + requestRender(); }); canvas.setOnScroll(e -> { camera.zoom(e.getDeltaY() < 0 ? 1.05 : 0.95); - canvas.repaint(); + requestRender(); }); } - public void resetCamera() { camera.reset(); canvas.repaint(); } + public void resetCamera() { camera.reset(); requestRender(); } } -- GitLab From 716ad63ade89a0b584f8fd6f1bd994793770a572 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 14:22:06 +0200 Subject: [PATCH 36/40] Keep highlight markers filled in wireframe mode glPolygonMode(GL_LINE) for wireframe applied to the sphere-impostor point quads too, drawing them as outlines. Reset to GL_FILL after the scene mesh so wireframe affects only the geometry; highlight points/edges stay filled. Co-Authored-By: Claude Opus 4.8 --- .../java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 9e682b10..9231b111 100644 --- 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 @@ -196,6 +196,8 @@ public class GLViewport { 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); // 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) -- GitLab From 8dcb25756caf71815ecc02e307a49402ab1afb3e Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Fri, 19 Jun 2026 14:25:57 +0200 Subject: [PATCH 37/40] Make highlight edges win depth ties so they show in wireframe Highlight edges share the exact depth of the wireframe mesh lines, so GL_LESS rejected them and they vanished in wireframe mode. Draw the overlay with GL_LEQUAL (restored to GL_LESS afterward) so coincident markers draw on top while still being occluded by nearer geometry. Co-Authored-By: Claude Opus 4.8 --- .../java/de/hft/stuttgart/citydoctor2/gui/gl/GLViewport.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index 9231b111..509b6e9f 100644 --- 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 @@ -198,6 +198,9 @@ public class GLViewport { 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) @@ -206,6 +209,7 @@ public class GLViewport { 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) { -- GitLab From 8ea7e37288914defdc3664dc8381fbecea7e5783 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Mon, 22 Jun 2026 09:17:21 +0200 Subject: [PATCH 38/40] Support coupled GL viewports for the dual-view healer Add OrbitCamera.copyOrbitFrom (sync orbit/zoom/pan, keep per-viewport aspect), and GLViewport interaction listener + requestRedraw + clearScene so two viewports can share camera movement and be cleared/redrawn independently. Co-Authored-By: Claude Opus 4.8 --- .../citydoctor2/gui/gl/GLViewport.java | 25 +++++++++++++++++++ .../citydoctor2/gui/gl/OrbitCamera.java | 13 ++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) 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 index 509b6e9f..47b9e160 100644 --- 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 @@ -56,6 +56,10 @@ public class GLViewport { 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, @@ -120,6 +124,19 @@ public class GLViewport { 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. @@ -257,12 +274,20 @@ public class GLViewport { 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/OrbitCamera.java b/CityDoctorParent/Extensions/CityDoctorGUI/src/main/java/de/hft/stuttgart/citydoctor2/gui/gl/OrbitCamera.java index 58bbf5ee..ed276845 100644 --- 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 @@ -6,8 +6,8 @@ package de.hft.stuttgart.citydoctor2.gui.gl; */ public class OrbitCamera { - private static final double INITIAL_AZIMUTH = 120.0; - private static final double INITIAL_ELEVATION = 20.0; + 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; @@ -57,6 +57,15 @@ public class OrbitCamera { 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. */ -- GitLab From 9827839c7fb767e5498b835bc5025038a2a67f32 Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Tue, 23 Jun 2026 13:26:56 +0200 Subject: [PATCH 39/40] remove different coloring from point frag shader fix healer gui with new OpenGL viewports --- .../stuttgart/citydoctor2/gui/gl/point.frag | 8 +- .../healer/gui/HealerController.java | 139 +++++++------- .../citydoctor2/healer/gui/HealerToolbar.java | 9 +- .../citydoctor2/healer/gui/HealerView.java | 177 ++++-------------- 4 files changed, 117 insertions(+), 216 deletions(-) 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 index 71d6d76b..291c4b40 100644 --- 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 @@ -8,11 +8,5 @@ void main() { if (r2 > 1.0) { discard; // outside the sphere's silhouette } - float z = sqrt(1.0 - r2); - vec3 normal = normalize(vec3(vQuad.x, vQuad.y, z)); - vec3 lightDir = normalize(vec3(0.3, 0.3, 1.0)); - float diff = max(dot(normal, lightDir), 0.0); - float ambient = 0.45; - vec3 color = vColor * (ambient + (1.0 - ambient) * diff); - fragColor = vec4(color, 1.0); + fragColor = vec4(vColor, 1.0); } 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 31627f7a..8680551f 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 c94cfe66..bb4b6d04 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 d91de2a4..25556f17 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; } -- GitLab From 39603322c6d5632697a195fbacf62dde34e3f90b Mon Sep 17 00:00:00 2001 From: Matthias Betz Date: Tue, 23 Jun 2026 13:38:07 +0200 Subject: [PATCH 40/40] Build: Resolve JOGL/GlueGen 2.5.0 from JogAmp Maven repo Maven Central only publishes JOGL/GlueGen up to 2.3.2, so 2.5.0 failed to resolve on clean machines (CI). Add the JogAmp Maven repository where 2.4.0+ are published. Co-Authored-By: Claude Opus 4.8 --- CityDoctorParent/pom.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CityDoctorParent/pom.xml b/CityDoctorParent/pom.xml index b441030f..c32b722d 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/ + -- GitLab