Commit adcc12e8 authored by Matthias Betz's avatar Matthias Betz
Browse files

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: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 297334ab
# 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<GLRenderEvent>)`, `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<Object> 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 &gt; 1 moves farther, &lt; 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<TriangleMeta> metas = Arrays.asList(
new TriangleMeta(0, null, 1, false),
new TriangleMeta(1, null, 2, false),
new TriangleMeta(2, null, 1, false));
Predicate<TriangleMeta> 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<TriangleMeta> 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<TriangleMeta> metas, Predicate<TriangleMeta> 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<TriangleMeta> 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<TriangleMeta> triangleMetas;
private final PickRegistry pickRegistry;
private final double[] center;
public SceneData(float[] positions, float[] colors, int[] ids,
List<TriangleMeta> 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<TriangleMeta> 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<Polygon> 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<? extends Polygon> 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<? extends Polygon> polygons, Color baseColor,
int lod, boolean roof) {
Vector3d center = findCenter(polygons);
MeshAccumulator acc = new MeshAccumulator(center);
PickRegistry registry = new PickRegistry();
List<TriangleMeta> 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<? extends Polygon> 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
<dependency>
<groupId>org.jogamp.jogl</groupId>
<artifactId>jogl-all-main</artifactId>
</dependency>
<dependency>
<groupId>org.jogamp.gluegen</groupId>
<artifactId>gluegen-rt-main</artifactId>
</dependency>
```
(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<SceneData> pendingScene = new AtomicReference<>();
private final AtomicReference<int[]> pendingIndexSet = new AtomicReference<>();
private final ConcurrentLinkedQueue<float[]> 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<Object> 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<Object> 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<Object> 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<double[]> 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<double[]> 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<LinearRing>), 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).
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment