feat(gui): add 3D-anchored label pass master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 11:31:16 +0000 (14:31 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 11:31:16 +0000 (14:31 +0300)
Move the world-anchored SDF label implementation into systems.gui so
engine consumers share one capability. Extract SdfTextMetrics to avoid
pass-to-pass coupling, add focused behavior coverage, and document the
new pass and current verification results.

Documentation/index.org
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfTextMetrics.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java

index 6fdcc01..c43beb2 100644 (file)
@@ -62,9 +62,9 @@ section updated at slice end.
 *Rename criteria:* all slices done OR consciously dropped, consumers
 running, FO4 exam green.
 
-*Current verified state (2026-10-21):* 132/132 tests green under
+*Current verified state (2026-10-24):* 142/142 tests green under
 =xvfb-run -a=; flat temple oracle 688/307200 px (0.224%, meanAbs 0.19);
-GI oracle 37541 px (12.220%, meanAbs 11.62), both engines converged,
+GI oracle 36193 px (11.782%, meanAbs 11.62), both engines converged,
 drift in named classes only. Numbers quoted inside the progress log
 below are the state at the time each entry was written — this line is
 the current one.
@@ -82,7 +82,7 @@ Maven project, Java 21 (=--release 21=), package
 | =kernel/=     | the fixed pipeline (=Kernel=, =Pass=, =Parallel=) and every substrate store: MeshStore, ScreenStore, ImageStore, CameraStore, LightStore, TextureStore, BvhStore, ChunkStore, InputStore, GuiStore, SdfFontStore, GiStore, LightmapStore, LineStore |
 | =systems/=    | one pass per file: Transform, Cull, Raster, Fog, Line, ChunkFrustumCull, WorldStreamer, Camera, SpaceMouse, StereoMerge                                                                 |
 | =systems/gi/= | GiPass (lifecycle + radiometry), GiScheduler, Raycast, EntryWork                                                                                                                        |
-| =systems/gui/= | GuiPass, GuiRenderPass, SdfFont, DiagnosticsHudPass                                                                                                                                    |
+| =systems/gui/= | GuiPass, GuiRenderPass, LabelPass, SdfFont, SdfTextMetrics, DiagnosticsHudPass                                                                                                                                    |
 | =shapes/=     | the geometry gallery: cube, sphere, cylinder, cone, arrow, pyramid, grid                                                                                                                |
 | =app/=        | AwtShell, DeveloperToolsWindow, BugReport, PersistentLog, SpaceNavigatorHid, SpaceMouseManager                                                                                          |
 | =oracle/=     | compare.sh, gi-compare.sh, OracleTemple, GiOracle                                                                                                                                       |
@@ -342,7 +342,7 @@ order is the table below.
 :CUSTOM_ID: pass-order
 :END:
 
-Measured from code (2026-10-21):
+Measured from the current code:
 
 | order | pass               | job                                                        |
 |-------+--------------------+------------------------------------------------------------|
@@ -357,6 +357,7 @@ Measured from code (2026-10-21):
 |    40 | LinePass           | world-space lines                                          |
 |    40 | StereoMergePass    | anaglyph / side-by-side merge                              |
 |    90 | FogPass            | exponential distance fog, post-process                      |
+|   249 | LabelPass          | viewer-facing SDF labels anchored to 3D points              |
 |   249 | DiagnosticsHudPass | fps / tris / chunks / camera readout                        |
 |   250 | GuiRenderPass      | panels, labels, SDF text                                    |
 
@@ -701,6 +702,15 @@ plus 2 px for the AA ramp (full-cell quads overlap at advance width and
 double-blend into blobs), and advance the pen even for out-of-range
 characters.
 
+=LabelPass= (order 249) maintains viewer-facing labels anchored to 3D
+world points. Applications explicitly register an instance and use
+=add(x, y, z, text, argb)= to supply their labels. Each frame it projects
+the anchors with the camera transform, centres the text, scales it with
+distance without a clamp, and hides anchors closer than camera-space
+=z=5. The pass owns only its configured =GuiStore.Label= records; it is
+not auto-discovered. =SdfTextMetrics= shares string-width measurement
+between the GUI systems without one pass referencing another.
+
 * Platform
 :PROPERTIES:
 :CUSTOM_ID: platform
@@ -2433,3 +2443,24 @@ md5-identical either way (=house-poc.png=). Engine suite 137/137; flat
 temple oracle unchanged 688 px / 0.224% / meanAbs 0.19; GI oracle meanAbs
 11.62 (pixel share 11.78-11.82%, run-to-run noise). The HouseDemo now
 becomes visible without any input.
+
+** 3D-anchored labels join the engine (2026-10-24)
+:PROPERTIES:
+:CUSTOM_ID: engine-label-pass
+:END:
+
+=LabelPass= moved out of demos-2 into =systems.gui=: world-anchored
+labels are an engine capability, while demos retain only their concrete
+label lists. Moving it exposed a contract violation hidden by the old
+location: the pass called =GuiRenderPass.textWidth=. Width measurement
+now lives in the non-pass =SdfTextMetrics= helper, shared by both GUI
+systems, so the no-pass-references-pass rule holds again.
+
+The projection, scaling, hiding and text-width arithmetic is unchanged.
+New =LabelPassTest= coverage pins projection and label reuse, near-plane
+hiding/restoration, absent font/image, and unclamped distance scaling;
+=SdfGuiTest= also pins width measurement. Verified 142/142 tests under
+=xvfb-run -a=, clean engine install and clean demos-2 package, and jar
+contents with the old demos class absent. Flat temple oracle: 688 px
+(0.224%, meanAbs 0.19). GI oracle: both engines converged, 36193 px
+(11.782%, meanAbs 11.62).
index e0a788b..8ee8375 100644 (file)
@@ -33,7 +33,8 @@ public final class GuiRenderPass implements Pass {
                     b.x, b.y, b.w, b.h, b.hover ? b.hoverArgb : b.argb);
             if (data.guiFont != null) {
                 final double s = (b.h - 10.0) / SdfFontStore.CELL;
-                final int textW = textWidth(data.guiFont, b.text, s);
+                final int textW = SdfTextMetrics.textWidth(
+                        data.guiFont, b.text, s);
                 drawText(data, b.x + (b.w - textW) / 2,
                         b.y + b.h / 2 + (int) (data.guiFont.baseline * s / 2),
                         s, b.textArgb, b.text);
@@ -46,20 +47,6 @@ public final class GuiRenderPass implements Pass {
         }
     }
 
-    /** Pixel width of a string at scale s (atlas advances are in cell px). */
-    public static int textWidth(final SdfFontStore font, final String text,
-                                final double s) {
-        int w = 0;
-        for (int i = 0; i < text.length(); i++) {
-            final char c = text.charAt(i);
-            if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) {
-                continue;
-            }
-            w += (int) (font.advance[c - SdfFontStore.FIRST] * s);
-        }
-        return w;
-    }
-
     private static int clamp(final int v, final int lo, final int hi) {
         return v < lo ? lo : Math.min(v, hi);
     }
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java
new file mode 100644 (file)
index 0000000..c57d3df
--- /dev/null
@@ -0,0 +1,109 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+
+/**
+ * Forward-oriented text labels anchored to 3D points — the e3d2 answer
+ * to legacy ForwardOrientedTextBlock: instead of text GEOMETRY (a
+ * plaque quad facing the start pose), each label is screen-space SDF
+ * text anchored to a projected 3D point. Always faces the viewer,
+ * never mirrored, scales with distance like world geometry (UNCLAMPED
+ * — user decision 2026-10-13), hides when the anchor is behind the
+ * camera.
+ *
+ * Applications configure instances with world anchors, text and colours;
+ * each instance maintains only its own GuiStore.Label records and is
+ * registered explicitly by the application.
+ */
+public final class LabelPass implements Pass {
+
+    /** World size the label emulates at the anchor's depth. */
+    private static final double WORLD_SIZE = 30;
+    /** Hide closer than this camera-space z (near-plane margin). */
+    private static final double HIDE_Z = 5;
+
+    private static final class Entry {
+        double x, y, z;
+        String text;
+        int argb;
+        GuiStore.Label label;
+    }
+
+    private final List<Entry> entries = new ArrayList<>();
+
+    /** Adds a label anchored at world (x,y,z). Returns this (fluent). */
+    public LabelPass add(final double x, final double y, final double z,
+                         final String text, final int argb) {
+        final Entry e = new Entry();
+        e.x = x;
+        e.y = y;
+        e.z = z;
+        e.text = text;
+        e.argb = argb;
+        entries.add(e);
+        return this;
+    }
+
+    @Override
+    public int order() {
+        return 249; // with diag-hud, just before gui-render (250)
+    }
+
+    @Override
+    public String name() {
+        return "labels";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        if (data.guiFont == null || data.image == null) {
+            return;
+        }
+        for (final Entry e : entries) {
+            if (e.label == null) {
+                e.label = new GuiStore.Label(0, 0, 0.2, e.argb, e.text);
+                data.gui.labels.add(e.label);
+            }
+            place(data, e);
+        }
+    }
+
+    /** Projects the anchor with exactly TransformPass's math, then
+     *  moves the label's screen position/scale. */
+    private void place(final Substrate data, final Entry e) {
+        final double cosY = Math.cos(data.camera.yaw);
+        final double sinY = Math.sin(data.camera.yaw);
+        final double cosP = Math.cos(data.camera.pitch);
+        final double sinP = Math.sin(data.camera.pitch);
+        final double dx = e.x - data.camera.x;
+        final double dy = e.y - data.camera.y;
+        final double dz = e.z - data.camera.z;
+        // inverse yaw about +Y, then pitch about +X (TransformPass)
+        final double x1 = dx * cosY - dz * sinY;
+        final double z1 = dx * sinY + dz * cosY;
+        final double cy1 = dy * cosP - z1 * sinP;
+        final double cz1 = dy * sinP + z1 * cosP;
+        if (cz1 < HIDE_Z) {
+            e.label.text = "";      // behind the near plane: hide
+            return;
+        }
+        e.label.text = e.text;
+        final double sx = data.image.width / 2.0 + (x1 / cz1) * data.camera.scale;
+        final double sy = data.image.height / 2.0 - (cy1 / cz1) * data.camera.scale;
+        // perspective scale, UNCLAMPED (user decision 2026-10-13):
+        // labels shrink to specks far away and blow up point-blank,
+        // exactly like world geometry
+        final double s = WORLD_SIZE * data.camera.scale
+                / (cz1 * SdfFontStore.CELL);
+        e.label.scale = s;
+        e.label.x = (int) (sx - SdfTextMetrics.textWidth(
+                data.guiFont, e.text, s) / 2.0);
+        e.label.y = (int) (sy + SdfFontStore.CELL * s * 0.25); // baseline ~centered
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfTextMetrics.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfTextMetrics.java
new file mode 100644 (file)
index 0000000..91d229a
--- /dev/null
@@ -0,0 +1,24 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+
+/** Text measurements shared by GUI systems without coupling passes. */
+final class SdfTextMetrics {
+
+    private SdfTextMetrics() {
+    }
+
+    /** Pixel width of a string at scale s (atlas advances are in cell px). */
+    static int textWidth(final SdfFontStore font, final String text,
+                         final double s) {
+        int w = 0;
+        for (int i = 0; i < text.length(); i++) {
+            final char c = text.charAt(i);
+            if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) {
+                continue;
+            }
+            w += (int) (font.advance[c - SdfFontStore.FIRST] * s);
+        }
+        return w;
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java
new file mode 100644 (file)
index 0000000..19cd1dc
--- /dev/null
@@ -0,0 +1,94 @@
+package eu.svjatoslav.aukio.e3d2.systems.gui;
+
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** World-anchored SDF labels. */
+class LabelPassTest {
+
+    private static Substrate data() {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(200, 100);
+        data.guiFont = SdfFont.build(48);
+        data.camera.set(0, 0, 0, 0, 0, 100);
+        return data;
+    }
+
+    @Test
+    void projectsWorldAnchorAndReusesGuiLabel() {
+        final Substrate data = data();
+        final LabelPass pass = new LabelPass()
+                .add(0, 0, 100, "X", 0xFF112233);
+
+        pass.apply(data);
+
+        assertEquals(1, data.gui.labels.size());
+        final GuiStore.Label label = data.gui.labels.get(0);
+        final double scale = 30.0 * 100 / (100 * SdfFontStore.CELL);
+        final int width = (int) (data.guiFont.advance['X' - SdfFontStore.FIRST]
+                * scale);
+        assertEquals("X", label.text);
+        assertEquals(0xFF112233, label.argb);
+        assertEquals(scale, label.scale, 1e-9);
+        assertEquals((int) (100 - width / 2.0), label.x);
+        assertEquals((int) (50 + SdfFontStore.CELL * scale * 0.25), label.y);
+
+        pass.apply(data);
+        assertEquals(1, data.gui.labels.size(), "second frame adds no duplicate");
+        assertSame(label, data.gui.labels.get(0), "existing label is updated");
+    }
+
+    @Test
+    void hidesBehindNearPlaneAndRestoresWhenVisible() {
+        final Substrate data = data();
+        final LabelPass pass = new LabelPass().add(0, 0, 4, "X", 0xFFFFFFFF);
+
+        pass.apply(data);
+        assertEquals(1, data.gui.labels.size());
+        assertEquals("", data.gui.labels.get(0).text);
+
+        data.camera.set(0, 0, -96, 0, 0, 100);
+        pass.apply(data);
+        assertEquals("X", data.gui.labels.get(0).text);
+    }
+
+    @Test
+    void missingFontOrImageLeavesGuiUntouched() {
+        final Substrate data = new Substrate();
+        final LabelPass pass = new LabelPass().add(0, 0, 100, "X", 0xFFFFFFFF);
+
+        pass.apply(data);
+        assertTrue(data.gui.labels.isEmpty(), "no font or image");
+
+        data.guiFont = SdfFont.build(48);
+        pass.apply(data);
+        assertTrue(data.gui.labels.isEmpty(), "no image");
+
+        data.guiFont = null;
+        data.image = new ImageStore(200, 100);
+        pass.apply(data);
+        assertTrue(data.gui.labels.isEmpty(), "no font");
+    }
+
+    @Test
+    void distantLabelsShrinkWithoutClamp() {
+        final Substrate data = data();
+        final LabelPass pass = new LabelPass()
+                .add(0, 0, 100, "near", 0xFFFFFFFF)
+                .add(0, 0, 1000, "far", 0xFFFFFFFF);
+
+        pass.apply(data);
+
+        final double nearScale = data.gui.labels.get(0).scale;
+        final double farScale = data.gui.labels.get(1).scale;
+        assertEquals(10.0, nearScale / farScale, 1e-9);
+        assertTrue(farScale < 0.1, "far label is not clamped to a minimum size");
+    }
+}
index 32640d9..8d46088 100644 (file)
@@ -60,6 +60,15 @@ class SdfGuiTest {
         assertEquals(1, data.gui.clicks.size(), "no click on release outside");
     }
 
+    @Test
+    void textWidthUsesPerGlyphAtlasAdvances() {
+        final SdfFontStore font = new SdfFontStore();
+        font.advance['A' - SdfFontStore.FIRST] = 20;
+        font.advance['B' - SdfFontStore.FIRST] = 10;
+
+        assertEquals(25, SdfTextMetrics.textWidth(font, "ABA", 0.5));
+    }
+
     @Test
     void sdfTextRendersWithAntialiasedEdges() {
         final Substrate data = new Substrate();