diff --git a/.gitignore b/.gitignore
index 8a527dc..5185bf7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,6 +65,8 @@ wxwidgets-clean/
!tests/apps/kicad/pcbnew.html
/tests/apps/gal-webgl/*.js
/tests/apps/gal-webgl/*.wasm
+/tests/apps/3d-webgl/*.js
+/tests/apps/3d-webgl/*.wasm
/temp/
*.log
*.tmp
diff --git a/scripts/build-3d-native-test.sh b/scripts/build-3d-native-test.sh
new file mode 100755
index 0000000..b46d87c
--- /dev/null
+++ b/scripts/build-3d-native-test.sh
@@ -0,0 +1,55 @@
+#!/bin/bash
+#
+# Build script for the native 3D-renderer test harness (tests/3d-regression/native).
+#
+# Builds a standalone macOS application that renders the shared 3D scenarios
+# through KiCad's actual RENDER_3D_OPENGL code paths (real desktop OpenGL) and
+# writes golden baseline PNGs for the OpenGL->WebGL port regression suite.
+#
+# Modeled on scripts/build-gal-native-test.sh.
+#
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+TEST_DIR="$PROJECT_ROOT/tests/3d-regression/native"
+BUILD_DIR="$TEST_DIR/build"
+LOG_FILE="$PROJECT_ROOT/tests/logs/3d-native-build.log"
+
+mkdir -p "$(dirname "$LOG_FILE")"
+
+echo "Building 3D Renderer Native Test..."
+echo " Test dir: $TEST_DIR"
+echo " Build dir: $BUILD_DIR"
+echo " Log file: $LOG_FILE"
+
+mkdir -p "$BUILD_DIR"
+
+echo "Running CMake..."
+if ! cmake -S "$TEST_DIR" -B "$BUILD_DIR" >> "$LOG_FILE" 2>&1; then
+ echo "CMake configuration failed! Check $LOG_FILE for details."
+ echo ""
+ echo "Last 50 lines of log:"
+ tail -50 "$LOG_FILE"
+ exit 1
+fi
+
+echo "Building..."
+if ! make -C "$BUILD_DIR" -j"$(sysctl -n hw.ncpu 2>/dev/null || echo 4)" >> "$LOG_FILE" 2>&1; then
+ echo "Build failed! Check $LOG_FILE for details."
+ echo ""
+ echo "Last 100 lines of log:"
+ tail -100 "$LOG_FILE"
+ exit 1
+fi
+
+echo "Build successful!"
+echo "Executable: $BUILD_DIR/scene3d_native_test"
+echo ""
+echo "Options:"
+echo " --output
Output directory for 3d-.png"
+echo " --manifest Write the scenario manifest JSON"
+echo " --filter Only run scenarios whose name contains "
+echo " --list Print scenario names and exit"
+echo " --show Keep the window open after rendering"
diff --git a/scripts/build-3d-webgl-test.sh b/scripts/build-3d-webgl-test.sh
new file mode 100755
index 0000000..7ae7e03
--- /dev/null
+++ b/scripts/build-3d-webgl-test.sh
@@ -0,0 +1,75 @@
+#!/bin/bash
+#
+# Build script for the 3D-renderer WebGL test harness (WASM).
+#
+# Builds the shared 3D scenarios (tests/3d-regression/scenarios) + real KiCad
+# 3D-viewer TUs against the FFP no-op stubs (wasm/stubs/gl_ffp_stub.c) — the
+# TDD red state for the OpenGL->WebGL port. Output: tests/apps/3d-webgl/.
+#
+# Requires the wxWidgets WASM build (scripts/build-wx-wasm.sh) and the sysroot
+# headers (boost/glm) in build-wasm/sysroot.
+#
+# Usage:
+# ./scripts/build-3d-webgl-test.sh # Clean build (default)
+# ./scripts/build-3d-webgl-test.sh --no-clean # Incremental build
+# ./scripts/build-3d-webgl-test.sh --debug # Debug build with source maps
+#
+# Modeled on scripts/build-gal-webgl-test.sh (no shader-generation step — the
+# FFP renderer has no GLSL yet; the port will add one here).
+#
+
+source "$(dirname "$0")/common/logging.sh"
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+JOBS="${JOBS:-$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)}"
+
+QUIET=1 source "$SCRIPT_DIR/common/env.sh"
+
+TEST_DIR="$PROJECT_ROOT/tests/3d-regression/wasm"
+OUTPUT_DIR="$PROJECT_ROOT/tests/apps/3d-webgl"
+
+echo "Building 3D WebGL Test (red-state harness)..."
+echo " Test dir: $TEST_DIR"
+echo " Output dir: $OUTPUT_DIR"
+
+if ! command -v em++ &> /dev/null; then
+ echo "ERROR: em++ not found. Run: ./scripts/setup-emsdk.sh"
+ exit 1
+fi
+
+if [ ! -x "$PROJECT_ROOT/build-wasm/wxwidgets/wx-config" ]; then
+ echo "ERROR: wxWidgets WASM build missing. Run: ./scripts/build-wx-wasm.sh"
+ exit 1
+fi
+
+echo " Emscripten: $(em++ --version 2>&1 | head -1)"
+
+DEBUG_BUILD=0
+CLEAN_BUILD=1
+
+for arg in "$@"; do
+ case "$arg" in
+ --debug) DEBUG_BUILD=1 ;;
+ --no-clean) CLEAN_BUILD=0 ;;
+ esac
+done
+
+cd "$TEST_DIR"
+
+if [ "$CLEAN_BUILD" = "1" ]; then
+ make clean || true
+fi
+
+rm -f "$OUTPUT_DIR"/*.js "$OUTPUT_DIR"/*.wasm
+
+if [ "$DEBUG_BUILD" = "1" ]; then
+ make -j"$JOBS" DEBUG=1
+else
+ make -j"$JOBS"
+fi
+
+echo "Build successful: $OUTPUT_DIR/3d_webgl_test.{js,wasm,html}"
+echo "Serve with: cd tests && npm run serve -> /3d-webgl/3d_webgl_test.html"
diff --git a/scripts/test-3d-regression.sh b/scripts/test-3d-regression.sh
new file mode 100755
index 0000000..e8db905
--- /dev/null
+++ b/scripts/test-3d-regression.sh
@@ -0,0 +1,179 @@
+#!/bin/bash
+#
+# 3D-renderer regression suite orchestrator (tests/3d-regression).
+#
+# Usage:
+# ./scripts/test-3d-regression.sh # native: build -> run -> compare vs baseline;
+# # webgl phase too once the wasm harness exists
+# ./scripts/test-3d-regression.sh native # native phase only
+# ./scripts/test-3d-regression.sh webgl # wasm build -> playwright -> webgl-self + parity
+# ./scripts/test-3d-regression.sh compare # comparisons only (skip builds/runs)
+# ./scripts/test-3d-regression.sh promote # promote output/native -> baseline/ (byte-diff
+# # guarded) + manifest.json
+#
+# Comparison engine: the pixelmatch CI tooling (tests/tools/screenshots/compare-dirs.ts),
+# NOT ImageMagick. Levels/floors: tests/3d-regression/floors.json.
+#
+
+source "$(dirname "${BASH_SOURCE[0]}")/common/logging.sh"
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+THREED_DIR="$PROJECT_ROOT/tests/3d-regression"
+BASELINE_DIR="$THREED_DIR/baseline"
+BASELINE_WEBGL_DIR="$THREED_DIR/baseline-webgl"
+NATIVE_OUT="$THREED_DIR/output/native"
+WEBGL_OUT="$THREED_DIR/output/webgl"
+NATIVE_BIN="$THREED_DIR/native/build/scene3d_native_test"
+
+MODE="${1:-all}"
+
+NATIVE_COMPARE_STATUS=0
+WEBGL_COMPARE_STATUS=0
+
+run_native() {
+ echo "=== Native phase: build + render ==="
+ "$SCRIPT_DIR/build-3d-native-test.sh"
+
+ mkdir -p "$NATIVE_OUT"
+ "$NATIVE_BIN" --output "$NATIVE_OUT" --manifest "$NATIVE_OUT/manifest.json"
+
+ # Anti-drift guard: the scenario registry (names/size) must match the
+ # committed manifest; a mismatch means scenarios changed and the baselines
+ # need review + re-promote.
+ if [ -f "$THREED_DIR/manifest.json" ]; then
+ if ! cmp -s "$THREED_DIR/manifest.json" "$NATIVE_OUT/manifest.json"; then
+ echo "ERROR: scenario registry changed (manifest.json differs from committed copy)."
+ echo "Review the change, then: ./scripts/test-3d-regression.sh promote"
+ exit 1
+ fi
+ else
+ echo "NOTE: no committed manifest yet (first run) — promote will create it."
+ fi
+}
+
+compare_native() {
+ if [ ! -d "$BASELINE_DIR" ] || [ -z "$(ls -A "$BASELINE_DIR" 2>/dev/null)" ]; then
+ echo "SKIP native-self compare: no committed baseline yet (run promote first)."
+ return
+ fi
+
+ echo "=== Compare: native-self (baseline/ vs output/native/) ==="
+ ( cd "$PROJECT_ROOT/tests" && npm run --silent 3d:check ) || NATIVE_COMPARE_STATUS=$?
+}
+
+run_webgl() {
+ if [ ! -f "$THREED_DIR/wasm/Makefile" ]; then
+ echo "SKIP webgl phase: tests/3d-regression/wasm not present yet."
+ return
+ fi
+
+ if [ ! -x "$PROJECT_ROOT/build-wasm/wxwidgets/wx-config" ]; then
+ echo "SKIP webgl phase: wxWidgets WASM build missing (scripts/build-wx-wasm.sh)."
+ return
+ fi
+
+ echo "=== WebGL phase: build + playwright capture ==="
+ "$SCRIPT_DIR/build-3d-webgl-test.sh"
+ ( cd "$PROJECT_ROOT/tests" && npx playwright test e2e/3d-webgl.spec.ts )
+}
+
+compare_webgl() {
+ if [ ! -d "$WEBGL_OUT" ] || [ -z "$(ls -A "$WEBGL_OUT" 2>/dev/null)" ]; then
+ echo "SKIP webgl compares: no webgl renders in output/webgl."
+ return
+ fi
+
+ if [ -d "$BASELINE_WEBGL_DIR" ] && [ -n "$(ls -A "$BASELINE_WEBGL_DIR" 2>/dev/null)" ]; then
+ echo "=== Compare: webgl-self (baseline-webgl/ vs output/webgl/) ==="
+ ( cd "$PROJECT_ROOT/tests" && npm run --silent 3d:check:webgl ) || WEBGL_COMPARE_STATUS=$?
+ else
+ echo "SKIP webgl-self compare: no baseline-webgl yet."
+ fi
+
+ # Port-parity is the TDD progress meter: informational, never gates.
+ echo "=== Compare: parity (baseline/ vs output/webgl/, informational) ==="
+ ( cd "$PROJECT_ROOT/tests" && npm run --silent 3d:check:parity ) || true
+}
+
+promote() {
+ if [ ! -d "$NATIVE_OUT" ] || [ -z "$(ls -A "$NATIVE_OUT" 2>/dev/null)" ]; then
+ echo "ERROR: nothing to promote — run the native phase first."
+ exit 1
+ fi
+
+ mkdir -p "$BASELINE_DIR"
+ local changed=0
+
+ for png in "$NATIVE_OUT"/3d-*.png; do
+ local name
+ name="$(basename "$png")"
+
+ if ! cmp -s "$png" "$BASELINE_DIR/$name"; then
+ cp "$png" "$BASELINE_DIR/$name"
+ echo " promoted: $name"
+ changed=$((changed + 1))
+ fi
+ done
+
+ if ! cmp -s "$NATIVE_OUT/manifest.json" "$THREED_DIR/manifest.json"; then
+ cp "$NATIVE_OUT/manifest.json" "$THREED_DIR/manifest.json"
+ echo " promoted: manifest.json"
+ changed=$((changed + 1))
+ fi
+
+ # Baselines removed from the registry linger in baseline/ — report them.
+ for png in "$BASELINE_DIR"/3d-*.png; do
+ [ -e "$png" ] || continue
+ if [ ! -f "$NATIVE_OUT/$(basename "$png")" ]; then
+ echo " STALE baseline (not in registry anymore): $(basename "$png")"
+ fi
+ done
+
+ echo "Promote done: $changed file(s) updated (byte-diff guarded, zero churn)."
+ echo "Review + git add tests/3d-regression/{baseline,manifest.json}."
+}
+
+case "$MODE" in
+ all)
+ run_native
+ compare_native
+ run_webgl
+ compare_webgl
+ ;;
+ native)
+ run_native
+ compare_native
+ ;;
+ webgl)
+ run_webgl
+ compare_webgl
+ ;;
+ compare)
+ compare_native
+ compare_webgl
+ ;;
+ promote)
+ promote
+ exit 0
+ ;;
+ *)
+ echo "Usage: $0 [all|native|webgl|compare|promote]"
+ exit 2
+ ;;
+esac
+
+echo ""
+if [ $NATIVE_COMPARE_STATUS -ne 0 ]; then
+ echo "RESULT: FAIL (native-self comparison found changes — see tests/3d-regression/output/diff/native-self/)"
+ exit 1
+fi
+
+if [ $WEBGL_COMPARE_STATUS -ne 0 ]; then
+ echo "RESULT: FAIL (webgl-self comparison found changes — see tests/3d-regression/output/diff/webgl-self/)"
+ exit 1
+fi
+
+echo "RESULT: PASS"
diff --git a/tests/3d-regression/README.md b/tests/3d-regression/README.md
new file mode 100644
index 0000000..3755eff
--- /dev/null
+++ b/tests/3d-regression/README.md
@@ -0,0 +1,124 @@
+# 3D Renderer Regression Suite (OpenGL → WebGL port)
+
+Screenshot-baseline TDD harness for porting KiCad's 3D viewer OpenGL renderer
+(`RENDER_3D_OPENGL`, pure GL 1.x fixed-function) to WebGL2 — the same approach
+used for the 2D GAL port (`tests/gal-regression/`), but compared with the CI
+pixelmatch engine instead of ImageMagick.
+
+Shared C++ **scenarios** call real KiCad 3D-viewer code (draw helpers, display
+lists, materials, camera, stencil hole-subtraction, VBO models…) and are
+compiled two ways:
+
+- **native/** — macOS app on real desktop OpenGL (2.1 compatibility context via
+ the vendored glad loader). Renders each scenario into a fixed 800×600
+ offscreen FBO (`GL_RGBA8` + `GL_DEPTH24_STENCIL8`, the
+ `EDA_3D_CANVAS::RenderToFrameBuffer` recipe) → **golden baselines**.
+- **wasm/** — the same scenario TUs compiled with em++ (planned; red state
+ first: linked against `wasm/stubs/gl_ffp_stub.c` no-ops, every render blank
+ until the port makes them green).
+
+## Directory layout
+
+```
+scenarios/ shared scenario sources (native + wasm) — the registry in
+ scene3d_test_scenarios.cpp is the single source of truth
+native/ golden generator (CMake; homebrew wxWidgets + OpenGL.framework)
+wasm/ WebGL harness (planned)
+baseline/ committed native goldens: 3d-.png
+baseline-webgl/ committed browser renders (port era, CI-promoted)
+output/ run output + diffs (gitignored)
+manifest.json {width,height,scenarios[]} written by the native harness;
+ committed — the anti-drift anchor for specs and orchestrator
+floors.json pixelmatch verdict floors per comparison level
+```
+
+## Running
+
+```
+./scripts/test-3d-regression.sh # build + render + gate (native; webgl when present)
+./scripts/test-3d-regression.sh native # native phase only
+./scripts/test-3d-regression.sh compare # comparisons only
+./scripts/test-3d-regression.sh promote # promote output/native -> baseline/ (byte-diff guarded)
+```
+
+Logs land in `logs/test-3d-regression/` (build logs in `tests/logs/`).
+
+## Comparison levels
+
+Engine: `tests/tools/screenshots/compare-dirs.ts` (pixelmatch
+`{threshold: 0.1, includeAA: false}` — AA edge pixels ignored), floors from
+`floors.json`. Diff triptychs/heatmaps + `report.json` land in
+`output/diff//`.
+
+| Level | Pair | Floor (changedRatio) | Role |
+|---|---|---|---|
+| `native-self` | `baseline/` vs `output/native/` | 0.001 (measured noise: exactly 0 on Apple M5) | gating regression check on the dev Mac |
+| `webgl-vs-native` | `baseline/` vs `output/webgl/` | 0.02, **report-only** | the TDD port-progress meter (`npm run 3d:check:parity`) |
+| `webgl-self` | `baseline-webgl/` vs `output/webgl/` | 0.005 | browser regression anchor (once the port renders) |
+
+npm scripts (from `tests/`): `3d:check`, `3d:check:webgl`, `3d:check:parity`,
+`3d:compare` (generic dir pair), `3d:test:webgl`.
+
+## Updating baselines
+
+Baselines are generated on the dev Mac (CI has no native GL — same model as the
+GAL suite). After an intentional render change:
+
+1. `./scripts/test-3d-regression.sh native` (fails with triptychs in
+ `output/diff/native-self/` — eyeball them),
+2. `./scripts/test-3d-regression.sh promote` (byte-diff-guarded copy, zero
+ churn) and commit `baseline/` + `manifest.json`.
+
+The orchestrator `cmp`s the freshly-written manifest against the committed one
+every run, so a scenario registry change can't silently drift past the
+baselines. Scenario names are append-only — never rename or renumber (they are
+the PNG names and the WebGL test IDs).
+
+## TDD red state
+
+Once `wasm/` exists, the Playwright spec (`tests/e2e/3d-webgl.spec.ts`) is
+capture-only and stays green; the red signal is `npm run 3d:check:parity`
+reporting ~100% changed for every scenario. Port progress = scenarios dropping
+out of that report. `glLineWidth > 1` (model bbox scenario) has no WebGL
+equivalent — expect that one to need quad emulation to go green.
+
+## Scenario tiers (47 scenarios)
+
+- **Tier 1 (30)** — standalone TUs: `opengl_utils`
+ (arrows/segments/bbox/half-cylinder), `ogl_utils` (background gradient,
+ materials, textures), `TRIANGLE_DISPLAY_LIST`/`OPENGL_RENDER_LIST` (display
+ lists, seg-ends alpha-test texture, `DrawCulled` stencil subtraction,
+ z-transform, transparency), `MODEL_3D` (VBO/IBO, material modes, bboxes),
+ `SPHERES_GIZMO`, camera (perspective/ortho/preset views, isolated lights).
+- **Tier 2 (14)** — `RENDER_3D_OPENGL` private generators
+ (`generateCylinder/Disk/Dimple/InvCone`, all five `addObjectTriangles`
+ overloads, `appendPostMachiningGeometry`, via composite, the four grid
+ densities, `setupMaterials`/`setLayerMaterial`/`setArrowMaterial`,
+ `createBoard`) via the rob-template accessor
+ (`native/render3d_test_accessor.*`) over the synthetic `BOARD_ADAPTER`
+ (`native/board_adapter_test_impl.cpp` — its `InitSettings` is the test-data
+ seam).
+- **Tier 3 (3)** — full `reload()` + `Redraw()` composites over the synthetic
+ mini-board: `redraw-empty`, `redraw-mini-board` (copper/silk/mask/stencil
+ holes), `redraw-mini-board-navigator` (grid + gizmo — the port-complete
+ gate).
+
+## Known upstream bug (documented by `3d-post-machining.png`)
+
+`appendPostMachiningGeometry`'s COUNTERSINK path adds middle-contour quads
+with `AddQuad` but never calls `AddNormal`, so the normals array ends up half
+the vertex count and `OPENGL_RENDER_LIST::generate_middle_triangles` rejects
+the whole middle list — a countersunk hole silently erases the walls of any
+geometry batched into the same `TRIANGLE_DISPLAY_LIST` (the real viewer has
+the same defect). The scenario keeps counterbore and countersink in separate
+lists so the counterbore renders correctly while the countersink half records
+the buggy (empty) upstream output.
+
+Lighting semantics worth knowing: `init_lights()` runs once at context init
+under the identity modelview, so the two directional lights are anchored in
+**eye space** (they follow the camera) — the harness replicates that
+(`SCENE3D_CTX::InitOnce`), and only the headlight is repositioned per frame
+like `Redraw()` does. Also avoid toggling `GL_LIGHTx` between draws inside one
+frame: the Apple GL driver drops the first draw after a mid-frame toggle (the
+real renderer never does this; the isolated-light scenarios use one light per
+frame instead).
diff --git a/tests/3d-regression/baseline/3d-addobj-all-shapes.png b/tests/3d-regression/baseline/3d-addobj-all-shapes.png
new file mode 100644
index 0000000..5b6d8c8
Binary files /dev/null and b/tests/3d-regression/baseline/3d-addobj-all-shapes.png differ
diff --git a/tests/3d-regression/baseline/3d-arrow-material.png b/tests/3d-regression/baseline/3d-arrow-material.png
new file mode 100644
index 0000000..401cf61
Binary files /dev/null and b/tests/3d-regression/baseline/3d-arrow-material.png differ
diff --git a/tests/3d-regression/baseline/3d-bg-gradient-alpha.png b/tests/3d-regression/baseline/3d-bg-gradient-alpha.png
new file mode 100644
index 0000000..122c4e1
Binary files /dev/null and b/tests/3d-regression/baseline/3d-bg-gradient-alpha.png differ
diff --git a/tests/3d-regression/baseline/3d-bg-gradient.png b/tests/3d-regression/baseline/3d-bg-gradient.png
new file mode 100644
index 0000000..989923f
Binary files /dev/null and b/tests/3d-regression/baseline/3d-bg-gradient.png differ
diff --git a/tests/3d-regression/baseline/3d-bounding-box.png b/tests/3d-regression/baseline/3d-bounding-box.png
new file mode 100644
index 0000000..79de865
Binary files /dev/null and b/tests/3d-regression/baseline/3d-bounding-box.png differ
diff --git a/tests/3d-regression/baseline/3d-camera-ortho.png b/tests/3d-regression/baseline/3d-camera-ortho.png
new file mode 100644
index 0000000..3dd97b5
Binary files /dev/null and b/tests/3d-regression/baseline/3d-camera-ortho.png differ
diff --git a/tests/3d-regression/baseline/3d-camera-persp.png b/tests/3d-regression/baseline/3d-camera-persp.png
new file mode 100644
index 0000000..585fc9a
Binary files /dev/null and b/tests/3d-regression/baseline/3d-camera-persp.png differ
diff --git a/tests/3d-regression/baseline/3d-camera-preset-views.png b/tests/3d-regression/baseline/3d-camera-preset-views.png
new file mode 100644
index 0000000..9010d72
Binary files /dev/null and b/tests/3d-regression/baseline/3d-camera-preset-views.png differ
diff --git a/tests/3d-regression/baseline/3d-create-board.png b/tests/3d-regression/baseline/3d-create-board.png
new file mode 100644
index 0000000..588fe3a
Binary files /dev/null and b/tests/3d-regression/baseline/3d-create-board.png differ
diff --git a/tests/3d-regression/baseline/3d-gen-cylinder.png b/tests/3d-regression/baseline/3d-gen-cylinder.png
new file mode 100644
index 0000000..7803d04
Binary files /dev/null and b/tests/3d-regression/baseline/3d-gen-cylinder.png differ
diff --git a/tests/3d-regression/baseline/3d-gen-dimple.png b/tests/3d-regression/baseline/3d-gen-dimple.png
new file mode 100644
index 0000000..182e49c
Binary files /dev/null and b/tests/3d-regression/baseline/3d-gen-dimple.png differ
diff --git a/tests/3d-regression/baseline/3d-gen-disk.png b/tests/3d-regression/baseline/3d-gen-disk.png
new file mode 100644
index 0000000..e98c0ca
Binary files /dev/null and b/tests/3d-regression/baseline/3d-gen-disk.png differ
diff --git a/tests/3d-regression/baseline/3d-gen-invcone.png b/tests/3d-regression/baseline/3d-gen-invcone.png
new file mode 100644
index 0000000..5a0936c
Binary files /dev/null and b/tests/3d-regression/baseline/3d-gen-invcone.png differ
diff --git a/tests/3d-regression/baseline/3d-grid-10mm.png b/tests/3d-regression/baseline/3d-grid-10mm.png
new file mode 100644
index 0000000..87f7944
Binary files /dev/null and b/tests/3d-regression/baseline/3d-grid-10mm.png differ
diff --git a/tests/3d-regression/baseline/3d-grid-1mm.png b/tests/3d-regression/baseline/3d-grid-1mm.png
new file mode 100644
index 0000000..3c3e07b
Binary files /dev/null and b/tests/3d-regression/baseline/3d-grid-1mm.png differ
diff --git a/tests/3d-regression/baseline/3d-grid-2p5mm.png b/tests/3d-regression/baseline/3d-grid-2p5mm.png
new file mode 100644
index 0000000..ba9b56b
Binary files /dev/null and b/tests/3d-regression/baseline/3d-grid-2p5mm.png differ
diff --git a/tests/3d-regression/baseline/3d-grid-5mm.png b/tests/3d-regression/baseline/3d-grid-5mm.png
new file mode 100644
index 0000000..6f9f578
Binary files /dev/null and b/tests/3d-regression/baseline/3d-grid-5mm.png differ
diff --git a/tests/3d-regression/baseline/3d-half-open-cylinder.png b/tests/3d-regression/baseline/3d-half-open-cylinder.png
new file mode 100644
index 0000000..b151074
Binary files /dev/null and b/tests/3d-regression/baseline/3d-half-open-cylinder.png differ
diff --git a/tests/3d-regression/baseline/3d-layer-materials.png b/tests/3d-regression/baseline/3d-layer-materials.png
new file mode 100644
index 0000000..5710044
Binary files /dev/null and b/tests/3d-regression/baseline/3d-layer-materials.png differ
diff --git a/tests/3d-regression/baseline/3d-light-bottom.png b/tests/3d-regression/baseline/3d-light-bottom.png
new file mode 100644
index 0000000..9ee1fa0
Binary files /dev/null and b/tests/3d-regression/baseline/3d-light-bottom.png differ
diff --git a/tests/3d-regression/baseline/3d-light-front.png b/tests/3d-regression/baseline/3d-light-front.png
new file mode 100644
index 0000000..c7bb068
Binary files /dev/null and b/tests/3d-regression/baseline/3d-light-front.png differ
diff --git a/tests/3d-regression/baseline/3d-light-top.png b/tests/3d-regression/baseline/3d-light-top.png
new file mode 100644
index 0000000..b6ae66c
Binary files /dev/null and b/tests/3d-regression/baseline/3d-light-top.png differ
diff --git a/tests/3d-regression/baseline/3d-material-copper.png b/tests/3d-regression/baseline/3d-material-copper.png
new file mode 100644
index 0000000..bc478dc
Binary files /dev/null and b/tests/3d-regression/baseline/3d-material-copper.png differ
diff --git a/tests/3d-regression/baseline/3d-material-diffuse-only.png b/tests/3d-regression/baseline/3d-material-diffuse-only.png
new file mode 100644
index 0000000..c798eab
Binary files /dev/null and b/tests/3d-regression/baseline/3d-material-diffuse-only.png differ
diff --git a/tests/3d-regression/baseline/3d-material-transparent.png b/tests/3d-regression/baseline/3d-material-transparent.png
new file mode 100644
index 0000000..3d3f0bb
Binary files /dev/null and b/tests/3d-regression/baseline/3d-material-transparent.png differ
diff --git a/tests/3d-regression/baseline/3d-model3d-bbox.png b/tests/3d-regression/baseline/3d-model3d-bbox.png
new file mode 100644
index 0000000..110489b
Binary files /dev/null and b/tests/3d-regression/baseline/3d-model3d-bbox.png differ
diff --git a/tests/3d-regression/baseline/3d-model3d-material-modes.png b/tests/3d-regression/baseline/3d-model3d-material-modes.png
new file mode 100644
index 0000000..ad2ec73
Binary files /dev/null and b/tests/3d-regression/baseline/3d-model3d-material-modes.png differ
diff --git a/tests/3d-regression/baseline/3d-model3d-opaque.png b/tests/3d-regression/baseline/3d-model3d-opaque.png
new file mode 100644
index 0000000..9a36b20
Binary files /dev/null and b/tests/3d-regression/baseline/3d-model3d-opaque.png differ
diff --git a/tests/3d-regression/baseline/3d-model3d-transparent.png b/tests/3d-regression/baseline/3d-model3d-transparent.png
new file mode 100644
index 0000000..3d2f11a
Binary files /dev/null and b/tests/3d-regression/baseline/3d-model3d-transparent.png differ
diff --git a/tests/3d-regression/baseline/3d-post-machining.png b/tests/3d-regression/baseline/3d-post-machining.png
new file mode 100644
index 0000000..18ae549
Binary files /dev/null and b/tests/3d-regression/baseline/3d-post-machining.png differ
diff --git a/tests/3d-regression/baseline/3d-redraw-empty.png b/tests/3d-regression/baseline/3d-redraw-empty.png
new file mode 100644
index 0000000..989923f
Binary files /dev/null and b/tests/3d-regression/baseline/3d-redraw-empty.png differ
diff --git a/tests/3d-regression/baseline/3d-redraw-mini-board-navigator.png b/tests/3d-regression/baseline/3d-redraw-mini-board-navigator.png
new file mode 100644
index 0000000..6108acc
Binary files /dev/null and b/tests/3d-regression/baseline/3d-redraw-mini-board-navigator.png differ
diff --git a/tests/3d-regression/baseline/3d-redraw-mini-board.png b/tests/3d-regression/baseline/3d-redraw-mini-board.png
new file mode 100644
index 0000000..e58be9a
Binary files /dev/null and b/tests/3d-regression/baseline/3d-redraw-mini-board.png differ
diff --git a/tests/3d-regression/baseline/3d-round-arrow.png b/tests/3d-regression/baseline/3d-round-arrow.png
new file mode 100644
index 0000000..21c9ae9
Binary files /dev/null and b/tests/3d-regression/baseline/3d-round-arrow.png differ
diff --git a/tests/3d-regression/baseline/3d-round-arrows-axes.png b/tests/3d-regression/baseline/3d-round-arrows-axes.png
new file mode 100644
index 0000000..9595981
Binary files /dev/null and b/tests/3d-regression/baseline/3d-round-arrows-axes.png differ
diff --git a/tests/3d-regression/baseline/3d-segment-single.png b/tests/3d-regression/baseline/3d-segment-single.png
new file mode 100644
index 0000000..7686e32
Binary files /dev/null and b/tests/3d-regression/baseline/3d-segment-single.png differ
diff --git a/tests/3d-regression/baseline/3d-segments-star.png b/tests/3d-regression/baseline/3d-segments-star.png
new file mode 100644
index 0000000..a4d464e
Binary files /dev/null and b/tests/3d-regression/baseline/3d-segments-star.png differ
diff --git a/tests/3d-regression/baseline/3d-spheres-gizmo.png b/tests/3d-regression/baseline/3d-spheres-gizmo.png
new file mode 100644
index 0000000..d6c36be
Binary files /dev/null and b/tests/3d-regression/baseline/3d-spheres-gizmo.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-culled-stencil.png b/tests/3d-regression/baseline/3d-tdl-culled-stencil.png
new file mode 100644
index 0000000..ae9a060
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-culled-stencil.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-draw-all.png b/tests/3d-regression/baseline/3d-tdl-draw-all.png
new file mode 100644
index 0000000..bbedb00
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-draw-all.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-draw-bot.png b/tests/3d-regression/baseline/3d-tdl-draw-bot.png
new file mode 100644
index 0000000..cd924d3
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-draw-bot.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-draw-middle.png b/tests/3d-regression/baseline/3d-tdl-draw-middle.png
new file mode 100644
index 0000000..077b927
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-draw-middle.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-draw-top.png b/tests/3d-regression/baseline/3d-tdl-draw-top.png
new file mode 100644
index 0000000..f1480f3
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-draw-top.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-seg-ends-texture.png b/tests/3d-regression/baseline/3d-tdl-seg-ends-texture.png
new file mode 100644
index 0000000..558d6b4
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-seg-ends-texture.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-transparent.png b/tests/3d-regression/baseline/3d-tdl-transparent.png
new file mode 100644
index 0000000..f274ef8
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-transparent.png differ
diff --git a/tests/3d-regression/baseline/3d-tdl-zscale.png b/tests/3d-regression/baseline/3d-tdl-zscale.png
new file mode 100644
index 0000000..2f9128d
Binary files /dev/null and b/tests/3d-regression/baseline/3d-tdl-zscale.png differ
diff --git a/tests/3d-regression/baseline/3d-via-composite.png b/tests/3d-regression/baseline/3d-via-composite.png
new file mode 100644
index 0000000..b65d8e7
Binary files /dev/null and b/tests/3d-regression/baseline/3d-via-composite.png differ
diff --git a/tests/3d-regression/floors.json b/tests/3d-regression/floors.json
new file mode 100644
index 0000000..8814728
--- /dev/null
+++ b/tests/3d-regression/floors.json
@@ -0,0 +1,14 @@
+{
+ "native-self": {
+ "default": { "changedRatio": 0.001, "meanChannelGuard": 2.0 },
+ "overrides": {}
+ },
+ "webgl-vs-native": {
+ "default": { "changedRatio": 0.02, "meanChannelGuard": 4.0 },
+ "overrides": {}
+ },
+ "webgl-self": {
+ "default": { "changedRatio": 0.005, "meanChannelGuard": 2.0 },
+ "overrides": {}
+ }
+}
diff --git a/tests/3d-regression/manifest.json b/tests/3d-regression/manifest.json
new file mode 100644
index 0000000..09447e4
--- /dev/null
+++ b/tests/3d-regression/manifest.json
@@ -0,0 +1,53 @@
+{
+ "width": 800,
+ "height": 600,
+ "scenarios": [
+ "bg-gradient",
+ "bg-gradient-alpha",
+ "bounding-box",
+ "half-open-cylinder",
+ "segment-single",
+ "segments-star",
+ "round-arrow",
+ "round-arrows-axes",
+ "material-copper",
+ "material-diffuse-only",
+ "material-transparent",
+ "light-front",
+ "light-top",
+ "light-bottom",
+ "tdl-draw-top",
+ "tdl-draw-bot",
+ "tdl-draw-middle",
+ "tdl-draw-all",
+ "tdl-seg-ends-texture",
+ "tdl-culled-stencil",
+ "tdl-zscale",
+ "tdl-transparent",
+ "model3d-opaque",
+ "model3d-transparent",
+ "model3d-material-modes",
+ "model3d-bbox",
+ "spheres-gizmo",
+ "camera-persp",
+ "camera-ortho",
+ "camera-preset-views",
+ "gen-cylinder",
+ "gen-invcone",
+ "gen-disk",
+ "gen-dimple",
+ "addobj-all-shapes",
+ "post-machining",
+ "via-composite",
+ "grid-1mm",
+ "grid-2p5mm",
+ "grid-5mm",
+ "grid-10mm",
+ "layer-materials",
+ "arrow-material",
+ "create-board",
+ "redraw-empty",
+ "redraw-mini-board",
+ "redraw-mini-board-navigator"
+ ]
+}
diff --git a/tests/3d-regression/native/CMakeLists.txt b/tests/3d-regression/native/CMakeLists.txt
new file mode 100644
index 0000000..e4a79e2
--- /dev/null
+++ b/tests/3d-regression/native/CMakeLists.txt
@@ -0,0 +1,168 @@
+# Native golden-baseline generator for the 3D-renderer regression suite.
+# Compiles REAL KiCad 3D-viewer sources against desktop OpenGL (macOS 2.1
+# compatibility context via the vendored glad loader — no GLEW).
+# Modeled on tests/gal-regression/native/CMakeLists.txt.
+
+cmake_minimum_required(VERSION 3.16)
+project(scene3d_native_test)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+if(APPLE)
+ add_compile_definitions(GL_SILENCE_DEPRECATION)
+endif()
+
+# KiCad builds clipper2 with USINGZ (PUBLIC), so every TU touching clipper
+# types must agree.
+add_compile_definitions(USINGZ)
+
+# System wxWidgets (homebrew)
+set(wxWidgets_CONFIG_EXECUTABLE "/opt/homebrew/bin/wx-config")
+find_package(wxWidgets REQUIRED COMPONENTS core base gl)
+include(${wxWidgets_USE_FILE})
+
+# OpenGL.framework provides GL and GLU on macOS
+find_package(OpenGL REQUIRED)
+
+# KiCad source root
+set(KICAD_ROOT ${CMAKE_SOURCE_DIR}/../../../kicad)
+
+# Vendored glad loader (the fork's kicad_gl/kiglad.h routes to it natively)
+set(GLAD_SOURCES ${KICAD_ROOT}/thirdparty/glad/src/gl.c)
+
+# Real KiCad 3D-viewer TUs — Stage 1 (standalone, no RENDER_3D_OPENGL members).
+# Stage 2 adds render_3d_opengl.cpp / create_scene.cpp + the board_adapter /
+# settings test impls (see the suite README).
+set(KICAD_3D_SOURCES
+ ${KICAD_ROOT}/3d-viewer/common_ogl/ogl_utils.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/image.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/buffers_debug.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/color_rgba.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/track_ball.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/trackball.cpp
+ ${KICAD_ROOT}/common/gal/3d/camera.cpp
+ # display lists / plates / stencil subtraction
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/layer_triangles.cpp
+ # immediate-mode helpers (arrows, segments, bbox, half-cylinder)
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/opengl_utils.cpp
+ # 3D model VBO path + navigator gizmo
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/3d_model.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/3d_spheres_gizmo.cpp
+ # shapes2D objects the scenarios construct (ROUND_SEGMENT_2D et al.)
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/ray.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/accelerators/container_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/object_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/bbox_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/round_segment_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/filled_circle_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes3D/bbox_3d.cpp
+ # kimath bits the above reference (RotatePoint in DrawHalfOpenCylinder)
+ ${KICAD_ROOT}/libs/kimath/src/trigo.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/eda_angle.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/seg.cpp
+ ${KICAD_ROOT}/libs/kimath/src/math/util.cpp
+
+ # ---- Stage 2: RENDER_3D_OPENGL itself ----
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/render_3d_opengl.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/opengl/create_scene.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/render_3d_base.cpp
+ # remaining shapes2D types the generators take as inputs
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/ring_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/triangle_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/4pt_polygon_2d.cpp
+ ${KICAD_ROOT}/3d-viewer/3d_rendering/raytracing/shapes2D/polygon_2d.cpp
+ # kimath polygon set + triangulation (createBoard/generateLayerList)
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_poly_set.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_line_chain.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_arc.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/vertex_set.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/geometry_utils.cpp
+ ${KICAD_ROOT}/libs/kimath/src/convert_basic_shapes_to_polygon.cpp
+ ${KICAD_ROOT}/libs/kimath/src/md5_hash.cpp
+ ${KICAD_ROOT}/libs/kimath/src/bezier_curves.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/circle.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/arc_chord_params.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/corner_operations.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_collisions.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_compound.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_rect.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_nearest_points.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/half_line.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_utils.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/roundrect.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/line.cpp
+ ${KICAD_ROOT}/libs/kimath/src/geometry/shape_segment.cpp
+ ${KICAD_ROOT}/libs/kimath/src/math/vector2.cpp
+ # small common/core TUs referenced by the render TUs
+ ${KICAD_ROOT}/common/layer_id.cpp
+ ${KICAD_ROOT}/common/gal/color4d.cpp
+ ${KICAD_ROOT}/common/kicad_gl/gl_context_mgr.cpp
+ ${KICAD_ROOT}/common/lset.cpp
+ ${KICAD_ROOT}/libs/core/utf8.cpp
+ # vendored clipper2 (SHAPE_POLY_SET booleans)
+ ${KICAD_ROOT}/thirdparty/clipper2/Clipper2Lib/src/clipper.engine.cpp
+ ${KICAD_ROOT}/thirdparty/clipper2/Clipper2Lib/src/clipper.offset.cpp
+ ${KICAD_ROOT}/thirdparty/clipper2/Clipper2Lib/src/clipper.rectclip.cpp
+)
+
+set(SCENARIO_SOURCES
+ ${CMAKE_SOURCE_DIR}/../scenarios/scene3d_test_scenarios.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scene3d_test_ctx.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/test_board_data.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scenario_tier1_utils.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scenario_tier1_tdl.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scenario_tier1_model.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scenario_tier2_generators.cpp
+ ${CMAKE_SOURCE_DIR}/../scenarios/scenario_tier3_composite.cpp
+)
+
+add_executable(scene3d_native_test
+ scene3d_native_test.cpp
+ fbo_capture.cpp
+ kicad_stubs_3d.cpp
+ board_adapter_test_impl.cpp
+ settings_3d_stub.cpp
+ render3d_test_accessor.cpp
+ ${SCENARIO_SOURCES}
+ ${KICAD_3D_SOURCES}
+ ${GLAD_SOURCES}
+)
+
+target_include_directories(scene3d_native_test PRIVATE
+ ${CMAKE_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/../scenarios
+
+ # KiCad includes
+ ${KICAD_ROOT}/include # kicad_gl/, gal/3d/camera.h, plugins/3dapi/
+ ${KICAD_ROOT}/3d-viewer # "3d_rendering/...", common_ogl/, 3d_math.h
+ ${KICAD_ROOT}/3d-viewer/3d_rendering # trackball.cpp does #include
+ ${KICAD_ROOT}/3d-viewer/3d_viewer # create_scene.cpp:
+ ${KICAD_ROOT}/pcbnew # pad.h et al. (Stage 2 headers)
+ ${KICAD_ROOT}/common
+
+ # KiCad libs
+ ${KICAD_ROOT}/libs/kimath/include
+ ${KICAD_ROOT}/libs/core/include
+
+ # KiCad thirdparty
+ ${KICAD_ROOT}/thirdparty/glad/include
+ ${KICAD_ROOT}/thirdparty/clipper2/Clipper2Lib/include
+ ${KICAD_ROOT}/thirdparty/dynamic_bitset
+ ${KICAD_ROOT}/thirdparty/rtree # geometry/rtree.h (shape_poly_set.cpp)
+ ${KICAD_ROOT}/thirdparty/magic_enum/magic_enum # magic_enum.hpp (layer_id.cpp)
+ ${KICAD_ROOT}/thirdparty/expected/include # tl/expected.hpp (library_table.h)
+ ${KICAD_ROOT}/thirdparty
+
+ # Homebrew (glm)
+ /opt/homebrew/include
+)
+
+target_link_libraries(scene3d_native_test
+ ${wxWidgets_LIBRARIES}
+ OpenGL::GL
+)
+
+message(STATUS "KiCad root: ${KICAD_ROOT}")
+message(STATUS "Building scene3d_native_test with actual KiCad 3D-viewer sources")
diff --git a/tests/3d-regression/native/board_adapter_test_impl.cpp b/tests/3d-regression/native/board_adapter_test_impl.cpp
new file mode 100644
index 0000000..76e13a5
--- /dev/null
+++ b/tests/3d-regression/native/board_adapter_test_impl.cpp
@@ -0,0 +1,521 @@
+/**
+ * Test-harness definitions of BOARD_ADAPTER's out-of-line members.
+ *
+ * The real board_adapter.cpp / create_layer_items.cpp drag the whole pcbnew
+ * board model + settings machinery in — none of which exists in this harness.
+ * Instead WE define the declared members: an out-of-line definition of a
+ * declared member function has full private access, so this TU is both the
+ * stub layer and the synthetic-board-data injection seam (InitSettings).
+ *
+ * Bodies marked "verbatim" are copied from board_adapter.cpp and must behave
+ * identically; bodies marked "test" are simplified board-less variants (any
+ * behavioral drift shows up as a baseline change and is reviewed there).
+ */
+
+#include "kicad_stubs_3d.h"
+
+#include "3d_canvas/board_adapter.h"
+#include "3d_rendering/raytracing/shapes2D/filled_circle_2d.h"
+#include "3d_rendering/raytracing/shapes2D/round_segment_2d.h"
+#include "3d_viewer/eda_3d_viewer_settings.h"
+
+#include
+#include
+#include // GetArcToSegmentCount
+
+// Same values as board_adapter.cpp:51-57 (defined there as TU-local macros).
+#define DEFAULT_BOARD_THICKNESS pcbIUScale.mmToIU( 1.6 )
+#define DEFAULT_COPPER_THICKNESS pcbIUScale.mmToIU( 0.035 )
+#define DEFAULT_TECH_LAYER_THICKNESS pcbIUScale.mmToIU( 0.025 )
+#define SOLDERPASTE_LAYER_THICKNESS pcbIUScale.mmToIU( 0.04 )
+
+#include "../scenarios/test_board_data.h"
+
+// Statics (board_adapter.cpp:60-89, verbatim).
+CUSTOM_COLORS_LIST BOARD_ADAPTER::g_SilkColors;
+CUSTOM_COLORS_LIST BOARD_ADAPTER::g_MaskColors;
+CUSTOM_COLORS_LIST BOARD_ADAPTER::g_PasteColors;
+CUSTOM_COLORS_LIST BOARD_ADAPTER::g_FinishColors;
+CUSTOM_COLORS_LIST BOARD_ADAPTER::g_BoardColors;
+
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultBackgroundTop;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultBackgroundBot;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultSilkscreen;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultSolderMask;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultSolderPaste;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultSurfaceFinish;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultBoardBody;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultComments;
+KIGFX::COLOR4D BOARD_ADAPTER::g_DefaultECOs;
+
+const wxChar* BOARD_ADAPTER::m_logTrace = wxT( "KI_TRACE_EDA_CINFO3D_VISU" );
+
+// The raytracer bevel global lives in board_adapter.cpp too.
+float g_BevelThickness3DU = 0.0f;
+
+
+// test: same default block as the real ctor (board_adapter.cpp:92-157) minus
+// ReloadColorSettings() (our version below is settings-free) and the custom
+// stackup color tables (not needed — GetLayerColors below is default-based).
+BOARD_ADAPTER::BOARD_ADAPTER() :
+ m_Cfg( nullptr ),
+ m_IsBoardView( true ),
+ m_MousewheelPanning( true ),
+ m_IsPreviewer( false ),
+ m_board( nullptr ),
+ m_3dModelManager( nullptr ),
+ m_layerZcoordTop(),
+ m_layerZcoordBottom()
+{
+ m_boardPos = VECTOR2I();
+ m_boardSize = VECTOR2I();
+ m_boardCenter = SFVEC3F( 0.0f );
+
+ m_boardBoundingBox.Reset();
+
+ m_TH_IDs.Clear();
+ m_TH_ODs.Clear();
+ m_viaAnnuli.Clear();
+
+ m_copperLayersCount = 2;
+
+ m_biuTo3Dunits = 1.0;
+ m_boardBodyThickness3DU = DEFAULT_BOARD_THICKNESS * m_biuTo3Dunits;
+ m_frontCopperThickness3DU = DEFAULT_COPPER_THICKNESS * m_biuTo3Dunits;
+ m_backCopperThickness3DU = DEFAULT_COPPER_THICKNESS * m_biuTo3Dunits;
+ m_nonCopperLayerThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_frontMaskThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_backMaskThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_solderPasteLayerThickness3DU = SOLDERPASTE_LAYER_THICKNESS * m_biuTo3Dunits;
+
+ m_trackCount = 0;
+ m_viaCount = 0;
+ m_averageViaHoleDiameter = 0.0f;
+ m_holeCount = 0;
+ m_averageHoleDiameter = 0.0f;
+ m_averageTrackWidth = 0.0f;
+
+ m_BgColorBot = SFVEC4F( 0.4, 0.4, 0.5, 1.0 );
+ m_BgColorTop = SFVEC4F( 0.8, 0.8, 0.9, 1.0 );
+ m_BoardBodyColor = SFVEC4F( 0.4, 0.4, 0.5, 0.9 );
+ m_SolderMaskColorTop = SFVEC4F( 0.1, 0.2, 0.1, 0.83 );
+ m_SolderMaskColorBot = SFVEC4F( 0.1, 0.2, 0.1, 0.83 );
+ m_SolderPasteColor = SFVEC4F( 0.4, 0.4, 0.4, 1.0 );
+ m_SilkScreenColorTop = SFVEC4F( 0.9, 0.9, 0.9, 1.0 );
+ m_SilkScreenColorBot = SFVEC4F( 0.9, 0.9, 0.9, 1.0 );
+ m_CopperColor = SFVEC4F( 0.75, 0.61, 0.23, 1.0 );
+ m_UserDrawingsColor = SFVEC4F( 0.85, 0.85, 0.85, 1.0 );
+ m_UserCommentsColor = SFVEC4F( 0.85, 0.85, 0.85, 1.0 );
+ m_ECO1Color = SFVEC4F( 0.70, 0.10, 0.10, 1.0 );
+ m_ECO2Color = SFVEC4F( 0.70, 0.10, 0.10, 1.0 );
+
+ for( int ii = 0; ii < 45; ++ii )
+ m_UserDefinedLayerColor[ii] = SFVEC4F( 0.70, 0.10, 0.10, 1.0 );
+
+ m_platedPadsFront = nullptr;
+ m_platedPadsBack = nullptr;
+ m_offboardPadsFront = nullptr;
+ m_offboardPadsBack = nullptr;
+
+ m_frontPlatedCopperPolys = nullptr;
+ m_backPlatedCopperPolys = nullptr;
+
+ ReloadColorSettings();
+
+ g_DefaultBackgroundTop = COLOR4D( 0.80, 0.80, 0.90, 1.0 );
+ g_DefaultBackgroundBot = COLOR4D( 0.40, 0.40, 0.50, 1.0 );
+ g_DefaultSilkscreen = COLOR4D( 0.94, 0.94, 0.94, 1.0 );
+ g_DefaultSolderMask = COLOR4D( 0.08, 0.20, 0.14, 0.83 );
+ g_DefaultSolderPaste = COLOR4D( 0.50, 0.50, 0.50, 1.0 );
+ g_DefaultSurfaceFinish = COLOR4D( 0.75, 0.61, 0.23, 1.0 );
+ g_DefaultBoardBody = COLOR4D( 0.4, 0.4, 0.5, 0.9 );
+ g_DefaultComments = COLOR4D( 0.85, 0.85, 0.85, 1.0 );
+ g_DefaultECOs = COLOR4D( 0.70, 0.10, 0.10, 1.0 );
+}
+
+
+BOARD_ADAPTER::~BOARD_ADAPTER()
+{
+ destroyLayers();
+}
+
+
+// test: frees exactly what our InitSettings allocates.
+void BOARD_ADAPTER::destroyLayers()
+{
+ for( auto& [layer, container] : m_layerMap )
+ delete container;
+
+ m_layerMap.clear();
+
+ for( auto& [layer, container] : m_layerHoleMap )
+ delete container;
+
+ m_layerHoleMap.clear();
+
+ for( auto& [layer, poly] : m_layers_poly )
+ delete poly;
+
+ m_layers_poly.clear();
+
+ for( auto& [layer, poly] : m_layerHoleOdPolys )
+ delete poly;
+
+ m_layerHoleOdPolys.clear();
+
+ for( auto& [layer, poly] : m_layerHoleIdPolys )
+ delete poly;
+
+ m_layerHoleIdPolys.clear();
+
+ delete m_platedPadsFront;
+ delete m_platedPadsBack;
+ delete m_offboardPadsFront;
+ delete m_offboardPadsBack;
+ m_platedPadsFront = m_platedPadsBack = nullptr;
+ m_offboardPadsFront = m_offboardPadsBack = nullptr;
+
+ delete m_frontPlatedCopperPolys;
+ delete m_backPlatedCopperPolys;
+ m_frontPlatedCopperPolys = nullptr;
+ m_backPlatedCopperPolys = nullptr;
+
+ m_TH_ODs.Clear();
+ m_TH_IDs.Clear();
+ m_viaAnnuli.Clear();
+ m_viaTH_ODs.Clear();
+
+ m_board_poly.RemoveAllContours();
+ m_TH_ODPolys.RemoveAllContours();
+ m_NPTH_ODPolys.RemoveAllContours();
+ m_viaTH_ODPolys.RemoveAllContours();
+ m_viaAnnuliPolys.RemoveAllContours();
+}
+
+
+// test: settings-free — the board-editor color table gets a neutral default
+// (the real one loads COLOR_SETTINGS; scenarios don't use per-PCB-layer colors).
+void BOARD_ADAPTER::ReloadColorSettings() noexcept
+{
+ for( int layer = F_Cu; layer < PCB_LAYER_ID_COUNT; ++layer )
+ m_BoardEditorColors[layer] = COLOR4D( 0.75, 0.61, 0.23, 1.0 );
+}
+
+
+// verbatim (board_adapter.cpp:243-288) minus the m_board branches (no board).
+bool BOARD_ADAPTER::Is3dLayerEnabled( PCB_LAYER_ID aLayer,
+ const std::bitset& aVisibilityFlags ) const
+{
+ wxASSERT( aLayer < PCB_LAYER_ID_COUNT );
+
+ switch( aLayer )
+ {
+ case B_Cu: return aVisibilityFlags.test( LAYER_3D_COPPER_BOTTOM );
+ case F_Cu: return aVisibilityFlags.test( LAYER_3D_COPPER_TOP );
+ case B_Adhes: return aVisibilityFlags.test( LAYER_3D_ADHESIVE );
+ case F_Adhes: return aVisibilityFlags.test( LAYER_3D_ADHESIVE );
+ case B_Paste: return aVisibilityFlags.test( LAYER_3D_SOLDERPASTE );
+ case F_Paste: return aVisibilityFlags.test( LAYER_3D_SOLDERPASTE );
+ case B_SilkS: return aVisibilityFlags.test( LAYER_3D_SILKSCREEN_BOTTOM );
+ case F_SilkS: return aVisibilityFlags.test( LAYER_3D_SILKSCREEN_TOP );
+ case B_Mask: return aVisibilityFlags.test( LAYER_3D_SOLDERMASK_BOTTOM );
+ case F_Mask: return aVisibilityFlags.test( LAYER_3D_SOLDERMASK_TOP );
+ case Dwgs_User: return aVisibilityFlags.test( LAYER_3D_USER_DRAWINGS );
+ case Cmts_User: return aVisibilityFlags.test( LAYER_3D_USER_COMMENTS );
+ case Eco1_User: return aVisibilityFlags.test( LAYER_3D_USER_ECO1 );
+ case Eco2_User: return aVisibilityFlags.test( LAYER_3D_USER_ECO2 );
+ default:
+ return false; // test: no board -> unmapped layers hidden
+ }
+}
+
+
+// test: previews/boards not modeled — footprints always "shown".
+bool BOARD_ADAPTER::IsFootprintShown( const FOOTPRINT* aFootprint ) const
+{
+ return aFootprint != nullptr;
+}
+
+
+// verbatim no-board branch (board_adapter.cpp:315-320).
+int BOARD_ADAPTER::GetHolePlatingThickness() const noexcept
+{
+ return DEFAULT_COPPER_THICKNESS;
+}
+
+
+// verbatim (board_adapter.cpp:322-328).
+unsigned int BOARD_ADAPTER::GetCircleSegmentCount( float aDiameter3DU ) const
+{
+ wxASSERT( aDiameter3DU > 0.0f );
+
+ return GetCircleSegmentCount( (int) ( aDiameter3DU / m_biuTo3Dunits ) );
+}
+
+
+// test: like board_adapter.cpp:330-336 with the BOARD_DESIGN_SETTINGS default
+// max error (ARC_HIGH_DEF) instead of a live board's setting.
+unsigned int BOARD_ADAPTER::GetCircleSegmentCount( int aDiameterBIU ) const
+{
+ wxASSERT( aDiameterBIU > 0 );
+
+ return GetArcToSegmentCount( aDiameterBIU / 2, ARC_HIGH_DEF, FULL_CIRCLE );
+}
+
+
+// verbatim non-previewer path (board_adapter.cpp:808-905) minus FOLLOW_PCB
+// (needs a board).
+std::bitset BOARD_ADAPTER::GetVisibleLayers() const
+{
+ std::bitset ret;
+
+ ret.set( LAYER_3D_BOARD, m_Cfg->m_Render.show_board_body );
+ ret.set( LAYER_3D_PLATED_BARRELS, m_Cfg->m_Render.show_plated_barrels );
+ ret.set( LAYER_3D_COPPER_TOP, m_Cfg->m_Render.show_copper_top );
+ ret.set( LAYER_3D_COPPER_BOTTOM, m_Cfg->m_Render.show_copper_bottom );
+ ret.set( LAYER_3D_SILKSCREEN_TOP, m_Cfg->m_Render.show_silkscreen_top );
+ ret.set( LAYER_3D_SILKSCREEN_BOTTOM, m_Cfg->m_Render.show_silkscreen_bottom );
+ ret.set( LAYER_3D_SOLDERMASK_TOP, m_Cfg->m_Render.show_soldermask_top );
+ ret.set( LAYER_3D_SOLDERMASK_BOTTOM, m_Cfg->m_Render.show_soldermask_bottom );
+ ret.set( LAYER_3D_SOLDERPASTE, m_Cfg->m_Render.show_solderpaste );
+ ret.set( LAYER_3D_ADHESIVE, m_Cfg->m_Render.show_adhesive );
+ ret.set( LAYER_3D_USER_COMMENTS, m_Cfg->m_Render.show_comments );
+ ret.set( LAYER_3D_USER_DRAWINGS, m_Cfg->m_Render.show_drawings );
+ ret.set( LAYER_3D_USER_ECO1, m_Cfg->m_Render.show_eco1 );
+ ret.set( LAYER_3D_USER_ECO2, m_Cfg->m_Render.show_eco2 );
+
+ for( int layer = LAYER_3D_USER_1; layer <= LAYER_3D_USER_45; ++layer )
+ ret.set( layer, m_Cfg->m_Render.show_user[layer - LAYER_3D_USER_1] );
+
+ ret.set( LAYER_FP_REFERENCES, m_Cfg->m_Render.show_fp_references );
+ ret.set( LAYER_FP_VALUES, m_Cfg->m_Render.show_fp_values );
+ ret.set( LAYER_FP_TEXT, m_Cfg->m_Render.show_fp_text );
+
+ ret.set( LAYER_3D_TH_MODELS, m_Cfg->m_Render.show_footprints_normal );
+ ret.set( LAYER_3D_SMD_MODELS, m_Cfg->m_Render.show_footprints_insert );
+ ret.set( LAYER_3D_VIRTUAL_MODELS, m_Cfg->m_Render.show_footprints_virtual );
+ ret.set( LAYER_3D_MODELS_NOT_IN_POS, m_Cfg->m_Render.show_footprints_not_in_posfile );
+ ret.set( LAYER_3D_MODELS_MARKED_DNP, m_Cfg->m_Render.show_footprints_dnp );
+
+ ret.set( LAYER_3D_BOUNDING_BOXES, m_Cfg->m_Render.show_model_bbox );
+ ret.set( LAYER_3D_OFF_BOARD_SILK, m_Cfg->m_Render.show_off_board_silk );
+ ret.set( LAYER_3D_NAVIGATOR, m_Cfg->m_Render.show_navigator );
+
+ return ret;
+}
+
+
+// test: no board -> board-editor copper colors never apply.
+bool BOARD_ADAPTER::GetUseBoardEditorCopperLayerColors() const
+{
+ return false;
+}
+
+
+// verbatim (board_adapter.cpp:1039-1053).
+float BOARD_ADAPTER::GetFootprintZPos( bool aIsFlipped ) const
+{
+ if( aIsFlipped )
+ {
+ if( auto it = m_layerZcoordBottom.find( B_Paste ); it != m_layerZcoordBottom.end() )
+ return it->second;
+ }
+ else
+ {
+ if( auto it = m_layerZcoordTop.find( F_Paste ); it != m_layerZcoordTop.end() )
+ return it->second;
+ }
+
+ return 0.0;
+}
+
+
+// verbatim (board_adapter.cpp:1056-1070); user-layer remap dropped (unused here).
+SFVEC4F BOARD_ADAPTER::GetLayerColor( int aLayerId ) const
+{
+ wxASSERT( aLayerId < PCB_LAYER_ID_COUNT );
+
+ return GetColor( m_BoardEditorColors.at( aLayerId ) );
+}
+
+
+SFVEC4F BOARD_ADAPTER::GetColor( const COLOR4D& aColor ) const
+{
+ return SFVEC4F( aColor.r, aColor.g, aColor.b, aColor.a );
+}
+
+
+// test: default-color scheme only (no COLOR_SETTINGS machinery).
+std::map BOARD_ADAPTER::GetDefaultColors() const
+{
+ std::map colors;
+
+ colors[LAYER_3D_BACKGROUND_TOP] = g_DefaultBackgroundTop;
+ colors[LAYER_3D_BACKGROUND_BOTTOM] = g_DefaultBackgroundBot;
+ colors[LAYER_3D_BOARD] = g_DefaultBoardBody;
+ colors[LAYER_3D_COPPER_TOP] = g_DefaultSurfaceFinish;
+ colors[LAYER_3D_COPPER_BOTTOM] = g_DefaultSurfaceFinish;
+ colors[LAYER_3D_SILKSCREEN_TOP] = g_DefaultSilkscreen;
+ colors[LAYER_3D_SILKSCREEN_BOTTOM] = g_DefaultSilkscreen;
+ colors[LAYER_3D_SOLDERMASK_TOP] = g_DefaultSolderMask;
+ colors[LAYER_3D_SOLDERMASK_BOTTOM] = g_DefaultSolderMask;
+ colors[LAYER_3D_SOLDERPASTE] = g_DefaultSolderPaste;
+ colors[LAYER_3D_USER_DRAWINGS] = g_DefaultComments;
+ colors[LAYER_3D_USER_COMMENTS] = g_DefaultComments;
+ colors[LAYER_3D_USER_ECO1] = g_DefaultECOs;
+ colors[LAYER_3D_USER_ECO2] = g_DefaultECOs;
+
+ return colors;
+}
+
+
+std::map BOARD_ADAPTER::GetLayerColors() const
+{
+ return GetDefaultColors();
+}
+
+
+// ---------------------------------------------------------------------------
+// THE SEAM: synthetic test-board data instead of a real BOARD.
+//
+// A 40 x 30 mm two-layer board. Layer Z stacking follows the real
+// InitSettings maths (board body centered on Z=0, copper plated on top/bottom,
+// tech layers above copper). All values in BIU (nm) scaled by m_biuTo3Dunits.
+// ---------------------------------------------------------------------------
+void BOARD_ADAPTER::InitSettings( REPORTER* aStatusReporter, REPORTER* aWarningReporter )
+{
+ (void) aStatusReporter;
+ (void) aWarningReporter;
+
+ destroyLayers();
+
+ const int boardW = pcbIUScale.mmToIU( 40 );
+ const int boardH = pcbIUScale.mmToIU( 30 );
+
+ m_boardSize = VECTOR2I( boardW, boardH );
+ m_boardPos = VECTOR2I( 0, 0 );
+ m_copperLayersCount = 2;
+
+ // Same scale maths as the real InitSettings (board_adapter.cpp:382-387):
+ // no BOARD -> the "footprint holder" zoom hack applies.
+ m_biuTo3Dunits = RANGE_SCALE_3D / std::max( m_boardSize.x, m_boardSize.y );
+ m_biuTo3Dunits *= 1.6f;
+
+ m_boardBodyThickness3DU = DEFAULT_BOARD_THICKNESS * m_biuTo3Dunits;
+ m_frontCopperThickness3DU = DEFAULT_COPPER_THICKNESS * m_biuTo3Dunits;
+ m_backCopperThickness3DU = DEFAULT_COPPER_THICKNESS * m_biuTo3Dunits;
+ m_nonCopperLayerThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_frontMaskThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_backMaskThickness3DU = DEFAULT_TECH_LAYER_THICKNESS * m_biuTo3Dunits;
+ m_solderPasteLayerThickness3DU = SOLDERPASTE_LAYER_THICKNESS * m_biuTo3Dunits;
+
+ // Layer Z coordinates (board body spans -body/2 .. +body/2).
+ const float bodyTop = m_boardBodyThickness3DU / 2.0f;
+ const float bodyBot = -m_boardBodyThickness3DU / 2.0f;
+
+ m_layerZcoordBottom[F_Cu] = bodyTop;
+ m_layerZcoordTop[F_Cu] = bodyTop + m_frontCopperThickness3DU;
+ m_layerZcoordBottom[B_Cu] = bodyBot;
+ m_layerZcoordTop[B_Cu] = bodyBot - m_backCopperThickness3DU;
+
+ m_layerZcoordBottom[F_Mask] = m_layerZcoordTop[F_Cu];
+ m_layerZcoordTop[F_Mask] = m_layerZcoordBottom[F_Mask] + m_frontMaskThickness3DU;
+ m_layerZcoordBottom[B_Mask] = m_layerZcoordTop[B_Cu];
+ m_layerZcoordTop[B_Mask] = m_layerZcoordBottom[B_Mask] - m_backMaskThickness3DU;
+
+ m_layerZcoordBottom[F_SilkS] = m_layerZcoordTop[F_Mask];
+ m_layerZcoordTop[F_SilkS] = m_layerZcoordBottom[F_SilkS] + m_nonCopperLayerThickness3DU;
+ m_layerZcoordBottom[B_SilkS] = m_layerZcoordTop[B_Mask];
+ m_layerZcoordTop[B_SilkS] = m_layerZcoordBottom[B_SilkS] - m_nonCopperLayerThickness3DU;
+
+ m_layerZcoordBottom[F_Paste] = m_layerZcoordTop[F_Cu];
+ m_layerZcoordTop[F_Paste] = m_layerZcoordBottom[F_Paste] + m_solderPasteLayerThickness3DU;
+ m_layerZcoordBottom[B_Paste] = m_layerZcoordTop[B_Cu];
+ m_layerZcoordTop[B_Paste] = m_layerZcoordBottom[B_Paste] - m_solderPasteLayerThickness3DU;
+
+ m_boardCenter = SFVEC3F( 0.0f, 0.0f, 0.0f );
+
+ m_boardBoundingBox.Set( SFVEC3F( -boardW / 2 * m_biuTo3Dunits, -boardH / 2 * m_biuTo3Dunits,
+ bodyBot ),
+ SFVEC3F( boardW / 2 * m_biuTo3Dunits, boardH / 2 * m_biuTo3Dunits,
+ bodyTop ) );
+
+ // Board outline: plain rectangle.
+ m_board_poly.RemoveAllContours();
+ m_board_poly.NewOutline();
+ m_board_poly.Append( -boardW / 2, -boardH / 2 );
+ m_board_poly.Append( boardW / 2, -boardH / 2 );
+ m_board_poly.Append( boardW / 2, boardH / 2 );
+ m_board_poly.Append( -boardW / 2, boardH / 2 );
+ m_board_poly.Outline( 0 ).SetClosed( true );
+
+ // Copper: a few tracks + round pads per side; silkscreen: a frame; the
+ // BVH containers own the objects (they delete them).
+ auto addTrack = [&]( BVH_CONTAINER_2D* aDst, double aX1mm, double aY1mm, double aX2mm,
+ double aY2mm, double aWidthMm )
+ {
+ aDst->Add( new ROUND_SEGMENT_2D(
+ SFVEC2F( pcbIUScale.mmToIU( aX1mm ) * m_biuTo3Dunits,
+ pcbIUScale.mmToIU( aY1mm ) * m_biuTo3Dunits ),
+ SFVEC2F( pcbIUScale.mmToIU( aX2mm ) * m_biuTo3Dunits,
+ pcbIUScale.mmToIU( aY2mm ) * m_biuTo3Dunits ),
+ pcbIUScale.mmToIU( aWidthMm ) * m_biuTo3Dunits, DummyBoardItem() ) );
+ };
+
+ auto addCircle = [&]( BVH_CONTAINER_2D* aDst, double aXmm, double aYmm, double aRmm )
+ {
+ aDst->Add( new FILLED_CIRCLE_2D( SFVEC2F( pcbIUScale.mmToIU( aXmm ) * m_biuTo3Dunits,
+ pcbIUScale.mmToIU( aYmm ) * m_biuTo3Dunits ),
+ pcbIUScale.mmToIU( aRmm ) * m_biuTo3Dunits,
+ DummyBoardItem() ) );
+ };
+
+ BVH_CONTAINER_2D* frontCu = new BVH_CONTAINER_2D;
+ addTrack( frontCu, -15, -10, 15, -10, 1.0 );
+ addTrack( frontCu, -15, -10, -15, 10, 1.0 );
+ addTrack( frontCu, -15, 10, 0, 10, 0.6 );
+ addTrack( frontCu, 0, 10, 8, 2, 0.6 );
+ addCircle( frontCu, -15, -10, 1.5 );
+ addCircle( frontCu, 15, -10, 1.5 );
+ addCircle( frontCu, 8, 2, 1.2 );
+ frontCu->BuildBVH();
+ m_layerMap[F_Cu] = frontCu;
+
+ BVH_CONTAINER_2D* backCu = new BVH_CONTAINER_2D;
+ addTrack( backCu, 15, -10, 15, 10, 1.2 );
+ addTrack( backCu, 15, 10, -8, 10, 1.2 );
+ addCircle( backCu, -15, -10, 1.5 );
+ addCircle( backCu, 15, -10, 1.5 );
+ backCu->BuildBVH();
+ m_layerMap[B_Cu] = backCu;
+
+ BVH_CONTAINER_2D* frontSilk = new BVH_CONTAINER_2D;
+ addTrack( frontSilk, -17, -13, 17, -13, 0.3 );
+ addTrack( frontSilk, 17, -13, 17, 13, 0.3 );
+ addTrack( frontSilk, 17, 13, -17, 13, 0.3 );
+ addTrack( frontSilk, -17, 13, -17, -13, 0.3 );
+ addCircle( frontSilk, -12, 6, 0.8 );
+ frontSilk->BuildBVH();
+ m_layerMap[F_SilkS] = frontSilk;
+
+ // Through holes: the two big pads are plated through.
+ auto addHolePoly = [&]( SHAPE_POLY_SET& aPolys, double aXmm, double aYmm, double aRmm )
+ {
+ TransformCircleToPolygon( aPolys,
+ VECTOR2I( pcbIUScale.mmToIU( aXmm ), pcbIUScale.mmToIU( aYmm ) ),
+ pcbIUScale.mmToIU( aRmm ), ARC_HIGH_DEF, ERROR_INSIDE );
+ };
+
+ addCircle( &m_TH_ODs, -15, -10, 0.8 );
+ addCircle( &m_TH_ODs, 15, -10, 0.8 );
+ m_TH_ODs.BuildBVH();
+
+ addCircle( &m_TH_IDs, -15, -10, 0.65 );
+ addCircle( &m_TH_IDs, 15, -10, 0.65 );
+ m_TH_IDs.BuildBVH();
+
+ addHolePoly( m_TH_ODPolys, -15, -10, 0.8 );
+ addHolePoly( m_TH_ODPolys, 15, -10, 0.8 );
+
+ m_TH_ODPolys.Simplify();
+}
diff --git a/tests/3d-regression/native/config.h b/tests/3d-regression/native/config.h
new file mode 100644
index 0000000..88a3f7a
--- /dev/null
+++ b/tests/3d-regression/native/config.h
@@ -0,0 +1,26 @@
+// Minimal config.h for native GAL test
+// Based on KiCad's generated config
+#ifndef KICAD_CONFIG_H
+#define KICAD_CONFIG_H
+
+// Version info
+#define KICAD_MAJOR_VERSION 8
+#define KICAD_MINOR_VERSION 0
+#define KICAD_PATCH_VERSION 0
+
+// Enable OpenGL
+#define KICAD_USE_OCC 0
+#define KICAD_USE_EGL 0
+
+// Platform detection
+#ifdef __APPLE__
+#define KICAD_MACOS 1
+#endif
+
+// Math
+#define KICAD_USE_STDROUND 1
+
+// Ensure types are properly sized
+#include
+
+#endif // KICAD_CONFIG_H
diff --git a/tests/3d-regression/native/fbo_capture.cpp b/tests/3d-regression/native/fbo_capture.cpp
new file mode 100644
index 0000000..06e0043
--- /dev/null
+++ b/tests/3d-regression/native/fbo_capture.cpp
@@ -0,0 +1,146 @@
+#include "fbo_capture.h"
+
+#include
+
+// Core-vs-EXT selection: Apple's 2.1 context exports the ARB entry points on
+// all modern renderers, but keep the EXT fallback the compositor path proves
+// works (see plan §7 risk 2). Enum values are shared between core and EXT.
+static PFNGLGENFRAMEBUFFERSPROC s_glGenFramebuffers = nullptr;
+static PFNGLBINDFRAMEBUFFERPROC s_glBindFramebuffer = nullptr;
+static PFNGLFRAMEBUFFERTEXTURE2DPROC s_glFramebufferTexture2D = nullptr;
+static PFNGLGENRENDERBUFFERSPROC s_glGenRenderbuffers = nullptr;
+static PFNGLBINDRENDERBUFFERPROC s_glBindRenderbuffer = nullptr;
+static PFNGLRENDERBUFFERSTORAGEPROC s_glRenderbufferStorage = nullptr;
+static PFNGLFRAMEBUFFERRENDERBUFFERPROC s_glFramebufferRenderbuffer = nullptr;
+static PFNGLCHECKFRAMEBUFFERSTATUSPROC s_glCheckFramebufferStatus = nullptr;
+static PFNGLDELETEFRAMEBUFFERSPROC s_glDeleteFramebuffers = nullptr;
+static PFNGLDELETERENDERBUFFERSPROC s_glDeleteRenderbuffers = nullptr;
+
+static bool resolveFboEntryPoints()
+{
+ if( s_glGenFramebuffers )
+ return true;
+
+ if( glad_glGenFramebuffers )
+ {
+ s_glGenFramebuffers = glad_glGenFramebuffers;
+ s_glBindFramebuffer = glad_glBindFramebuffer;
+ s_glFramebufferTexture2D = glad_glFramebufferTexture2D;
+ s_glGenRenderbuffers = glad_glGenRenderbuffers;
+ s_glBindRenderbuffer = glad_glBindRenderbuffer;
+ s_glRenderbufferStorage = glad_glRenderbufferStorage;
+ s_glFramebufferRenderbuffer = glad_glFramebufferRenderbuffer;
+ s_glCheckFramebufferStatus = glad_glCheckFramebufferStatus;
+ s_glDeleteFramebuffers = glad_glDeleteFramebuffers;
+ s_glDeleteRenderbuffers = glad_glDeleteRenderbuffers;
+ return true;
+ }
+
+ if( glad_glGenFramebuffersEXT )
+ {
+ std::fprintf( stderr, "[fbo] core FBO entry points missing; using EXT fallback\n" );
+ s_glGenFramebuffers = glad_glGenFramebuffersEXT;
+ s_glBindFramebuffer = glad_glBindFramebufferEXT;
+ s_glFramebufferTexture2D = glad_glFramebufferTexture2DEXT;
+ s_glGenRenderbuffers = glad_glGenRenderbuffersEXT;
+ s_glBindRenderbuffer = glad_glBindRenderbufferEXT;
+ s_glRenderbufferStorage = glad_glRenderbufferStorageEXT;
+ s_glFramebufferRenderbuffer = glad_glFramebufferRenderbufferEXT;
+ s_glCheckFramebufferStatus = glad_glCheckFramebufferStatusEXT;
+ s_glDeleteFramebuffers = glad_glDeleteFramebuffersEXT;
+ s_glDeleteRenderbuffers = glad_glDeleteRenderbuffersEXT;
+ return true;
+ }
+
+ std::fprintf( stderr, "[fbo] no framebuffer object support in this context\n" );
+ return false;
+}
+
+
+bool FBO_CAPTURE::Create( int aWidth, int aHeight )
+{
+ if( !resolveFboEntryPoints() )
+ return false;
+
+ m_width = aWidth;
+ m_height = aHeight;
+
+ // Same sequence as EDA_3D_CANVAS::RenderToFrameBuffer (eda_3d_canvas.cpp:736-772).
+ s_glGenFramebuffers( 1, &m_fbo );
+ s_glBindFramebuffer( GL_FRAMEBUFFER, m_fbo );
+
+ glGenTextures( 1, &m_colorTexture );
+ glBindTexture( GL_TEXTURE_2D, m_colorTexture );
+ glTexImage2D( GL_TEXTURE_2D, 0, GL_RGBA8, aWidth, aHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+ nullptr );
+ glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
+ glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR );
+ glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE );
+ glTexParameteri( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE );
+ s_glFramebufferTexture2D( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
+ m_colorTexture, 0 );
+ glBindTexture( GL_TEXTURE_2D, 0 );
+
+ s_glGenRenderbuffers( 1, &m_depthStencil );
+ s_glBindRenderbuffer( GL_RENDERBUFFER, m_depthStencil );
+ s_glRenderbufferStorage( GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, aWidth, aHeight );
+ s_glFramebufferRenderbuffer( GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER,
+ m_depthStencil );
+
+ const GLenum status = s_glCheckFramebufferStatus( GL_FRAMEBUFFER );
+
+ if( status != GL_FRAMEBUFFER_COMPLETE )
+ {
+ std::fprintf( stderr, "[fbo] framebuffer incomplete: 0x%04X\n", status );
+ Destroy();
+ return false;
+ }
+
+ return true;
+}
+
+
+void FBO_CAPTURE::Bind()
+{
+ s_glBindFramebuffer( GL_FRAMEBUFFER, m_fbo );
+ glViewport( 0, 0, m_width, m_height );
+}
+
+
+bool FBO_CAPTURE::ReadPixels( std::vector& aOut )
+{
+ if( !m_fbo )
+ return false;
+
+ s_glBindFramebuffer( GL_FRAMEBUFFER, m_fbo );
+ glFinish();
+
+ aOut.resize( static_cast( m_width ) * m_height * 4 );
+ glPixelStorei( GL_PACK_ALIGNMENT, 1 );
+ glReadPixels( 0, 0, m_width, m_height, GL_RGBA, GL_UNSIGNED_BYTE, aOut.data() );
+
+ const GLenum err = glGetError();
+
+ if( err != GL_NO_ERROR )
+ {
+ std::fprintf( stderr, "[fbo] glReadPixels error: 0x%04X\n", err );
+ return false;
+ }
+
+ return true;
+}
+
+
+void FBO_CAPTURE::Destroy()
+{
+ if( m_fbo )
+ s_glDeleteFramebuffers( 1, &m_fbo );
+
+ if( m_colorTexture )
+ glDeleteTextures( 1, &m_colorTexture );
+
+ if( m_depthStencil )
+ s_glDeleteRenderbuffers( 1, &m_depthStencil );
+
+ m_fbo = m_colorTexture = m_depthStencil = 0;
+}
diff --git a/tests/3d-regression/native/fbo_capture.h b/tests/3d-regression/native/fbo_capture.h
new file mode 100644
index 0000000..ec41656
--- /dev/null
+++ b/tests/3d-regression/native/fbo_capture.h
@@ -0,0 +1,46 @@
+/**
+ * Offscreen FBO for deterministic fixed-size capture, mirroring
+ * EDA_3D_CANVAS::RenderToFrameBuffer (eda_3d_canvas.cpp:736-772):
+ * GL_RGBA8 color texture + GL_DEPTH24_STENCIL8 renderbuffer on
+ * GL_DEPTH_STENCIL_ATTACHMENT (the stencil is required by
+ * OPENGL_RENDER_LIST::DrawCulled hole cutting).
+ *
+ * Rendering into an FBO makes the output independent of window size and
+ * Retina scaling — pixels are exactly aWidth x aHeight.
+ */
+
+#ifndef FBO_CAPTURE_H
+#define FBO_CAPTURE_H
+
+#include
+
+#include
+#include
+
+class FBO_CAPTURE
+{
+public:
+ /// Create the FBO; returns false if the framebuffer is incomplete.
+ /// Falls back to the EXT entry points if the core ARB ones didn't load
+ /// (Apple GL 2.1 exposes both; glad loads by symbol name).
+ bool Create( int aWidth, int aHeight );
+
+ void Bind();
+
+ /// glFinish + glReadPixels(GL_RGBA); aOut is resized to w*h*4.
+ bool ReadPixels( std::vector& aOut );
+
+ void Destroy();
+
+ int Width() const { return m_width; }
+ int Height() const { return m_height; }
+
+private:
+ int m_width = 0;
+ int m_height = 0;
+ GLuint m_fbo = 0;
+ GLuint m_colorTexture = 0;
+ GLuint m_depthStencil = 0;
+};
+
+#endif // FBO_CAPTURE_H
diff --git a/tests/3d-regression/native/kicad_stubs_3d.cpp b/tests/3d-regression/native/kicad_stubs_3d.cpp
new file mode 100644
index 0000000..f729524
--- /dev/null
+++ b/tests/3d-regression/native/kicad_stubs_3d.cpp
@@ -0,0 +1,303 @@
+/**
+ * Link stubs for the native 3D-renderer test build.
+ *
+ * The harness compiles real KiCad 3D-viewer TUs; the symbols those TUs
+ * reference from elsewhere in KiCad but only reach through dead branches
+ * (m_board / model-cache / app machinery is never populated here) are defined
+ * as safe no-ops. One stub per linker error, each annotated with the
+ * referencing TU. Seeded from tests/gal-regression/native/kicad_stubs.cpp.
+ */
+
+#include "kicad_stubs_3d.h"
+
+#include
+#include
+#include
+#include
+
+// PGM_BASE holds unique_ptrs to these — complete types needed to define
+// its ctor/dtor here.
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+
+#include "3d_cache/3d_cache.h"
+
+//=============================================================================
+// PGM_BASE / Pgm() — render_3d_opengl.cpp uses
+// Pgm().GetGLContextManager()->RunWithoutCtxLock() during reload().
+//=============================================================================
+
+class PGM_BASE_TEST : public PGM_BASE
+{
+public:
+ PGM_BASE_TEST() { m_singleton.m_GLContextManager = new GL_CONTEXT_MANAGER(); }
+
+ void MacOpenFile( const wxString& ) override {}
+};
+
+PGM_BASE& Pgm()
+{
+ static PGM_BASE_TEST* s_pgm = nullptr;
+
+ if( !s_pgm )
+ s_pgm = new PGM_BASE_TEST();
+
+ return *s_pgm;
+}
+
+// PGM_BASE out-of-line methods (pgm_base.cpp is not compiled) — same stub set
+// as the GAL harness.
+PGM_BASE::PGM_BASE()
+{
+}
+
+PGM_BASE::~PGM_BASE()
+{
+}
+
+wxApp& PGM_BASE::App()
+{
+ static wxApp* app = nullptr;
+ return *app;
+}
+
+COMMON_SETTINGS* PGM_BASE::GetCommonSettings() const
+{
+ return nullptr;
+}
+
+const wxString& PGM_BASE::GetExecutablePath() const
+{
+ static wxString s;
+ return s;
+}
+
+ENV_VAR_MAP& PGM_BASE::GetLocalEnvVariables() const
+{
+ static ENV_VAR_MAP map;
+ return map;
+}
+
+bool PGM_BASE::SetLanguage( wxString&, bool )
+{
+ return false;
+}
+
+const wxString& PGM_BASE::GetTextEditor( bool )
+{
+ static wxString s;
+ return s;
+}
+
+void PGM_BASE::SetTextEditor( const wxString& )
+{
+}
+
+wxString PGM_BASE::GetLanguageTag()
+{
+ return wxString();
+}
+
+void PGM_BASE::SetLanguagePath()
+{
+}
+
+void PGM_BASE::ReadPdfBrowserInfos()
+{
+}
+
+bool PGM_BASE::SetLocalEnvVariable( const wxString&, const wxString& )
+{
+ return false;
+}
+
+void PGM_BASE::SetLocalEnvVariables()
+{
+}
+
+void PGM_BASE::WritePdfBrowserInfos()
+{
+}
+
+void PGM_BASE::SetLanguageIdentifier( int )
+{
+}
+
+const wxString PGM_BASE::AskUserForPreferredEditor( const wxString& )
+{
+ return wxString();
+}
+
+//=============================================================================
+// ADVANCED_CFG singleton (advanced_config.cpp is not compiled)
+//=============================================================================
+
+const ADVANCED_CFG& ADVANCED_CFG::GetCfg()
+{
+ static ADVANCED_CFG instance;
+ return instance;
+}
+
+ADVANCED_CFG::ADVANCED_CFG()
+{
+ // Only fields the compiled TUs actually read; KiCad default DPI.
+ m_ScreenDPI = 91;
+ m_3DRT_BevelHeight_um = 30;
+ m_3DRT_BevelExtentFactor = 1.0 / 16.0;
+}
+
+//=============================================================================
+// Profiling clock — deterministic zero for the test harness.
+//=============================================================================
+
+int64_t GetRunningMicroSecs()
+{
+ return 0;
+}
+
+//=============================================================================
+// Destructors of app-machinery members PGM_BASE owns (their real TUs would
+// drag the whole settings/library world in; nothing here ever populates them).
+//=============================================================================
+
+KICAD_SINGLETON::~KICAD_SINGLETON()
+{
+ delete m_GLContextManager;
+ m_GLContextManager = nullptr;
+}
+
+LIBRARY_MANAGER::~LIBRARY_MANAGER() = default;
+
+SETTINGS_MANAGER::~SETTINGS_MANAGER() = default;
+
+//=============================================================================
+// STATUSBAR_REPORTER (reporter.cpp drags fontconfig; Redraw() takes REPORTER*
+// but the harness always passes nullptr).
+//=============================================================================
+
+#include
+
+REPORTER& STATUSBAR_REPORTER::Report( const wxString&, SEVERITY )
+{
+ return *this;
+}
+
+//=============================================================================
+// BOARD / BOARD_ITEM / PAD / PCB_VIA — referenced from create_scene.cpp and
+// render_3d_opengl.cpp branches that only run with a real BOARD loaded
+// (m_board stays nullptr in this harness).
+//=============================================================================
+
+int BOARD::GetCopperLayerCount() const
+{
+ return 2;
+}
+
+const EMBEDDED_FILES* BOARD::GetEmbeddedFiles() const
+{
+ return nullptr;
+}
+
+const wxString BOARD::GetLayerName( PCB_LAYER_ID aLayer ) const
+{
+ return LayerName( aLayer );
+}
+
+int BOARD_ITEM::GetMaxError() const
+{
+ return ARC_HIGH_DEF;
+}
+
+bool PAD::TransformHoleToPolygon( SHAPE_POLY_SET&, int, int, ERROR_LOC ) const
+{
+ return false;
+}
+
+int PCB_VIA::GetDrillValue() const
+{
+ return 0;
+}
+
+void PCB_VIA::LayerPair( PCB_LAYER_ID* aTopLayer, PCB_LAYER_ID* aBottomLayer ) const
+{
+ if( aTopLayer )
+ *aTopLayer = F_Cu;
+
+ if( aBottomLayer )
+ *aBottomLayer = B_Cu;
+}
+
+std::optional PCB_VIA::GetSecondaryDrillSize() const
+{
+ return std::nullopt;
+}
+
+std::optional PCB_VIA::GetTertiaryDrillSize() const
+{
+ return std::nullopt;
+}
+
+FILLING_MODE PCB_VIA::GetFillingMode() const
+{
+ return static_cast( 0 );
+}
+
+CAPPING_MODE PCB_VIA::GetCappingMode() const
+{
+ return static_cast( 0 );
+}
+
+PLUGGING_MODE PCB_VIA::GetFrontPluggingMode() const
+{
+ return static_cast( 0 );
+}
+
+PLUGGING_MODE PCB_VIA::GetBackPluggingMode() const
+{
+ return static_cast( 0 );
+}
+
+COVERING_MODE PCB_VIA::GetFrontCoveringMode() const
+{
+ return static_cast( 0 );
+}
+
+COVERING_MODE PCB_VIA::GetBackCoveringMode() const
+{
+ return static_cast( 0 );
+}
+
+//=============================================================================
+// Footprint-model loading chain (Load3dModelsIfNeeded is never called).
+//=============================================================================
+
+FOOTPRINT_LIBRARY_ADAPTER* PROJECT_PCB::FootprintLibAdapter( PROJECT* )
+{
+ return nullptr;
+}
+
+wxString LIBRARY_MANAGER::GetFullURI( const LIBRARY_TABLE_ROW*, bool )
+{
+ return wxString();
+}
+
+std::optional LIBRARY_MANAGER_ADAPTER::GetRow( const wxString&,
+ LIBRARY_TABLE_SCOPE ) const
+{
+ return std::nullopt;
+}
+
+S3DMODEL* S3D_CACHE::GetModel( const wxString&, const wxString&,
+ std::vector )
+{
+ return nullptr;
+}
diff --git a/tests/3d-regression/native/kicad_stubs_3d.h b/tests/3d-regression/native/kicad_stubs_3d.h
new file mode 100644
index 0000000..4c09837
--- /dev/null
+++ b/tests/3d-regression/native/kicad_stubs_3d.h
@@ -0,0 +1,15 @@
+/**
+ * Include shim for the native 3D-renderer test build.
+ * kiglad.h must precede wx/glcanvas.h so GL symbols come from the vendored
+ * glad loader instead of the (deprecated) Apple GL headers.
+ */
+
+#ifndef KICAD_STUBS_3D_H
+#define KICAD_STUBS_3D_H
+
+#include
+
+#include
+#include
+
+#endif // KICAD_STUBS_3D_H
diff --git a/tests/3d-regression/native/render3d_test_accessor.cpp b/tests/3d-regression/native/render3d_test_accessor.cpp
new file mode 100644
index 0000000..6274c6b
--- /dev/null
+++ b/tests/3d-regression/native/render3d_test_accessor.cpp
@@ -0,0 +1,273 @@
+#include "kicad_stubs_3d.h"
+
+#include "render3d_test_accessor.h"
+
+#include "3d_rendering/opengl/render_3d_opengl.h"
+
+// Member-pointer private-access technique, same as gal_test_accessor.cpp
+// (https://bloglitb.blogspot.com/2010/07/access-to-private-members-thats-easy.html).
+
+template
+struct result
+{
+ typedef typename Tag::type type;
+ static type ptr;
+};
+
+template
+typename result::type result::ptr;
+
+template
+struct rob : result
+{
+ struct filler
+ {
+ filler() { result::ptr = p; }
+ };
+ static filler filler_obj;
+};
+
+template
+typename rob::filler rob::filler_obj;
+
+// Tags — the typedef's signature picks the right overload of the member.
+struct R3D_initializeOpenGL
+{
+ typedef bool ( RENDER_3D_OPENGL::*type )();
+};
+template struct rob;
+
+struct R3D_generateCylinder
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const SFVEC2F&, float, float, float, float,
+ unsigned int, TRIANGLE_DISPLAY_LIST* );
+};
+template struct rob;
+
+struct R3D_generateInvCone
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const SFVEC2F&, float, float, float, float,
+ unsigned int, TRIANGLE_DISPLAY_LIST*, EDA_ANGLE );
+};
+template struct rob;
+
+struct R3D_generateDisk
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const SFVEC2F&, float, float, unsigned int,
+ TRIANGLE_DISPLAY_LIST*, bool );
+};
+template struct rob;
+
+struct R3D_generateDimple
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const SFVEC2F&, float, float, float, unsigned int,
+ TRIANGLE_DISPLAY_LIST*, bool );
+};
+template struct rob;
+
+struct R3D_generateRing
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const SFVEC2F&, float, float, unsigned int,
+ std::vector&, std::vector&,
+ bool );
+};
+template struct rob;
+
+struct R3D_addObj_Circle
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const FILLED_CIRCLE_2D*, TRIANGLE_DISPLAY_LIST*,
+ float, float );
+};
+template struct rob;
+
+struct R3D_addObj_Ring
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const RING_2D*, TRIANGLE_DISPLAY_LIST*, float,
+ float );
+};
+template struct rob;
+
+struct R3D_addObj_Poly4
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const POLYGON_4PT_2D*, TRIANGLE_DISPLAY_LIST*,
+ float, float );
+};
+template struct rob;
+
+struct R3D_addObj_Tri
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const TRIANGLE_2D*, TRIANGLE_DISPLAY_LIST*, float,
+ float );
+};
+template struct rob;
+
+struct R3D_addObj_Seg
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( const ROUND_SEGMENT_2D*, TRIANGLE_DISPLAY_LIST*,
+ float, float );
+};
+template struct rob;
+
+struct R3D_appendPostMachining
+{
+ typedef bool ( RENDER_3D_OPENGL::*type )( TRIANGLE_DISPLAY_LIST*, const SFVEC2F&,
+ PAD_DRILL_POST_MACHINING_MODE, int, int, float,
+ float, bool, float, float, float* );
+};
+template struct rob;
+
+struct R3D_createBoard
+{
+ typedef OPENGL_RENDER_LIST* ( RENDER_3D_OPENGL::*type )( const SHAPE_POLY_SET&,
+ const BVH_CONTAINER_2D* );
+};
+template struct rob;
+
+struct R3D_generate3dGrid
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( GRID3D_TYPE );
+};
+template struct rob;
+
+struct R3D_setupMaterials
+{
+ typedef void ( RENDER_3D_OPENGL::*type )();
+};
+template struct rob;
+
+struct R3D_setLayerMaterial
+{
+ typedef void ( RENDER_3D_OPENGL::*type )( PCB_LAYER_ID );
+};
+template struct rob;
+
+struct R3D_setArrowMaterial
+{
+ typedef void ( RENDER_3D_OPENGL::*type )();
+};
+template struct rob;
+
+struct R3D_m_grid
+{
+ typedef GLuint RENDER_3D_OPENGL::*type;
+};
+template struct rob;
+
+// ---- public wrappers ----
+
+bool R3D_InitializeOpenGL( RENDER_3D_OPENGL& aRenderer )
+{
+ return ( aRenderer.*result::ptr )();
+}
+
+void R3D_GenerateCylinder( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter,
+ float aInnerRadius, float aOuterRadius, float aZtop, float aZbot,
+ unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst )
+{
+ ( aRenderer.*result::ptr )( aCenter, aInnerRadius, aOuterRadius, aZtop,
+ aZbot, aNrSides, aDst );
+}
+
+void R3D_GenerateInvCone( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter,
+ float aInnerRadius, float aOuterRadius, float aZtop, float aZbot,
+ unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst, EDA_ANGLE aAngle )
+{
+ ( aRenderer.*result::ptr )( aCenter, aInnerRadius, aOuterRadius, aZtop,
+ aZbot, aNrSides, aDst, aAngle );
+}
+
+void R3D_GenerateDisk( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aRadius,
+ float aZ, unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst, bool aTop )
+{
+ ( aRenderer.*result::ptr )( aCenter, aRadius, aZ, aNrSides, aDst, aTop );
+}
+
+void R3D_GenerateDimple( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aRadius,
+ float aZ, float aDepth, unsigned int aNrSides,
+ TRIANGLE_DISPLAY_LIST* aDst, bool aTop )
+{
+ ( aRenderer.*result::ptr )( aCenter, aRadius, aZ, aDepth, aNrSides, aDst,
+ aTop );
+}
+
+void R3D_GenerateRing( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aInnerRadius,
+ float aOuterRadius, unsigned int aNrSides,
+ std::vector& aInnerContour, std::vector& aOuterContour,
+ bool aInvertOrder )
+{
+ ( aRenderer.*result::ptr )( aCenter, aInnerRadius, aOuterRadius, aNrSides,
+ aInnerContour, aOuterContour, aInvertOrder );
+}
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const FILLED_CIRCLE_2D* aCircle,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot )
+{
+ ( aRenderer.*result::ptr )( aCircle, aDst, aZtop, aZbot );
+}
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const RING_2D* aRing,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot )
+{
+ ( aRenderer.*result::ptr )( aRing, aDst, aZtop, aZbot );
+}
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const POLYGON_4PT_2D* aPoly,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot )
+{
+ ( aRenderer.*result::ptr )( aPoly, aDst, aZtop, aZbot );
+}
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const TRIANGLE_2D* aTri,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot )
+{
+ ( aRenderer.*result::ptr )( aTri, aDst, aZtop, aZbot );
+}
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const ROUND_SEGMENT_2D* aSeg,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot )
+{
+ ( aRenderer.*result::ptr )( aSeg, aDst, aZtop, aZbot );
+}
+
+bool R3D_AppendPostMachining( RENDER_3D_OPENGL& aRenderer, TRIANGLE_DISPLAY_LIST* aDst,
+ const SFVEC2F& aHoleCenter, PAD_DRILL_POST_MACHINING_MODE aMode,
+ int aSizeIU, int aDepthIU, float aHoleInnerRadius, float aZSurface,
+ bool aIsFront, float aPlatingThickness3d, float aUnitScale,
+ float* aZEnd )
+{
+ return ( aRenderer.*result::ptr )(
+ aDst, aHoleCenter, aMode, aSizeIU, aDepthIU, aHoleInnerRadius, aZSurface, aIsFront,
+ aPlatingThickness3d, aUnitScale, aZEnd );
+}
+
+OPENGL_RENDER_LIST* R3D_CreateBoard( RENDER_3D_OPENGL& aRenderer,
+ const SHAPE_POLY_SET& aBoardPoly,
+ const BVH_CONTAINER_2D* aThroughHoles )
+{
+ return ( aRenderer.*result::ptr )( aBoardPoly, aThroughHoles );
+}
+
+void R3D_Generate3dGrid( RENDER_3D_OPENGL& aRenderer, GRID3D_TYPE aGridType )
+{
+ ( aRenderer.*result::ptr )( aGridType );
+}
+
+unsigned int R3D_GetGridList( RENDER_3D_OPENGL& aRenderer )
+{
+ return aRenderer.*result::ptr;
+}
+
+void R3D_SetupMaterials( RENDER_3D_OPENGL& aRenderer )
+{
+ ( aRenderer.*result::ptr )();
+}
+
+void R3D_SetLayerMaterial( RENDER_3D_OPENGL& aRenderer, PCB_LAYER_ID aLayerID )
+{
+ ( aRenderer.*result::ptr )( aLayerID );
+}
+
+void R3D_SetArrowMaterial( RENDER_3D_OPENGL& aRenderer )
+{
+ ( aRenderer.*result::ptr )();
+}
diff --git a/tests/3d-regression/native/render3d_test_accessor.h b/tests/3d-regression/native/render3d_test_accessor.h
new file mode 100644
index 0000000..b6ead08
--- /dev/null
+++ b/tests/3d-regression/native/render3d_test_accessor.h
@@ -0,0 +1,83 @@
+/**
+ * Access to RENDER_3D_OPENGL's private geometry generators / material setters
+ * for the Tier-2 scenarios, using the same member-pointer technique as
+ * tests/gal-regression/native/gal_test_accessor.cpp (no KiCad header edits).
+ * Overloads are disambiguated by the tag's member-function-pointer typedef.
+ */
+
+#ifndef RENDER3D_TEST_ACCESSOR_H
+#define RENDER3D_TEST_ACCESSOR_H
+
+#include
+
+#include <3d_enums.h>
+#include
+#include
+#include // PAD_DRILL_POST_MACHINING_MODE
+
+#include
+
+class RENDER_3D_OPENGL;
+class TRIANGLE_DISPLAY_LIST;
+class OPENGL_RENDER_LIST;
+class FILLED_CIRCLE_2D;
+class RING_2D;
+class POLYGON_4PT_2D;
+class TRIANGLE_2D;
+class ROUND_SEGMENT_2D;
+class SHAPE_POLY_SET;
+class BVH_CONTAINER_2D;
+
+bool R3D_InitializeOpenGL( RENDER_3D_OPENGL& aRenderer );
+
+void R3D_GenerateCylinder( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter,
+ float aInnerRadius, float aOuterRadius, float aZtop, float aZbot,
+ unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst );
+
+void R3D_GenerateInvCone( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter,
+ float aInnerRadius, float aOuterRadius, float aZtop, float aZbot,
+ unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst, EDA_ANGLE aAngle );
+
+void R3D_GenerateDisk( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aRadius,
+ float aZ, unsigned int aNrSides, TRIANGLE_DISPLAY_LIST* aDst, bool aTop );
+
+void R3D_GenerateDimple( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aRadius,
+ float aZ, float aDepth, unsigned int aNrSides,
+ TRIANGLE_DISPLAY_LIST* aDst, bool aTop );
+
+void R3D_GenerateRing( RENDER_3D_OPENGL& aRenderer, const SFVEC2F& aCenter, float aInnerRadius,
+ float aOuterRadius, unsigned int aNrSides,
+ std::vector& aInnerContour, std::vector& aOuterContour,
+ bool aInvertOrder );
+
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const FILLED_CIRCLE_2D* aCircle,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot );
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const RING_2D* aRing,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot );
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const POLYGON_4PT_2D* aPoly,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot );
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const TRIANGLE_2D* aTri,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot );
+void R3D_AddObjTriangles( RENDER_3D_OPENGL& aRenderer, const ROUND_SEGMENT_2D* aSeg,
+ TRIANGLE_DISPLAY_LIST* aDst, float aZtop, float aZbot );
+
+bool R3D_AppendPostMachining( RENDER_3D_OPENGL& aRenderer, TRIANGLE_DISPLAY_LIST* aDst,
+ const SFVEC2F& aHoleCenter, PAD_DRILL_POST_MACHINING_MODE aMode,
+ int aSizeIU, int aDepthIU, float aHoleInnerRadius, float aZSurface,
+ bool aIsFront, float aPlatingThickness3d, float aUnitScale,
+ float* aZEnd );
+
+OPENGL_RENDER_LIST* R3D_CreateBoard( RENDER_3D_OPENGL& aRenderer,
+ const SHAPE_POLY_SET& aBoardPoly,
+ const BVH_CONTAINER_2D* aThroughHoles = nullptr );
+
+void R3D_Generate3dGrid( RENDER_3D_OPENGL& aRenderer, GRID3D_TYPE aGridType );
+
+/// The compiled grid display-list id (private m_grid).
+unsigned int R3D_GetGridList( RENDER_3D_OPENGL& aRenderer );
+
+void R3D_SetupMaterials( RENDER_3D_OPENGL& aRenderer );
+void R3D_SetLayerMaterial( RENDER_3D_OPENGL& aRenderer, PCB_LAYER_ID aLayerID );
+void R3D_SetArrowMaterial( RENDER_3D_OPENGL& aRenderer );
+
+#endif // RENDER3D_TEST_ACCESSOR_H
diff --git a/tests/3d-regression/native/scene3d_native_test.cpp b/tests/3d-regression/native/scene3d_native_test.cpp
new file mode 100644
index 0000000..3d30ae9
--- /dev/null
+++ b/tests/3d-regression/native/scene3d_native_test.cpp
@@ -0,0 +1,299 @@
+/**
+ * Native 3D-renderer test application — golden-baseline generator.
+ *
+ * Renders the shared scenarios (tests/3d-regression/scenarios/) through the
+ * REAL KiCad 3D-viewer OpenGL code on a desktop GL 2.1 compatibility context,
+ * captures each into a fixed-size offscreen FBO and writes PNGs. These PNGs
+ * are the committed goldens the WebGL port will be compared against with the
+ * pixelmatch engine (tests/tools/screenshots/compare-dirs.ts).
+ *
+ * Modeled on tests/gal-regression/native/gal_native_test.cpp.
+ */
+
+#include "kicad_stubs_3d.h" // kiglad before wx
+
+#define STB_IMAGE_WRITE_IMPLEMENTATION
+#include "stb_image_write.h"
+
+#include "fbo_capture.h"
+#include "scene3d_test_ctx.h"
+#include "scene3d_test_scenarios.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace fs = std::filesystem;
+
+// Fixed capture size — must match the WebGL harness canvas (wasm/3d_webgl_test.html)
+// and manifest.json. FBO capture makes this independent of window size / Retina.
+static const int CAPTURE_WIDTH = 800;
+static const int CAPTURE_HEIGHT = 600;
+
+static std::string g_outputDir;
+static std::string g_manifestPath;
+static std::string g_filter;
+static bool g_showWindow = false;
+
+
+static bool SavePng( const std::string& aPath, std::vector& aPixels, int aWidth,
+ int aHeight )
+{
+ // Force alpha opaque: the browser canvas the WebGL side screenshots is
+ // composited opaque, while the FBO keeps partial alpha (same rationale as
+ // gal_native_test.cpp SaveScreenshot).
+ for( size_t i = 3; i < aPixels.size(); i += 4 )
+ aPixels[i] = 255;
+
+ // OpenGL rows are bottom-up.
+ stbi_flip_vertically_on_write( 1 );
+
+ return stbi_write_png( aPath.c_str(), aWidth, aHeight, 4, aPixels.data(), aWidth * 4 ) != 0;
+}
+
+
+static bool WriteManifest( const std::string& aPath )
+{
+ std::ofstream out( aPath );
+
+ if( !out )
+ {
+ std::cerr << "Failed to write manifest: " << aPath << "\n";
+ return false;
+ }
+
+ out << "{\n \"width\": " << CAPTURE_WIDTH << ",\n \"height\": " << CAPTURE_HEIGHT
+ << ",\n \"scenarios\": [\n";
+
+ for( int i = 0; i < Scene3DTest::GetScenarioCount(); i++ )
+ {
+ out << " \"" << Scene3DTest::GetScenarioName( i ) << "\""
+ << ( i + 1 < Scene3DTest::GetScenarioCount() ? "," : "" ) << "\n";
+ }
+
+ out << " ]\n}\n";
+ return true;
+}
+
+
+class TEST_GL_CANVAS : public wxGLCanvas
+{
+public:
+ explicit TEST_GL_CANVAS( wxWindow* aParent, const wxGLAttributes& aAttrs ) :
+ wxGLCanvas( aParent, aAttrs, wxID_ANY, wxDefaultPosition,
+ wxSize( CAPTURE_WIDTH, CAPTURE_HEIGHT ) )
+ {
+ wxGLContextAttrs ctxAttrs; // default: legacy compatibility profile —
+ ctxAttrs.PlatformDefaults().EndList(); // required for immediate mode + display lists
+ m_context = new wxGLContext( this, nullptr, &ctxAttrs );
+ }
+
+ ~TEST_GL_CANVAS() override { delete m_context; }
+
+ bool MakeCurrent() { return SetCurrent( *m_context ); }
+
+private:
+ wxGLContext* m_context;
+};
+
+
+class SCENE3D_TEST_FRAME : public wxFrame
+{
+public:
+ SCENE3D_TEST_FRAME() :
+ wxFrame( nullptr, wxID_ANY, "3D Renderer Native Test", wxDefaultPosition,
+ wxSize( CAPTURE_WIDTH, CAPTURE_HEIGHT ) )
+ {
+ wxGLAttributes attrs;
+ attrs.PlatformDefaults().RGBA().DoubleBuffer().Depth( 24 ).Stencil( 8 ).EndList();
+
+ m_canvas = new TEST_GL_CANVAS( this, attrs );
+
+ CallAfter( &SCENE3D_TEST_FRAME::RunScenarios );
+ }
+
+ void RunScenarios()
+ {
+ std::vector toRun;
+
+ for( int i = 0; i < Scene3DTest::GetScenarioCount(); i++ )
+ {
+ const std::string name = Scene3DTest::GetScenarioName( i );
+
+ if( g_filter.empty() || name.find( g_filter ) != std::string::npos )
+ toRun.push_back( i );
+ }
+
+ m_total = static_cast( toRun.size() );
+
+ if( !m_canvas->MakeCurrent() )
+ {
+ std::cerr << "Failed to make GL context current\n";
+ Close();
+ return;
+ }
+
+ const int gladVersion = gladLoaderLoadGL();
+
+ if( !gladVersion )
+ {
+ std::cerr << "gladLoaderLoadGL failed\n";
+ Close();
+ return;
+ }
+
+ std::cout << "GL_VERSION: " << (const char*) glGetString( GL_VERSION ) << "\n";
+ std::cout << "GL_RENDERER: " << (const char*) glGetString( GL_RENDERER ) << "\n";
+
+ // The FFP renderer needs a compatibility context: display lists must exist.
+ if( !glad_glGenLists )
+ {
+ std::cerr << "glGenLists did not load — not a compatibility context?\n";
+ Close();
+ return;
+ }
+
+ FBO_CAPTURE fbo;
+
+ if( !fbo.Create( CAPTURE_WIDTH, CAPTURE_HEIGHT ) )
+ {
+ std::cerr << "FBO creation failed\n";
+ Close();
+ return;
+ }
+
+ fs::create_directories( g_outputDir );
+
+ SCENE3D_CTX ctx( CAPTURE_WIDTH, CAPTURE_HEIGHT );
+ ctx.InitOnce(); // initializeOpenGL()-equivalent state + circle texture
+
+ std::vector pixels;
+
+ for( int i : toRun )
+ {
+ const std::string name = Scene3DTest::GetScenarioName( i );
+
+ std::cout << "Scenario " << i << ": " << name << "... " << std::flush;
+
+ fbo.Bind();
+ Scene3DTest::RenderScenario( ctx, i );
+
+ const std::string path = g_outputDir + "/3d-" + name + ".png";
+
+ if( fbo.ReadPixels( pixels ) && SavePng( path, pixels, fbo.Width(), fbo.Height() ) )
+ {
+ std::cout << "OK\n";
+ m_passed++;
+ }
+ else
+ {
+ std::cout << "FAILED\n";
+ }
+ }
+
+ fbo.Destroy();
+
+ if( !g_manifestPath.empty() )
+ {
+ if( WriteManifest( g_manifestPath ) )
+ std::cout << "Manifest: " << g_manifestPath << "\n";
+ else
+ m_passed = -1;
+ }
+
+ std::cout << "\nResults: " << m_passed << "/" << m_total << " scenarios saved\n";
+
+ if( !g_showWindow )
+ Close();
+ }
+
+ int GetPassed() const { return m_passed; }
+ int GetTotal() const { return m_total; }
+
+private:
+ TEST_GL_CANVAS* m_canvas;
+ int m_passed = 0;
+ int m_total = 0;
+};
+
+
+class SCENE3D_TEST_APP : public wxApp
+{
+public:
+ bool OnInit() override
+ {
+ m_frame = new SCENE3D_TEST_FRAME();
+ m_frame->Show( true );
+ return true;
+ }
+
+ int OnExit() override
+ {
+ if( m_frame )
+ return ( m_frame->GetPassed() == m_frame->GetTotal() ) ? 0 : 1;
+
+ return 1;
+ }
+
+private:
+ SCENE3D_TEST_FRAME* m_frame = nullptr;
+};
+
+wxIMPLEMENT_APP_NO_MAIN( SCENE3D_TEST_APP );
+
+
+int main( int argc, char** argv )
+{
+ for( int i = 1; i < argc; i++ )
+ {
+ const std::string arg = argv[i];
+
+ if( arg == "--output" && i + 1 < argc )
+ {
+ g_outputDir = argv[++i];
+ }
+ else if( arg == "--manifest" && i + 1 < argc )
+ {
+ g_manifestPath = argv[++i];
+ }
+ else if( arg == "--filter" && i + 1 < argc )
+ {
+ g_filter = argv[++i];
+ }
+ else if( arg == "--show" )
+ {
+ g_showWindow = true;
+ }
+ else if( arg == "--list" )
+ {
+ for( int s = 0; s < Scene3DTest::GetScenarioCount(); s++ )
+ std::cout << s << ": " << Scene3DTest::GetScenarioName( s ) << "\n";
+
+ return 0;
+ }
+ else
+ {
+ std::cout << "Usage: scene3d_native_test --output [options]\n"
+ " --output Output directory for 3d-.png\n"
+ " --manifest Write the scenario manifest JSON\n"
+ " --filter Only run scenarios whose name contains \n"
+ " --list Print scenario names and exit\n"
+ " --show Keep the window open after rendering\n";
+ return arg == "--help" ? 0 : 2;
+ }
+ }
+
+ if( g_outputDir.empty() )
+ {
+ std::cerr << "--output is required (or use --list)\n";
+ return 2;
+ }
+
+ std::cout << "3D Renderer Native Test - RENDER_3D_OPENGL Baseline Generator\n";
+ std::cout << "==============================================================\n\n";
+
+ return wxEntry( argc, argv );
+}
diff --git a/tests/3d-regression/native/settings_3d_stub.cpp b/tests/3d-regression/native/settings_3d_stub.cpp
new file mode 100644
index 0000000..4735782
--- /dev/null
+++ b/tests/3d-regression/native/settings_3d_stub.cpp
@@ -0,0 +1,159 @@
+/**
+ * Test-harness stub of the EDA_3D_VIEWER_SETTINGS / APP_SETTINGS_BASE /
+ * JSON_SETTINGS chain. The renderer only reads m_Render/m_Camera plain
+ * fields; none of the JSON load/store machinery ever runs, so every virtual
+ * is a no-op and the ctor fills the render settings with the upstream
+ * defaults (deterministic test values, documented deviations flagged).
+ */
+
+#include "kicad_stubs_3d.h"
+
+#include "3d_viewer/eda_3d_viewer_settings.h"
+#include "common_ogl/ogl_attr_list.h" // ANTIALIASING_MODE (fwd-declared in the settings header)
+
+#include
+
+// ---- JSON_SETTINGS ----
+
+JSON_SETTINGS::JSON_SETTINGS( const wxString& aFilename, SETTINGS_LOC aLocation,
+ int aSchemaVersion, bool aCreateIfMissing, bool aCreateIfDefault,
+ bool aWriteFile ) :
+ m_filename( aFilename ),
+ m_legacy_filename( "" ),
+ m_location( aLocation ),
+ m_createIfMissing( aCreateIfMissing ),
+ m_createIfDefault( aCreateIfDefault ),
+ m_writeFile( aWriteFile ),
+ m_modified( false ),
+ m_deleteLegacyAfterMigration( false ),
+ m_resetParamsIfMissing( true ),
+ m_schemaVersion( aSchemaVersion ),
+ m_manager( nullptr )
+{
+ m_internals = std::make_unique();
+}
+
+JSON_SETTINGS::~JSON_SETTINGS() = default;
+
+void JSON_SETTINGS::Load() {}
+bool JSON_SETTINGS::Store() { return false; }
+bool JSON_SETTINGS::LoadFromFile( const wxString& ) { return false; }
+bool JSON_SETTINGS::SaveToFile( const wxString&, bool ) { return false; }
+std::map JSON_SETTINGS::GetFileHistories() { return {}; }
+bool JSON_SETTINGS::MigrateFromLegacy( wxConfigBase* ) { return false; }
+
+// ---- APP_SETTINGS_BASE ----
+
+APP_SETTINGS_BASE::APP_SETTINGS_BASE( const std::string& aFilename, int aSchemaVersion ) :
+ JSON_SETTINGS( aFilename, SETTINGS_LOC::USER, aSchemaVersion, true, true, true ),
+ m_CrossProbing(),
+ m_FindReplace(),
+ m_Graphics(),
+ m_ColorPicker(),
+ m_LibTree(),
+ m_Printing(),
+ m_SearchPane(),
+ m_System(),
+ m_Window(),
+ m_appSettingsSchemaVersion( aSchemaVersion )
+{
+}
+
+bool APP_SETTINGS_BASE::MigrateFromLegacy( wxConfigBase* ) { return false; }
+
+// ---- EDA_3D_VIEWER_SETTINGS ----
+
+EDA_3D_VIEWER_SETTINGS::EDA_3D_VIEWER_SETTINGS() :
+ APP_SETTINGS_BASE( "3d_viewer", 0 ),
+ m_Render(),
+ m_Camera()
+{
+ RENDER_SETTINGS& r = m_Render;
+
+ // Upstream defaults (eda_3d_viewer_settings.cpp PARAM defaults), with two
+ // determinism-driven deviations: engine is OPENGL (the renderer under
+ // test) and AA is NONE (context is single-sample anyway).
+ r.engine = RENDER_ENGINE::OPENGL;
+ r.grid_type = GRID3D_TYPE::NONE;
+ r.opengl_AA_mode = ANTIALIASING_MODE::AA_NONE;
+ r.material_mode = MATERIAL_MODE::NORMAL;
+
+ r.opengl_AA_disableOnMove = false;
+ r.opengl_thickness_disableOnMove = false;
+ r.opengl_microvias_disableOnMove = false;
+ r.opengl_holes_disableOnMove = false;
+ r.opengl_render_bbox_only_OnMove = false;
+ r.opengl_copper_thickness = true;
+ r.show_model_bbox = false;
+ r.show_off_board_silk = false;
+ r.highlight_on_rollover = false;
+ r.opengl_selection_color = KIGFX::COLOR4D( 0.0, 1.0, 0.0, 1.0 );
+
+ r.raytrace_anti_aliasing = false;
+ r.raytrace_backfloor = false;
+ r.raytrace_post_processing = false;
+ r.raytrace_procedural_textures = false;
+ r.raytrace_reflections = false;
+ r.raytrace_refractions = false;
+ r.raytrace_shadows = false;
+ r.raytrace_nrsamples_shadows = 0;
+ r.raytrace_nrsamples_reflections = 0;
+ r.raytrace_nrsamples_refractions = 0;
+ r.raytrace_spread_shadows = 0.0f;
+ r.raytrace_spread_reflections = 0.0f;
+ r.raytrace_spread_refractions = 0.0f;
+ r.raytrace_recursivelevel_reflections = 0;
+ r.raytrace_recursivelevel_refractions = 0;
+ r.raytrace_lightColorCamera = KIGFX::COLOR4D( 0.2, 0.2, 0.2, 1.0 );
+ r.raytrace_lightColorTop = KIGFX::COLOR4D( 0.247, 0.247, 0.247, 1.0 );
+ r.raytrace_lightColorBottom = KIGFX::COLOR4D( 0.247, 0.247, 0.247, 1.0 );
+
+ r.show_adhesive = true;
+ r.show_navigator = false;
+ r.show_board_body = true;
+ r.show_plated_barrels = true;
+ r.show_comments = true;
+ r.show_drawings = true;
+ r.show_eco1 = true;
+ r.show_eco2 = true;
+
+ for( bool& user : r.show_user )
+ user = false;
+
+ r.show_footprints_insert = true;
+ r.show_footprints_normal = true;
+ r.show_footprints_virtual = true;
+ r.show_footprints_not_in_posfile = true;
+ r.show_footprints_dnp = true;
+ r.show_silkscreen_top = true;
+ r.show_silkscreen_bottom = true;
+ r.show_soldermask_top = true;
+ r.show_soldermask_bottom = true;
+ r.show_solderpaste = true;
+ r.show_copper_top = true;
+ r.show_copper_bottom = true;
+ r.show_zones = true;
+ r.show_fp_references = true;
+ r.show_fp_values = true;
+ r.show_fp_text = true;
+ r.subtract_mask_from_silk = false;
+ r.clip_silk_on_via_annuli = true;
+ r.differentiate_plated_copper = true;
+ r.use_board_editor_copper_colors = false;
+ r.preview_show_board_body = true;
+
+ m_Camera.animation_enabled = false;
+ m_Camera.moving_speed_multiplier = 3;
+ m_Camera.rotation_increment = 10.0;
+ m_Camera.projection_mode = 0;
+}
+
+LAYER_PRESET_3D* EDA_3D_VIEWER_SETTINGS::FindPreset( const wxString& )
+{
+ return nullptr;
+}
+
+bool EDA_3D_VIEWER_SETTINGS::MigrateFromLegacy( wxConfigBase* )
+{
+ return false;
+}
diff --git a/tests/3d-regression/native/stb_image_write.h b/tests/3d-regression/native/stb_image_write.h
new file mode 100644
index 0000000..e4b32ed
--- /dev/null
+++ b/tests/3d-regression/native/stb_image_write.h
@@ -0,0 +1,1724 @@
+/* stb_image_write - v1.16 - public domain - http://nothings.org/stb
+ writes out PNG/BMP/TGA/JPEG/HDR images to C stdio - Sean Barrett 2010-2015
+ no warranty implied; use at your own risk
+
+ Before #including,
+
+ #define STB_IMAGE_WRITE_IMPLEMENTATION
+
+ in the file that you want to have the implementation.
+
+ Will probably not work correctly with strict-aliasing optimizations.
+
+ABOUT:
+
+ This header file is a library for writing images to C stdio or a callback.
+
+ The PNG output is not optimal; it is 20-50% larger than the file
+ written by a decent optimizing implementation; though providing a custom
+ zlib compress function (see STBIW_ZLIB_COMPRESS) can mitigate that.
+ This library is designed for source code compactness and simplicity,
+ not optimal image file size or run-time performance.
+
+BUILDING:
+
+ You can #define STBIW_ASSERT(x) before the #include to avoid using assert.h.
+ You can #define STBIW_MALLOC(), STBIW_REALLOC(), and STBIW_FREE() to replace
+ malloc,realloc,free.
+ You can #define STBIW_MEMMOVE() to replace memmove()
+ You can #define STBIW_ZLIB_COMPRESS to use a custom zlib-style compress function
+ for PNG compression (instead of the builtin one), it must have the following signature:
+ unsigned char * my_compress(unsigned char *data, int data_len, int *out_len, int quality);
+ The returned data will be freed with STBIW_FREE() (free() by default),
+ so it must be heap allocated with STBIW_MALLOC() (malloc() by default),
+
+UNICODE:
+
+ If compiling for Windows and you wish to use Unicode filenames, compile
+ with
+ #define STBIW_WINDOWS_UTF8
+ and pass utf8-encoded filenames. Call stbiw_convert_wchar_to_utf8 to convert
+ Windows wchar_t filenames to utf8.
+
+USAGE:
+
+ There are five functions, one for each image file format:
+
+ int stbi_write_png(char const *filename, int w, int h, int comp, const void *data, int stride_in_bytes);
+ int stbi_write_bmp(char const *filename, int w, int h, int comp, const void *data);
+ int stbi_write_tga(char const *filename, int w, int h, int comp, const void *data);
+ int stbi_write_jpg(char const *filename, int w, int h, int comp, const void *data, int quality);
+ int stbi_write_hdr(char const *filename, int w, int h, int comp, const float *data);
+
+ void stbi_flip_vertically_on_write(int flag); // flag is non-zero to flip data vertically
+
+ There are also five equivalent functions that use an arbitrary write function. You are
+ expected to open/close your file-equivalent before and after calling these:
+
+ int stbi_write_png_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data, int stride_in_bytes);
+ int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data);
+ int stbi_write_tga_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data);
+ int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const float *data);
+ int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality);
+
+ where the callback is:
+ void stbi_write_func(void *context, void *data, int size);
+
+ You can configure it with these global variables:
+ int stbi_write_tga_with_rle; // defaults to true; set to 0 to disable RLE
+ int stbi_write_png_compression_level; // defaults to 8; set to higher for more compression
+ int stbi_write_force_png_filter; // defaults to -1; set to 0..5 to force a filter mode
+
+
+ You can define STBI_WRITE_NO_STDIO to disable the file variant of these
+ functions, so the library will not use stdio.h at all. However, this will
+ also disable HDR writing, because it requires stdio for formatted output.
+
+ Each function returns 0 on failure and non-0 on success.
+
+ The functions create an image file defined by the parameters. The image
+ is a rectangle of pixels stored from left-to-right, top-to-bottom.
+ Each pixel contains 'comp' channels of data stored interleaved with 8-bits
+ per channel, in the following order: 1=Y, 2=YA, 3=RGB, 4=RGBA. (Y is
+ monochrome color.) The rectangle is 'w' pixels wide and 'h' pixels tall.
+ The *data pointer points to the first byte of the top-left-most pixel.
+ For PNG, "stride_in_bytes" is the distance in bytes from the first byte of
+ a row of pixels to the first byte of the next row of pixels.
+
+ PNG creates output files with the same number of components as the input.
+ The BMP format expands Y to RGB in the file format and does not
+ output alpha.
+
+ PNG supports writing rectangles of data even when the bytes storing rows of
+ data are not consecutive in memory (e.g. sub-rectangles of a larger image),
+ by supplying the stride between the beginning of adjacent rows. The other
+ formats do not. (Thus you cannot write a native-format BMP through the BMP
+ writer, both because it is in BGR order and because it may have padding
+ at the end of the line.)
+
+ PNG allows you to set the deflate compression level by setting the global
+ variable 'stbi_write_png_compression_level' (it defaults to 8).
+
+ HDR expects linear float data. Since the format is always 32-bit rgb(e)
+ data, alpha (if provided) is discarded, and for monochrome data it is
+ replicated across all three channels.
+
+ TGA supports RLE or non-RLE compressed data. To use non-RLE-compressed
+ data, set the global variable 'stbi_write_tga_with_rle' to 0.
+
+ JPEG does ignore alpha channels in input data; quality is between 1 and 100.
+ Higher quality looks better but results in a bigger image.
+ JPEG baseline (no JPEG progressive).
+
+CREDITS:
+
+
+ Sean Barrett - PNG/BMP/TGA
+ Baldur Karlsson - HDR
+ Jean-Sebastien Guay - TGA monochrome
+ Tim Kelsey - misc enhancements
+ Alan Hickman - TGA RLE
+ Emmanuel Julien - initial file IO callback implementation
+ Jon Olick - original jo_jpeg.cpp code
+ Daniel Gibson - integrate JPEG, allow external zlib
+ Aarni Koskela - allow choosing PNG filter
+
+ bugfixes:
+ github:Chribba
+ Guillaume Chereau
+ github:jry2
+ github:romigrou
+ Sergio Gonzalez
+ Jonas Karlsson
+ Filip Wasil
+ Thatcher Ulrich
+ github:poppolopoppo
+ Patrick Boettcher
+ github:xeekworx
+ Cap Petschulat
+ Simon Rodriguez
+ Ivan Tikhonov
+ github:ignotion
+ Adam Schackart
+ Andrew Kensler
+
+LICENSE
+
+ See end of file for license information.
+
+*/
+
+#ifndef INCLUDE_STB_IMAGE_WRITE_H
+#define INCLUDE_STB_IMAGE_WRITE_H
+
+#include
+
+// if STB_IMAGE_WRITE_STATIC causes problems, try defining STBIWDEF to 'inline' or 'static inline'
+#ifndef STBIWDEF
+#ifdef STB_IMAGE_WRITE_STATIC
+#define STBIWDEF static
+#else
+#ifdef __cplusplus
+#define STBIWDEF extern "C"
+#else
+#define STBIWDEF extern
+#endif
+#endif
+#endif
+
+#ifndef STB_IMAGE_WRITE_STATIC // C++ forbids static forward declarations
+STBIWDEF int stbi_write_tga_with_rle;
+STBIWDEF int stbi_write_png_compression_level;
+STBIWDEF int stbi_write_force_png_filter;
+#endif
+
+#ifndef STBI_WRITE_NO_STDIO
+STBIWDEF int stbi_write_png(char const *filename, int w, int h, int comp, const void *data, int stride_in_bytes);
+STBIWDEF int stbi_write_bmp(char const *filename, int w, int h, int comp, const void *data);
+STBIWDEF int stbi_write_tga(char const *filename, int w, int h, int comp, const void *data);
+STBIWDEF int stbi_write_hdr(char const *filename, int w, int h, int comp, const float *data);
+STBIWDEF int stbi_write_jpg(char const *filename, int x, int y, int comp, const void *data, int quality);
+
+#ifdef STBIW_WINDOWS_UTF8
+STBIWDEF int stbiw_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input);
+#endif
+#endif
+
+typedef void stbi_write_func(void *context, void *data, int size);
+
+STBIWDEF int stbi_write_png_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data, int stride_in_bytes);
+STBIWDEF int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data);
+STBIWDEF int stbi_write_tga_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const void *data);
+STBIWDEF int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int w, int h, int comp, const float *data);
+STBIWDEF int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality);
+
+STBIWDEF void stbi_flip_vertically_on_write(int flip_boolean);
+
+#endif//INCLUDE_STB_IMAGE_WRITE_H
+
+#ifdef STB_IMAGE_WRITE_IMPLEMENTATION
+
+#ifdef _WIN32
+ #ifndef _CRT_SECURE_NO_WARNINGS
+ #define _CRT_SECURE_NO_WARNINGS
+ #endif
+ #ifndef _CRT_NONSTDC_NO_DEPRECATE
+ #define _CRT_NONSTDC_NO_DEPRECATE
+ #endif
+#endif
+
+#ifndef STBI_WRITE_NO_STDIO
+#include
+#endif // STBI_WRITE_NO_STDIO
+
+#include
+#include
+#include
+#include
+
+#if defined(STBIW_MALLOC) && defined(STBIW_FREE) && (defined(STBIW_REALLOC) || defined(STBIW_REALLOC_SIZED))
+// ok
+#elif !defined(STBIW_MALLOC) && !defined(STBIW_FREE) && !defined(STBIW_REALLOC) && !defined(STBIW_REALLOC_SIZED)
+// ok
+#else
+#error "Must define all or none of STBIW_MALLOC, STBIW_FREE, and STBIW_REALLOC (or STBIW_REALLOC_SIZED)."
+#endif
+
+#ifndef STBIW_MALLOC
+#define STBIW_MALLOC(sz) malloc(sz)
+#define STBIW_REALLOC(p,newsz) realloc(p,newsz)
+#define STBIW_FREE(p) free(p)
+#endif
+
+#ifndef STBIW_REALLOC_SIZED
+#define STBIW_REALLOC_SIZED(p,oldsz,newsz) STBIW_REALLOC(p,newsz)
+#endif
+
+
+#ifndef STBIW_MEMMOVE
+#define STBIW_MEMMOVE(a,b,sz) memmove(a,b,sz)
+#endif
+
+
+#ifndef STBIW_ASSERT
+#include
+#define STBIW_ASSERT(x) assert(x)
+#endif
+
+#define STBIW_UCHAR(x) (unsigned char) ((x) & 0xff)
+
+#ifdef STB_IMAGE_WRITE_STATIC
+static int stbi_write_png_compression_level = 8;
+static int stbi_write_tga_with_rle = 1;
+static int stbi_write_force_png_filter = -1;
+#else
+int stbi_write_png_compression_level = 8;
+int stbi_write_tga_with_rle = 1;
+int stbi_write_force_png_filter = -1;
+#endif
+
+static int stbi__flip_vertically_on_write = 0;
+
+STBIWDEF void stbi_flip_vertically_on_write(int flag)
+{
+ stbi__flip_vertically_on_write = flag;
+}
+
+typedef struct
+{
+ stbi_write_func *func;
+ void *context;
+ unsigned char buffer[64];
+ int buf_used;
+} stbi__write_context;
+
+// initialize a callback-based context
+static void stbi__start_write_callbacks(stbi__write_context *s, stbi_write_func *c, void *context)
+{
+ s->func = c;
+ s->context = context;
+}
+
+#ifndef STBI_WRITE_NO_STDIO
+
+static void stbi__stdio_write(void *context, void *data, int size)
+{
+ fwrite(data,1,size,(FILE*) context);
+}
+
+#if defined(_WIN32) && defined(STBIW_WINDOWS_UTF8)
+#ifdef __cplusplus
+#define STBIW_EXTERN extern "C"
+#else
+#define STBIW_EXTERN extern
+#endif
+STBIW_EXTERN __declspec(dllimport) int __stdcall MultiByteToWideChar(unsigned int cp, unsigned long flags, const char *str, int cbmb, wchar_t *widestr, int cchwide);
+STBIW_EXTERN __declspec(dllimport) int __stdcall WideCharToMultiByte(unsigned int cp, unsigned long flags, const wchar_t *widestr, int cchwide, char *str, int cbmb, const char *defchar, int *used_default);
+
+STBIWDEF int stbiw_convert_wchar_to_utf8(char *buffer, size_t bufferlen, const wchar_t* input)
+{
+ return WideCharToMultiByte(65001 /* UTF8 */, 0, input, -1, buffer, (int) bufferlen, NULL, NULL);
+}
+#endif
+
+static FILE *stbiw__fopen(char const *filename, char const *mode)
+{
+ FILE *f;
+#if defined(_WIN32) && defined(STBIW_WINDOWS_UTF8)
+ wchar_t wMode[64];
+ wchar_t wFilename[1024];
+ if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, filename, -1, wFilename, sizeof(wFilename)/sizeof(*wFilename)))
+ return 0;
+
+ if (0 == MultiByteToWideChar(65001 /* UTF8 */, 0, mode, -1, wMode, sizeof(wMode)/sizeof(*wMode)))
+ return 0;
+
+#if defined(_MSC_VER) && _MSC_VER >= 1400
+ if (0 != _wfopen_s(&f, wFilename, wMode))
+ f = 0;
+#else
+ f = _wfopen(wFilename, wMode);
+#endif
+
+#elif defined(_MSC_VER) && _MSC_VER >= 1400
+ if (0 != fopen_s(&f, filename, mode))
+ f=0;
+#else
+ f = fopen(filename, mode);
+#endif
+ return f;
+}
+
+static int stbi__start_write_file(stbi__write_context *s, const char *filename)
+{
+ FILE *f = stbiw__fopen(filename, "wb");
+ stbi__start_write_callbacks(s, stbi__stdio_write, (void *) f);
+ return f != NULL;
+}
+
+static void stbi__end_write_file(stbi__write_context *s)
+{
+ fclose((FILE *)s->context);
+}
+
+#endif // !STBI_WRITE_NO_STDIO
+
+typedef unsigned int stbiw_uint32;
+typedef int stb_image_write_test[sizeof(stbiw_uint32)==4 ? 1 : -1];
+
+static void stbiw__writefv(stbi__write_context *s, const char *fmt, va_list v)
+{
+ while (*fmt) {
+ switch (*fmt++) {
+ case ' ': break;
+ case '1': { unsigned char x = STBIW_UCHAR(va_arg(v, int));
+ s->func(s->context,&x,1);
+ break; }
+ case '2': { int x = va_arg(v,int);
+ unsigned char b[2];
+ b[0] = STBIW_UCHAR(x);
+ b[1] = STBIW_UCHAR(x>>8);
+ s->func(s->context,b,2);
+ break; }
+ case '4': { stbiw_uint32 x = va_arg(v,int);
+ unsigned char b[4];
+ b[0]=STBIW_UCHAR(x);
+ b[1]=STBIW_UCHAR(x>>8);
+ b[2]=STBIW_UCHAR(x>>16);
+ b[3]=STBIW_UCHAR(x>>24);
+ s->func(s->context,b,4);
+ break; }
+ default:
+ STBIW_ASSERT(0);
+ return;
+ }
+ }
+}
+
+static void stbiw__writef(stbi__write_context *s, const char *fmt, ...)
+{
+ va_list v;
+ va_start(v, fmt);
+ stbiw__writefv(s, fmt, v);
+ va_end(v);
+}
+
+static void stbiw__write_flush(stbi__write_context *s)
+{
+ if (s->buf_used) {
+ s->func(s->context, &s->buffer, s->buf_used);
+ s->buf_used = 0;
+ }
+}
+
+static void stbiw__putc(stbi__write_context *s, unsigned char c)
+{
+ s->func(s->context, &c, 1);
+}
+
+static void stbiw__write1(stbi__write_context *s, unsigned char a)
+{
+ if ((size_t)s->buf_used + 1 > sizeof(s->buffer))
+ stbiw__write_flush(s);
+ s->buffer[s->buf_used++] = a;
+}
+
+static void stbiw__write3(stbi__write_context *s, unsigned char a, unsigned char b, unsigned char c)
+{
+ int n;
+ if ((size_t)s->buf_used + 3 > sizeof(s->buffer))
+ stbiw__write_flush(s);
+ n = s->buf_used;
+ s->buf_used = n+3;
+ s->buffer[n+0] = a;
+ s->buffer[n+1] = b;
+ s->buffer[n+2] = c;
+}
+
+static void stbiw__write_pixel(stbi__write_context *s, int rgb_dir, int comp, int write_alpha, int expand_mono, unsigned char *d)
+{
+ unsigned char bg[3] = { 255, 0, 255}, px[3];
+ int k;
+
+ if (write_alpha < 0)
+ stbiw__write1(s, d[comp - 1]);
+
+ switch (comp) {
+ case 2: // 2 pixels = mono + alpha, alpha is written separately, so same as 1-channel case
+ case 1:
+ if (expand_mono)
+ stbiw__write3(s, d[0], d[0], d[0]); // monochrome bmp
+ else
+ stbiw__write1(s, d[0]); // monochrome TGA
+ break;
+ case 4:
+ if (!write_alpha) {
+ // composite against pink background
+ for (k = 0; k < 3; ++k)
+ px[k] = bg[k] + ((d[k] - bg[k]) * d[3]) / 255;
+ stbiw__write3(s, px[1 - rgb_dir], px[1], px[1 + rgb_dir]);
+ break;
+ }
+ /* FALLTHROUGH */
+ case 3:
+ stbiw__write3(s, d[1 - rgb_dir], d[1], d[1 + rgb_dir]);
+ break;
+ }
+ if (write_alpha > 0)
+ stbiw__write1(s, d[comp - 1]);
+}
+
+static void stbiw__write_pixels(stbi__write_context *s, int rgb_dir, int vdir, int x, int y, int comp, void *data, int write_alpha, int scanline_pad, int expand_mono)
+{
+ stbiw_uint32 zero = 0;
+ int i,j, j_end;
+
+ if (y <= 0)
+ return;
+
+ if (stbi__flip_vertically_on_write)
+ vdir *= -1;
+
+ if (vdir < 0) {
+ j_end = -1; j = y-1;
+ } else {
+ j_end = y; j = 0;
+ }
+
+ for (; j != j_end; j += vdir) {
+ for (i=0; i < x; ++i) {
+ unsigned char *d = (unsigned char *) data + (j*x+i)*comp;
+ stbiw__write_pixel(s, rgb_dir, comp, write_alpha, expand_mono, d);
+ }
+ stbiw__write_flush(s);
+ s->func(s->context, &zero, scanline_pad);
+ }
+}
+
+static int stbiw__outfile(stbi__write_context *s, int rgb_dir, int vdir, int x, int y, int comp, int expand_mono, void *data, int alpha, int pad, const char *fmt, ...)
+{
+ if (y < 0 || x < 0) {
+ return 0;
+ } else {
+ va_list v;
+ va_start(v, fmt);
+ stbiw__writefv(s, fmt, v);
+ va_end(v);
+ stbiw__write_pixels(s,rgb_dir,vdir,x,y,comp,data,alpha,pad, expand_mono);
+ return 1;
+ }
+}
+
+static int stbi_write_bmp_core(stbi__write_context *s, int x, int y, int comp, const void *data)
+{
+ if (comp != 4) {
+ // write RGB bitmap
+ int pad = (-x*3) & 3;
+ return stbiw__outfile(s,-1,-1,x,y,comp,1,(void *) data,0,pad,
+ "11 4 22 4" "4 44 22 444444",
+ 'B', 'M', 14+40+(x*3+pad)*y, 0,0, 14+40, // file header
+ 40, x,y, 1,24, 0,0,0,0,0,0); // bitmap header
+ } else {
+ // RGBA bitmaps need a v4 header
+ // use BI_BITFIELDS mode with 32bpp and alpha mask
+ // (straight BI_RGB with alpha mask doesn't work in most readers)
+ return stbiw__outfile(s,-1,-1,x,y,comp,1,(void *)data,1,0,
+ "11 4 22 4" "4 44 22 444444 4444 4 444 444 444 444",
+ 'B', 'M', 14+108+x*y*4, 0, 0, 14+108, // file header
+ 108, x,y, 1,32, 3,0,0,0,0,0, 0xff0000,0xff00,0xff,0xff000000u, 0, 0,0,0, 0,0,0, 0,0,0, 0,0,0); // bitmap V4 header
+ }
+}
+
+STBIWDEF int stbi_write_bmp_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data)
+{
+ stbi__write_context s = { 0 };
+ stbi__start_write_callbacks(&s, func, context);
+ return stbi_write_bmp_core(&s, x, y, comp, data);
+}
+
+#ifndef STBI_WRITE_NO_STDIO
+STBIWDEF int stbi_write_bmp(char const *filename, int x, int y, int comp, const void *data)
+{
+ stbi__write_context s = { 0 };
+ if (stbi__start_write_file(&s,filename)) {
+ int r = stbi_write_bmp_core(&s, x, y, comp, data);
+ stbi__end_write_file(&s);
+ return r;
+ } else
+ return 0;
+}
+#endif //!STBI_WRITE_NO_STDIO
+
+static int stbi_write_tga_core(stbi__write_context *s, int x, int y, int comp, void *data)
+{
+ int has_alpha = (comp == 2 || comp == 4);
+ int colorbytes = has_alpha ? comp-1 : comp;
+ int format = colorbytes < 2 ? 3 : 2; // 3 color channels (RGB/RGBA) = 2, 1 color channel (Y/YA) = 3
+
+ if (y < 0 || x < 0)
+ return 0;
+
+ if (!stbi_write_tga_with_rle) {
+ return stbiw__outfile(s, -1, -1, x, y, comp, 0, (void *) data, has_alpha, 0,
+ "111 221 2222 11", 0, 0, format, 0, 0, 0, 0, 0, x, y, (colorbytes + has_alpha) * 8, has_alpha * 8);
+ } else {
+ int i,j,k;
+ int jend, jdir;
+
+ stbiw__writef(s, "111 221 2222 11", 0,0,format+8, 0,0,0, 0,0,x,y, (colorbytes + has_alpha) * 8, has_alpha * 8);
+
+ if (stbi__flip_vertically_on_write) {
+ j = 0;
+ jend = y;
+ jdir = 1;
+ } else {
+ j = y-1;
+ jend = -1;
+ jdir = -1;
+ }
+ for (; j != jend; j += jdir) {
+ unsigned char *row = (unsigned char *) data + j * x * comp;
+ int len;
+
+ for (i = 0; i < x; i += len) {
+ unsigned char *begin = row + i * comp;
+ int diff = 1;
+ len = 1;
+
+ if (i < x - 1) {
+ ++len;
+ diff = memcmp(begin, row + (i + 1) * comp, comp);
+ if (diff) {
+ const unsigned char *prev = begin;
+ for (k = i + 2; k < x && len < 128; ++k) {
+ if (memcmp(prev, row + k * comp, comp)) {
+ prev += comp;
+ ++len;
+ } else {
+ --len;
+ break;
+ }
+ }
+ } else {
+ for (k = i + 2; k < x && len < 128; ++k) {
+ if (!memcmp(begin, row + k * comp, comp)) {
+ ++len;
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ if (diff) {
+ unsigned char header = STBIW_UCHAR(len - 1);
+ stbiw__write1(s, header);
+ for (k = 0; k < len; ++k) {
+ stbiw__write_pixel(s, -1, comp, has_alpha, 0, begin + k * comp);
+ }
+ } else {
+ unsigned char header = STBIW_UCHAR(len - 129);
+ stbiw__write1(s, header);
+ stbiw__write_pixel(s, -1, comp, has_alpha, 0, begin);
+ }
+ }
+ }
+ stbiw__write_flush(s);
+ }
+ return 1;
+}
+
+STBIWDEF int stbi_write_tga_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data)
+{
+ stbi__write_context s = { 0 };
+ stbi__start_write_callbacks(&s, func, context);
+ return stbi_write_tga_core(&s, x, y, comp, (void *) data);
+}
+
+#ifndef STBI_WRITE_NO_STDIO
+STBIWDEF int stbi_write_tga(char const *filename, int x, int y, int comp, const void *data)
+{
+ stbi__write_context s = { 0 };
+ if (stbi__start_write_file(&s,filename)) {
+ int r = stbi_write_tga_core(&s, x, y, comp, (void *) data);
+ stbi__end_write_file(&s);
+ return r;
+ } else
+ return 0;
+}
+#endif
+
+// *************************************************************************************************
+// Radiance RGBE HDR writer
+// by Baldur Karlsson
+
+#define stbiw__max(a, b) ((a) > (b) ? (a) : (b))
+
+#ifndef STBI_WRITE_NO_STDIO
+
+static void stbiw__linear_to_rgbe(unsigned char *rgbe, float *linear)
+{
+ int exponent;
+ float maxcomp = stbiw__max(linear[0], stbiw__max(linear[1], linear[2]));
+
+ if (maxcomp < 1e-32f) {
+ rgbe[0] = rgbe[1] = rgbe[2] = rgbe[3] = 0;
+ } else {
+ float normalize = (float) frexp(maxcomp, &exponent) * 256.0f/maxcomp;
+
+ rgbe[0] = (unsigned char)(linear[0] * normalize);
+ rgbe[1] = (unsigned char)(linear[1] * normalize);
+ rgbe[2] = (unsigned char)(linear[2] * normalize);
+ rgbe[3] = (unsigned char)(exponent + 128);
+ }
+}
+
+static void stbiw__write_run_data(stbi__write_context *s, int length, unsigned char databyte)
+{
+ unsigned char lengthbyte = STBIW_UCHAR(length+128);
+ STBIW_ASSERT(length+128 <= 255);
+ s->func(s->context, &lengthbyte, 1);
+ s->func(s->context, &databyte, 1);
+}
+
+static void stbiw__write_dump_data(stbi__write_context *s, int length, unsigned char *data)
+{
+ unsigned char lengthbyte = STBIW_UCHAR(length);
+ STBIW_ASSERT(length <= 128); // inconsistent with spec but consistent with official code
+ s->func(s->context, &lengthbyte, 1);
+ s->func(s->context, data, length);
+}
+
+static void stbiw__write_hdr_scanline(stbi__write_context *s, int width, int ncomp, unsigned char *scratch, float *scanline)
+{
+ unsigned char scanlineheader[4] = { 2, 2, 0, 0 };
+ unsigned char rgbe[4];
+ float linear[3];
+ int x;
+
+ scanlineheader[2] = (width&0xff00)>>8;
+ scanlineheader[3] = (width&0x00ff);
+
+ /* skip RLE for images too small or large */
+ if (width < 8 || width >= 32768) {
+ for (x=0; x < width; x++) {
+ switch (ncomp) {
+ case 4: /* fallthrough */
+ case 3: linear[2] = scanline[x*ncomp + 2];
+ linear[1] = scanline[x*ncomp + 1];
+ linear[0] = scanline[x*ncomp + 0];
+ break;
+ default:
+ linear[0] = linear[1] = linear[2] = scanline[x*ncomp + 0];
+ break;
+ }
+ stbiw__linear_to_rgbe(rgbe, linear);
+ s->func(s->context, rgbe, 4);
+ }
+ } else {
+ int c,r;
+ /* encode into scratch buffer */
+ for (x=0; x < width; x++) {
+ switch(ncomp) {
+ case 4: /* fallthrough */
+ case 3: linear[2] = scanline[x*ncomp + 2];
+ linear[1] = scanline[x*ncomp + 1];
+ linear[0] = scanline[x*ncomp + 0];
+ break;
+ default:
+ linear[0] = linear[1] = linear[2] = scanline[x*ncomp + 0];
+ break;
+ }
+ stbiw__linear_to_rgbe(rgbe, linear);
+ scratch[x + width*0] = rgbe[0];
+ scratch[x + width*1] = rgbe[1];
+ scratch[x + width*2] = rgbe[2];
+ scratch[x + width*3] = rgbe[3];
+ }
+
+ s->func(s->context, scanlineheader, 4);
+
+ /* RLE each component separately */
+ for (c=0; c < 4; c++) {
+ unsigned char *comp = &scratch[width*c];
+
+ x = 0;
+ while (x < width) {
+ // find first run
+ r = x;
+ while (r+2 < width) {
+ if (comp[r] == comp[r+1] && comp[r] == comp[r+2])
+ break;
+ ++r;
+ }
+ if (r+2 >= width)
+ r = width;
+ // dump up to first run
+ while (x < r) {
+ int len = r-x;
+ if (len > 128) len = 128;
+ stbiw__write_dump_data(s, len, &comp[x]);
+ x += len;
+ }
+ // if there's a run, output it
+ if (r+2 < width) { // same test as what we break out of in search loop, so only true if we break'd
+ // find next byte after run
+ while (r < width && comp[r] == comp[x])
+ ++r;
+ // output run up to r
+ while (x < r) {
+ int len = r-x;
+ if (len > 127) len = 127;
+ stbiw__write_run_data(s, len, comp[x]);
+ x += len;
+ }
+ }
+ }
+ }
+ }
+}
+
+static int stbi_write_hdr_core(stbi__write_context *s, int x, int y, int comp, float *data)
+{
+ if (y <= 0 || x <= 0 || data == NULL)
+ return 0;
+ else {
+ // Each component is stored separately. Allocate scratch space for full output scanline.
+ unsigned char *scratch = (unsigned char *) STBIW_MALLOC(x*4);
+ int i, len;
+ char buffer[128];
+ char header[] = "#?RADIANCE\n# Written by stb_image_write.h\nFORMAT=32-bit_rle_rgbe\n";
+ s->func(s->context, header, sizeof(header)-1);
+
+#ifdef __STDC_LIB_EXT1__
+ len = sprintf_s(buffer, sizeof(buffer), "EXPOSURE= 1.0000000000000\n\n-Y %d +X %d\n", y, x);
+#else
+ len = sprintf(buffer, "EXPOSURE= 1.0000000000000\n\n-Y %d +X %d\n", y, x);
+#endif
+ s->func(s->context, buffer, len);
+
+ for(i=0; i < y; i++)
+ stbiw__write_hdr_scanline(s, x, comp, scratch, data + comp*x*(stbi__flip_vertically_on_write ? y-1-i : i));
+ STBIW_FREE(scratch);
+ return 1;
+ }
+}
+
+STBIWDEF int stbi_write_hdr_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const float *data)
+{
+ stbi__write_context s = { 0 };
+ stbi__start_write_callbacks(&s, func, context);
+ return stbi_write_hdr_core(&s, x, y, comp, (float *) data);
+}
+
+STBIWDEF int stbi_write_hdr(char const *filename, int x, int y, int comp, const float *data)
+{
+ stbi__write_context s = { 0 };
+ if (stbi__start_write_file(&s,filename)) {
+ int r = stbi_write_hdr_core(&s, x, y, comp, (float *) data);
+ stbi__end_write_file(&s);
+ return r;
+ } else
+ return 0;
+}
+#endif // STBI_WRITE_NO_STDIO
+
+
+//////////////////////////////////////////////////////////////////////////////
+//
+// PNG writer
+//
+
+#ifndef STBIW_ZLIB_COMPRESS
+// stretchy buffer; stbiw__sbpush() == vector<>::push_back() -- stbiw__sbcount() == vector<>::size()
+#define stbiw__sbraw(a) ((int *) (void *) (a) - 2)
+#define stbiw__sbm(a) stbiw__sbraw(a)[0]
+#define stbiw__sbn(a) stbiw__sbraw(a)[1]
+
+#define stbiw__sbneedgrow(a,n) ((a)==0 || stbiw__sbn(a)+n >= stbiw__sbm(a))
+#define stbiw__sbmaybegrow(a,n) (stbiw__sbneedgrow(a,(n)) ? stbiw__sbgrow(a,n) : 0)
+#define stbiw__sbgrow(a,n) stbiw__sbgrowf((void **) &(a), (n), sizeof(*(a)))
+
+#define stbiw__sbpush(a, v) (stbiw__sbmaybegrow(a,1), (a)[stbiw__sbn(a)++] = (v))
+#define stbiw__sbcount(a) ((a) ? stbiw__sbn(a) : 0)
+#define stbiw__sbfree(a) ((a) ? STBIW_FREE(stbiw__sbraw(a)),0 : 0)
+
+static void *stbiw__sbgrowf(void **arr, int increment, int itemsize)
+{
+ int m = *arr ? 2*stbiw__sbm(*arr)+increment : increment+1;
+ void *p = STBIW_REALLOC_SIZED(*arr ? stbiw__sbraw(*arr) : 0, *arr ? (stbiw__sbm(*arr)*itemsize + sizeof(int)*2) : 0, itemsize * m + sizeof(int)*2);
+ STBIW_ASSERT(p);
+ if (p) {
+ if (!*arr) ((int *) p)[1] = 0;
+ *arr = (void *) ((int *) p + 2);
+ stbiw__sbm(*arr) = m;
+ }
+ return *arr;
+}
+
+static unsigned char *stbiw__zlib_flushf(unsigned char *data, unsigned int *bitbuffer, int *bitcount)
+{
+ while (*bitcount >= 8) {
+ stbiw__sbpush(data, STBIW_UCHAR(*bitbuffer));
+ *bitbuffer >>= 8;
+ *bitcount -= 8;
+ }
+ return data;
+}
+
+static int stbiw__zlib_bitrev(int code, int codebits)
+{
+ int res=0;
+ while (codebits--) {
+ res = (res << 1) | (code & 1);
+ code >>= 1;
+ }
+ return res;
+}
+
+static unsigned int stbiw__zlib_countm(unsigned char *a, unsigned char *b, int limit)
+{
+ int i;
+ for (i=0; i < limit && i < 258; ++i)
+ if (a[i] != b[i]) break;
+ return i;
+}
+
+static unsigned int stbiw__zhash(unsigned char *data)
+{
+ stbiw_uint32 hash = data[0] + (data[1] << 8) + (data[2] << 16);
+ hash ^= hash << 3;
+ hash += hash >> 5;
+ hash ^= hash << 4;
+ hash += hash >> 17;
+ hash ^= hash << 25;
+ hash += hash >> 6;
+ return hash;
+}
+
+#define stbiw__zlib_flush() (out = stbiw__zlib_flushf(out, &bitbuf, &bitcount))
+#define stbiw__zlib_add(code,codebits) \
+ (bitbuf |= (code) << bitcount, bitcount += (codebits), stbiw__zlib_flush())
+#define stbiw__zlib_huffa(b,c) stbiw__zlib_add(stbiw__zlib_bitrev(b,c),c)
+// default huffman tables
+#define stbiw__zlib_huff1(n) stbiw__zlib_huffa(0x30 + (n), 8)
+#define stbiw__zlib_huff2(n) stbiw__zlib_huffa(0x190 + (n)-144, 9)
+#define stbiw__zlib_huff3(n) stbiw__zlib_huffa(0 + (n)-256,7)
+#define stbiw__zlib_huff4(n) stbiw__zlib_huffa(0xc0 + (n)-280,8)
+#define stbiw__zlib_huff(n) ((n) <= 143 ? stbiw__zlib_huff1(n) : (n) <= 255 ? stbiw__zlib_huff2(n) : (n) <= 279 ? stbiw__zlib_huff3(n) : stbiw__zlib_huff4(n))
+#define stbiw__zlib_huffb(n) ((n) <= 143 ? stbiw__zlib_huff1(n) : stbiw__zlib_huff2(n))
+
+#define stbiw__ZHASH 16384
+
+#endif // STBIW_ZLIB_COMPRESS
+
+STBIWDEF unsigned char * stbi_zlib_compress(unsigned char *data, int data_len, int *out_len, int quality)
+{
+#ifdef STBIW_ZLIB_COMPRESS
+ // user provided a zlib compress implementation, use that
+ return STBIW_ZLIB_COMPRESS(data, data_len, out_len, quality);
+#else // use builtin
+ static unsigned short lengthc[] = { 3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258, 259 };
+ static unsigned char lengtheb[]= { 0,0,0,0,0,0,0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0 };
+ static unsigned short distc[] = { 1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577, 32768 };
+ static unsigned char disteb[] = { 0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13 };
+ unsigned int bitbuf=0;
+ int i,j, bitcount=0;
+ unsigned char *out = NULL;
+ unsigned char ***hash_table = (unsigned char***) STBIW_MALLOC(stbiw__ZHASH * sizeof(unsigned char**));
+ if (hash_table == NULL)
+ return NULL;
+ if (quality < 5) quality = 5;
+
+ stbiw__sbpush(out, 0x78); // DEFLATE 32K window
+ stbiw__sbpush(out, 0x5e); // FLEVEL = 1
+ stbiw__zlib_add(1,1); // BFINAL = 1
+ stbiw__zlib_add(1,2); // BTYPE = 1 -- fixed huffman
+
+ for (i=0; i < stbiw__ZHASH; ++i)
+ hash_table[i] = NULL;
+
+ i=0;
+ while (i < data_len-3) {
+ // hash next 3 bytes of data to be compressed
+ int h = stbiw__zhash(data+i)&(stbiw__ZHASH-1), best=3;
+ unsigned char *bestloc = 0;
+ unsigned char **hlist = hash_table[h];
+ int n = stbiw__sbcount(hlist);
+ for (j=0; j < n; ++j) {
+ if (hlist[j]-data > i-32768) { // if entry lies within window
+ int d = stbiw__zlib_countm(hlist[j], data+i, data_len-i);
+ if (d >= best) { best=d; bestloc=hlist[j]; }
+ }
+ }
+ // when hash table entry is too long, delete half the entries
+ if (hash_table[h] && stbiw__sbn(hash_table[h]) == 2*quality) {
+ STBIW_MEMMOVE(hash_table[h], hash_table[h]+quality, sizeof(hash_table[h][0])*quality);
+ stbiw__sbn(hash_table[h]) = quality;
+ }
+ stbiw__sbpush(hash_table[h],data+i);
+
+ if (bestloc) {
+ // "lazy matching" - check match at *next* byte, and if it's better, do cur byte as literal
+ h = stbiw__zhash(data+i+1)&(stbiw__ZHASH-1);
+ hlist = hash_table[h];
+ n = stbiw__sbcount(hlist);
+ for (j=0; j < n; ++j) {
+ if (hlist[j]-data > i-32767) {
+ int e = stbiw__zlib_countm(hlist[j], data+i+1, data_len-i-1);
+ if (e > best) { // if next match is better, bail on current match
+ bestloc = NULL;
+ break;
+ }
+ }
+ }
+ }
+
+ if (bestloc) {
+ int d = (int) (data+i - bestloc); // distance back
+ STBIW_ASSERT(d <= 32767 && best <= 258);
+ for (j=0; best > lengthc[j+1]-1; ++j);
+ stbiw__zlib_huff(j+257);
+ if (lengtheb[j]) stbiw__zlib_add(best - lengthc[j], lengtheb[j]);
+ for (j=0; d > distc[j+1]-1; ++j);
+ stbiw__zlib_add(stbiw__zlib_bitrev(j,5),5);
+ if (disteb[j]) stbiw__zlib_add(d - distc[j], disteb[j]);
+ i += best;
+ } else {
+ stbiw__zlib_huffb(data[i]);
+ ++i;
+ }
+ }
+ // write out final bytes
+ for (;i < data_len; ++i)
+ stbiw__zlib_huffb(data[i]);
+ stbiw__zlib_huff(256); // end of block
+ // pad with 0 bits to byte boundary
+ while (bitcount)
+ stbiw__zlib_add(0,1);
+
+ for (i=0; i < stbiw__ZHASH; ++i)
+ (void) stbiw__sbfree(hash_table[i]);
+ STBIW_FREE(hash_table);
+
+ // store uncompressed instead if compression was worse
+ if (stbiw__sbn(out) > data_len + 2 + ((data_len+32766)/32767)*5) {
+ stbiw__sbn(out) = 2; // truncate to DEFLATE 32K window and FLEVEL = 1
+ for (j = 0; j < data_len;) {
+ int blocklen = data_len - j;
+ if (blocklen > 32767) blocklen = 32767;
+ stbiw__sbpush(out, data_len - j == blocklen); // BFINAL = ?, BTYPE = 0 -- no compression
+ stbiw__sbpush(out, STBIW_UCHAR(blocklen)); // LEN
+ stbiw__sbpush(out, STBIW_UCHAR(blocklen >> 8));
+ stbiw__sbpush(out, STBIW_UCHAR(~blocklen)); // NLEN
+ stbiw__sbpush(out, STBIW_UCHAR(~blocklen >> 8));
+ memcpy(out+stbiw__sbn(out), data+j, blocklen);
+ stbiw__sbn(out) += blocklen;
+ j += blocklen;
+ }
+ }
+
+ {
+ // compute adler32 on input
+ unsigned int s1=1, s2=0;
+ int blocklen = (int) (data_len % 5552);
+ j=0;
+ while (j < data_len) {
+ for (i=0; i < blocklen; ++i) { s1 += data[j+i]; s2 += s1; }
+ s1 %= 65521; s2 %= 65521;
+ j += blocklen;
+ blocklen = 5552;
+ }
+ stbiw__sbpush(out, STBIW_UCHAR(s2 >> 8));
+ stbiw__sbpush(out, STBIW_UCHAR(s2));
+ stbiw__sbpush(out, STBIW_UCHAR(s1 >> 8));
+ stbiw__sbpush(out, STBIW_UCHAR(s1));
+ }
+ *out_len = stbiw__sbn(out);
+ // make returned pointer freeable
+ STBIW_MEMMOVE(stbiw__sbraw(out), out, *out_len);
+ return (unsigned char *) stbiw__sbraw(out);
+#endif // STBIW_ZLIB_COMPRESS
+}
+
+static unsigned int stbiw__crc32(unsigned char *buffer, int len)
+{
+#ifdef STBIW_CRC32
+ return STBIW_CRC32(buffer, len);
+#else
+ static unsigned int crc_table[256] =
+ {
+ 0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA, 0x076DC419, 0x706AF48F, 0xE963A535, 0x9E6495A3,
+ 0x0eDB8832, 0x79DCB8A4, 0xE0D5E91E, 0x97D2D988, 0x09B64C2B, 0x7EB17CBD, 0xE7B82D07, 0x90BF1D91,
+ 0x1DB71064, 0x6AB020F2, 0xF3B97148, 0x84BE41DE, 0x1ADAD47D, 0x6DDDE4EB, 0xF4D4B551, 0x83D385C7,
+ 0x136C9856, 0x646BA8C0, 0xFD62F97A, 0x8A65C9EC, 0x14015C4F, 0x63066CD9, 0xFA0F3D63, 0x8D080DF5,
+ 0x3B6E20C8, 0x4C69105E, 0xD56041E4, 0xA2677172, 0x3C03E4D1, 0x4B04D447, 0xD20D85FD, 0xA50AB56B,
+ 0x35B5A8FA, 0x42B2986C, 0xDBBBC9D6, 0xACBCF940, 0x32D86CE3, 0x45DF5C75, 0xDCD60DCF, 0xABD13D59,
+ 0x26D930AC, 0x51DE003A, 0xC8D75180, 0xBFD06116, 0x21B4F4B5, 0x56B3C423, 0xCFBA9599, 0xB8BDA50F,
+ 0x2802B89E, 0x5F058808, 0xC60CD9B2, 0xB10BE924, 0x2F6F7C87, 0x58684C11, 0xC1611DAB, 0xB6662D3D,
+ 0x76DC4190, 0x01DB7106, 0x98D220BC, 0xEFD5102A, 0x71B18589, 0x06B6B51F, 0x9FBFE4A5, 0xE8B8D433,
+ 0x7807C9A2, 0x0F00F934, 0x9609A88E, 0xE10E9818, 0x7F6A0DBB, 0x086D3D2D, 0x91646C97, 0xE6635C01,
+ 0x6B6B51F4, 0x1C6C6162, 0x856530D8, 0xF262004E, 0x6C0695ED, 0x1B01A57B, 0x8208F4C1, 0xF50FC457,
+ 0x65B0D9C6, 0x12B7E950, 0x8BBEB8EA, 0xFCB9887C, 0x62DD1DDF, 0x15DA2D49, 0x8CD37CF3, 0xFBD44C65,
+ 0x4DB26158, 0x3AB551CE, 0xA3BC0074, 0xD4BB30E2, 0x4ADFA541, 0x3DD895D7, 0xA4D1C46D, 0xD3D6F4FB,
+ 0x4369E96A, 0x346ED9FC, 0xAD678846, 0xDA60B8D0, 0x44042D73, 0x33031DE5, 0xAA0A4C5F, 0xDD0D7CC9,
+ 0x5005713C, 0x270241AA, 0xBE0B1010, 0xC90C2086, 0x5768B525, 0x206F85B3, 0xB966D409, 0xCE61E49F,
+ 0x5EDEF90E, 0x29D9C998, 0xB0D09822, 0xC7D7A8B4, 0x59B33D17, 0x2EB40D81, 0xB7BD5C3B, 0xC0BA6CAD,
+ 0xEDB88320, 0x9ABFB3B6, 0x03B6E20C, 0x74B1D29A, 0xEAD54739, 0x9DD277AF, 0x04DB2615, 0x73DC1683,
+ 0xE3630B12, 0x94643B84, 0x0D6D6A3E, 0x7A6A5AA8, 0xE40ECF0B, 0x9309FF9D, 0x0A00AE27, 0x7D079EB1,
+ 0xF00F9344, 0x8708A3D2, 0x1E01F268, 0x6906C2FE, 0xF762575D, 0x806567CB, 0x196C3671, 0x6E6B06E7,
+ 0xFED41B76, 0x89D32BE0, 0x10DA7A5A, 0x67DD4ACC, 0xF9B9DF6F, 0x8EBEEFF9, 0x17B7BE43, 0x60B08ED5,
+ 0xD6D6A3E8, 0xA1D1937E, 0x38D8C2C4, 0x4FDFF252, 0xD1BB67F1, 0xA6BC5767, 0x3FB506DD, 0x48B2364B,
+ 0xD80D2BDA, 0xAF0A1B4C, 0x36034AF6, 0x41047A60, 0xDF60EFC3, 0xA867DF55, 0x316E8EEF, 0x4669BE79,
+ 0xCB61B38C, 0xBC66831A, 0x256FD2A0, 0x5268E236, 0xCC0C7795, 0xBB0B4703, 0x220216B9, 0x5505262F,
+ 0xC5BA3BBE, 0xB2BD0B28, 0x2BB45A92, 0x5CB36A04, 0xC2D7FFA7, 0xB5D0CF31, 0x2CD99E8B, 0x5BDEAE1D,
+ 0x9B64C2B0, 0xEC63F226, 0x756AA39C, 0x026D930A, 0x9C0906A9, 0xEB0E363F, 0x72076785, 0x05005713,
+ 0x95BF4A82, 0xE2B87A14, 0x7BB12BAE, 0x0CB61B38, 0x92D28E9B, 0xE5D5BE0D, 0x7CDCEFB7, 0x0BDBDF21,
+ 0x86D3D2D4, 0xF1D4E242, 0x68DDB3F8, 0x1FDA836E, 0x81BE16CD, 0xF6B9265B, 0x6FB077E1, 0x18B74777,
+ 0x88085AE6, 0xFF0F6A70, 0x66063BCA, 0x11010B5C, 0x8F659EFF, 0xF862AE69, 0x616BFFD3, 0x166CCF45,
+ 0xA00AE278, 0xD70DD2EE, 0x4E048354, 0x3903B3C2, 0xA7672661, 0xD06016F7, 0x4969474D, 0x3E6E77DB,
+ 0xAED16A4A, 0xD9D65ADC, 0x40DF0B66, 0x37D83BF0, 0xA9BCAE53, 0xDEBB9EC5, 0x47B2CF7F, 0x30B5FFE9,
+ 0xBDBDF21C, 0xCABAC28A, 0x53B39330, 0x24B4A3A6, 0xBAD03605, 0xCDD70693, 0x54DE5729, 0x23D967BF,
+ 0xB3667A2E, 0xC4614AB8, 0x5D681B02, 0x2A6F2B94, 0xB40BBE37, 0xC30C8EA1, 0x5A05DF1B, 0x2D02EF8D
+ };
+
+ unsigned int crc = ~0u;
+ int i;
+ for (i=0; i < len; ++i)
+ crc = (crc >> 8) ^ crc_table[buffer[i] ^ (crc & 0xff)];
+ return ~crc;
+#endif
+}
+
+#define stbiw__wpng4(o,a,b,c,d) ((o)[0]=STBIW_UCHAR(a),(o)[1]=STBIW_UCHAR(b),(o)[2]=STBIW_UCHAR(c),(o)[3]=STBIW_UCHAR(d),(o)+=4)
+#define stbiw__wp32(data,v) stbiw__wpng4(data, (v)>>24,(v)>>16,(v)>>8,(v));
+#define stbiw__wptag(data,s) stbiw__wpng4(data, s[0],s[1],s[2],s[3])
+
+static void stbiw__wpcrc(unsigned char **data, int len)
+{
+ unsigned int crc = stbiw__crc32(*data - len - 4, len+4);
+ stbiw__wp32(*data, crc);
+}
+
+static unsigned char stbiw__paeth(int a, int b, int c)
+{
+ int p = a + b - c, pa = abs(p-a), pb = abs(p-b), pc = abs(p-c);
+ if (pa <= pb && pa <= pc) return STBIW_UCHAR(a);
+ if (pb <= pc) return STBIW_UCHAR(b);
+ return STBIW_UCHAR(c);
+}
+
+// @OPTIMIZE: provide an option that always forces left-predict or paeth predict
+static void stbiw__encode_png_line(unsigned char *pixels, int stride_bytes, int width, int height, int y, int n, int filter_type, signed char *line_buffer)
+{
+ static int mapping[] = { 0,1,2,3,4 };
+ static int firstmap[] = { 0,1,0,5,6 };
+ int *mymap = (y != 0) ? mapping : firstmap;
+ int i;
+ int type = mymap[filter_type];
+ unsigned char *z = pixels + stride_bytes * (stbi__flip_vertically_on_write ? height-1-y : y);
+ int signed_stride = stbi__flip_vertically_on_write ? -stride_bytes : stride_bytes;
+
+ if (type==0) {
+ memcpy(line_buffer, z, width*n);
+ return;
+ }
+
+ // first loop isn't optimized since it's just one pixel
+ for (i = 0; i < n; ++i) {
+ switch (type) {
+ case 1: line_buffer[i] = z[i]; break;
+ case 2: line_buffer[i] = z[i] - z[i-signed_stride]; break;
+ case 3: line_buffer[i] = z[i] - (z[i-signed_stride]>>1); break;
+ case 4: line_buffer[i] = (signed char) (z[i] - stbiw__paeth(0,z[i-signed_stride],0)); break;
+ case 5: line_buffer[i] = z[i]; break;
+ case 6: line_buffer[i] = z[i]; break;
+ }
+ }
+ switch (type) {
+ case 1: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - z[i-n]; break;
+ case 2: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - z[i-signed_stride]; break;
+ case 3: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - ((z[i-n] + z[i-signed_stride])>>1); break;
+ case 4: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - stbiw__paeth(z[i-n], z[i-signed_stride], z[i-signed_stride-n]); break;
+ case 5: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - (z[i-n]>>1); break;
+ case 6: for (i=n; i < width*n; ++i) line_buffer[i] = z[i] - stbiw__paeth(z[i-n], 0,0); break;
+ }
+}
+
+STBIWDEF unsigned char *stbi_write_png_to_mem(const unsigned char *pixels, int stride_bytes, int x, int y, int n, int *out_len)
+{
+ int force_filter = stbi_write_force_png_filter;
+ int ctype[5] = { -1, 0, 4, 2, 6 };
+ unsigned char sig[8] = { 137,80,78,71,13,10,26,10 };
+ unsigned char *out,*o, *filt, *zlib;
+ signed char *line_buffer;
+ int j,zlen;
+
+ if (stride_bytes == 0)
+ stride_bytes = x * n;
+
+ if (force_filter >= 5) {
+ force_filter = -1;
+ }
+
+ filt = (unsigned char *) STBIW_MALLOC((x*n+1) * y); if (!filt) return 0;
+ line_buffer = (signed char *) STBIW_MALLOC(x * n); if (!line_buffer) { STBIW_FREE(filt); return 0; }
+ for (j=0; j < y; ++j) {
+ int filter_type;
+ if (force_filter > -1) {
+ filter_type = force_filter;
+ stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, force_filter, line_buffer);
+ } else { // Estimate the best filter by running through all of them:
+ int best_filter = 0, best_filter_val = 0x7fffffff, est, i;
+ for (filter_type = 0; filter_type < 5; filter_type++) {
+ stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, filter_type, line_buffer);
+
+ // Estimate the entropy of the line using this filter; the less, the better.
+ est = 0;
+ for (i = 0; i < x*n; ++i) {
+ est += abs((signed char) line_buffer[i]);
+ }
+ if (est < best_filter_val) {
+ best_filter_val = est;
+ best_filter = filter_type;
+ }
+ }
+ if (filter_type != best_filter) { // If the last iteration already got us the best filter, don't redo it
+ stbiw__encode_png_line((unsigned char*)(pixels), stride_bytes, x, y, j, n, best_filter, line_buffer);
+ filter_type = best_filter;
+ }
+ }
+ // when we get here, filter_type contains the filter type, and line_buffer contains the data
+ filt[j*(x*n+1)] = (unsigned char) filter_type;
+ STBIW_MEMMOVE(filt+j*(x*n+1)+1, line_buffer, x*n);
+ }
+ STBIW_FREE(line_buffer);
+ zlib = stbi_zlib_compress(filt, y*( x*n+1), &zlen, stbi_write_png_compression_level);
+ STBIW_FREE(filt);
+ if (!zlib) return 0;
+
+ // each tag requires 12 bytes of overhead
+ out = (unsigned char *) STBIW_MALLOC(8 + 12+13 + 12+zlen + 12);
+ if (!out) return 0;
+ *out_len = 8 + 12+13 + 12+zlen + 12;
+
+ o=out;
+ STBIW_MEMMOVE(o,sig,8); o+= 8;
+ stbiw__wp32(o, 13); // header length
+ stbiw__wptag(o, "IHDR");
+ stbiw__wp32(o, x);
+ stbiw__wp32(o, y);
+ *o++ = 8;
+ *o++ = STBIW_UCHAR(ctype[n]);
+ *o++ = 0;
+ *o++ = 0;
+ *o++ = 0;
+ stbiw__wpcrc(&o,13);
+
+ stbiw__wp32(o, zlen);
+ stbiw__wptag(o, "IDAT");
+ STBIW_MEMMOVE(o, zlib, zlen);
+ o += zlen;
+ STBIW_FREE(zlib);
+ stbiw__wpcrc(&o, zlen);
+
+ stbiw__wp32(o,0);
+ stbiw__wptag(o, "IEND");
+ stbiw__wpcrc(&o,0);
+
+ STBIW_ASSERT(o == out + *out_len);
+
+ return out;
+}
+
+#ifndef STBI_WRITE_NO_STDIO
+STBIWDEF int stbi_write_png(char const *filename, int x, int y, int comp, const void *data, int stride_bytes)
+{
+ FILE *f;
+ int len;
+ unsigned char *png = stbi_write_png_to_mem((const unsigned char *) data, stride_bytes, x, y, comp, &len);
+ if (png == NULL) return 0;
+
+ f = stbiw__fopen(filename, "wb");
+ if (!f) { STBIW_FREE(png); return 0; }
+ fwrite(png, 1, len, f);
+ fclose(f);
+ STBIW_FREE(png);
+ return 1;
+}
+#endif
+
+STBIWDEF int stbi_write_png_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int stride_bytes)
+{
+ int len;
+ unsigned char *png = stbi_write_png_to_mem((const unsigned char *) data, stride_bytes, x, y, comp, &len);
+ if (png == NULL) return 0;
+ func(context, png, len);
+ STBIW_FREE(png);
+ return 1;
+}
+
+
+/* ***************************************************************************
+ *
+ * JPEG writer
+ *
+ * This is based on Jon Olick's jo_jpeg.cpp:
+ * public domain Simple, Minimalistic JPEG writer - http://www.jonolick.com/code.html
+ */
+
+static const unsigned char stbiw__jpg_ZigZag[] = { 0,1,5,6,14,15,27,28,2,4,7,13,16,26,29,42,3,8,12,17,25,30,41,43,9,11,18,
+ 24,31,40,44,53,10,19,23,32,39,45,52,54,20,22,33,38,46,51,55,60,21,34,37,47,50,56,59,61,35,36,48,49,57,58,62,63 };
+
+static void stbiw__jpg_writeBits(stbi__write_context *s, int *bitBufP, int *bitCntP, const unsigned short *bs) {
+ int bitBuf = *bitBufP, bitCnt = *bitCntP;
+ bitCnt += bs[1];
+ bitBuf |= bs[0] << (24 - bitCnt);
+ while(bitCnt >= 8) {
+ unsigned char c = (bitBuf >> 16) & 255;
+ stbiw__putc(s, c);
+ if(c == 255) {
+ stbiw__putc(s, 0);
+ }
+ bitBuf <<= 8;
+ bitCnt -= 8;
+ }
+ *bitBufP = bitBuf;
+ *bitCntP = bitCnt;
+}
+
+static void stbiw__jpg_DCT(float *d0p, float *d1p, float *d2p, float *d3p, float *d4p, float *d5p, float *d6p, float *d7p) {
+ float d0 = *d0p, d1 = *d1p, d2 = *d2p, d3 = *d3p, d4 = *d4p, d5 = *d5p, d6 = *d6p, d7 = *d7p;
+ float z1, z2, z3, z4, z5, z11, z13;
+
+ float tmp0 = d0 + d7;
+ float tmp7 = d0 - d7;
+ float tmp1 = d1 + d6;
+ float tmp6 = d1 - d6;
+ float tmp2 = d2 + d5;
+ float tmp5 = d2 - d5;
+ float tmp3 = d3 + d4;
+ float tmp4 = d3 - d4;
+
+ // Even part
+ float tmp10 = tmp0 + tmp3; // phase 2
+ float tmp13 = tmp0 - tmp3;
+ float tmp11 = tmp1 + tmp2;
+ float tmp12 = tmp1 - tmp2;
+
+ d0 = tmp10 + tmp11; // phase 3
+ d4 = tmp10 - tmp11;
+
+ z1 = (tmp12 + tmp13) * 0.707106781f; // c4
+ d2 = tmp13 + z1; // phase 5
+ d6 = tmp13 - z1;
+
+ // Odd part
+ tmp10 = tmp4 + tmp5; // phase 2
+ tmp11 = tmp5 + tmp6;
+ tmp12 = tmp6 + tmp7;
+
+ // The rotator is modified from fig 4-8 to avoid extra negations.
+ z5 = (tmp10 - tmp12) * 0.382683433f; // c6
+ z2 = tmp10 * 0.541196100f + z5; // c2-c6
+ z4 = tmp12 * 1.306562965f + z5; // c2+c6
+ z3 = tmp11 * 0.707106781f; // c4
+
+ z11 = tmp7 + z3; // phase 5
+ z13 = tmp7 - z3;
+
+ *d5p = z13 + z2; // phase 6
+ *d3p = z13 - z2;
+ *d1p = z11 + z4;
+ *d7p = z11 - z4;
+
+ *d0p = d0; *d2p = d2; *d4p = d4; *d6p = d6;
+}
+
+static void stbiw__jpg_calcBits(int val, unsigned short bits[2]) {
+ int tmp1 = val < 0 ? -val : val;
+ val = val < 0 ? val-1 : val;
+ bits[1] = 1;
+ while(tmp1 >>= 1) {
+ ++bits[1];
+ }
+ bits[0] = val & ((1<0)&&(DU[end0pos]==0); --end0pos) {
+ }
+ // end0pos = first element in reverse order !=0
+ if(end0pos == 0) {
+ stbiw__jpg_writeBits(s, bitBuf, bitCnt, EOB);
+ return DU[0];
+ }
+ for(i = 1; i <= end0pos; ++i) {
+ int startpos = i;
+ int nrzeroes;
+ unsigned short bits[2];
+ for (; DU[i]==0 && i<=end0pos; ++i) {
+ }
+ nrzeroes = i-startpos;
+ if ( nrzeroes >= 16 ) {
+ int lng = nrzeroes>>4;
+ int nrmarker;
+ for (nrmarker=1; nrmarker <= lng; ++nrmarker)
+ stbiw__jpg_writeBits(s, bitBuf, bitCnt, M16zeroes);
+ nrzeroes &= 15;
+ }
+ stbiw__jpg_calcBits(DU[i], bits);
+ stbiw__jpg_writeBits(s, bitBuf, bitCnt, HTAC[(nrzeroes<<4)+bits[1]]);
+ stbiw__jpg_writeBits(s, bitBuf, bitCnt, bits);
+ }
+ if(end0pos != 63) {
+ stbiw__jpg_writeBits(s, bitBuf, bitCnt, EOB);
+ }
+ return DU[0];
+}
+
+static int stbi_write_jpg_core(stbi__write_context *s, int width, int height, int comp, const void* data, int quality) {
+ // Constants that don't pollute global namespace
+ static const unsigned char std_dc_luminance_nrcodes[] = {0,0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0};
+ static const unsigned char std_dc_luminance_values[] = {0,1,2,3,4,5,6,7,8,9,10,11};
+ static const unsigned char std_ac_luminance_nrcodes[] = {0,0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,0x7d};
+ static const unsigned char std_ac_luminance_values[] = {
+ 0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xa1,0x08,
+ 0x23,0x42,0xb1,0xc1,0x15,0x52,0xd1,0xf0,0x24,0x33,0x62,0x72,0x82,0x09,0x0a,0x16,0x17,0x18,0x19,0x1a,0x25,0x26,0x27,0x28,
+ 0x29,0x2a,0x34,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59,
+ 0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x83,0x84,0x85,0x86,0x87,0x88,0x89,
+ 0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6,
+ 0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,0xe1,0xe2,
+ 0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0xfa
+ };
+ static const unsigned char std_dc_chrominance_nrcodes[] = {0,0,3,1,1,1,1,1,1,1,1,1,0,0,0,0,0};
+ static const unsigned char std_dc_chrominance_values[] = {0,1,2,3,4,5,6,7,8,9,10,11};
+ static const unsigned char std_ac_chrominance_nrcodes[] = {0,0,2,1,2,4,4,3,4,7,5,4,4,0,1,2,0x77};
+ static const unsigned char std_ac_chrominance_values[] = {
+ 0x00,0x01,0x02,0x03,0x11,0x04,0x05,0x21,0x31,0x06,0x12,0x41,0x51,0x07,0x61,0x71,0x13,0x22,0x32,0x81,0x08,0x14,0x42,0x91,
+ 0xa1,0xb1,0xc1,0x09,0x23,0x33,0x52,0xf0,0x15,0x62,0x72,0xd1,0x0a,0x16,0x24,0x34,0xe1,0x25,0xf1,0x17,0x18,0x19,0x1a,0x26,
+ 0x27,0x28,0x29,0x2a,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,
+ 0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x82,0x83,0x84,0x85,0x86,0x87,
+ 0x88,0x89,0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,
+ 0xb5,0xb6,0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,
+ 0xe2,0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,0xf9,0xfa
+ };
+ // Huffman tables
+ static const unsigned short YDC_HT[256][2] = { {0,2},{2,3},{3,3},{4,3},{5,3},{6,3},{14,4},{30,5},{62,6},{126,7},{254,8},{510,9}};
+ static const unsigned short UVDC_HT[256][2] = { {0,2},{1,2},{2,2},{6,3},{14,4},{30,5},{62,6},{126,7},{254,8},{510,9},{1022,10},{2046,11}};
+ static const unsigned short YAC_HT[256][2] = {
+ {10,4},{0,2},{1,2},{4,3},{11,4},{26,5},{120,7},{248,8},{1014,10},{65410,16},{65411,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {12,4},{27,5},{121,7},{502,9},{2038,11},{65412,16},{65413,16},{65414,16},{65415,16},{65416,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {28,5},{249,8},{1015,10},{4084,12},{65417,16},{65418,16},{65419,16},{65420,16},{65421,16},{65422,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {58,6},{503,9},{4085,12},{65423,16},{65424,16},{65425,16},{65426,16},{65427,16},{65428,16},{65429,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {59,6},{1016,10},{65430,16},{65431,16},{65432,16},{65433,16},{65434,16},{65435,16},{65436,16},{65437,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {122,7},{2039,11},{65438,16},{65439,16},{65440,16},{65441,16},{65442,16},{65443,16},{65444,16},{65445,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {123,7},{4086,12},{65446,16},{65447,16},{65448,16},{65449,16},{65450,16},{65451,16},{65452,16},{65453,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {250,8},{4087,12},{65454,16},{65455,16},{65456,16},{65457,16},{65458,16},{65459,16},{65460,16},{65461,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {504,9},{32704,15},{65462,16},{65463,16},{65464,16},{65465,16},{65466,16},{65467,16},{65468,16},{65469,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {505,9},{65470,16},{65471,16},{65472,16},{65473,16},{65474,16},{65475,16},{65476,16},{65477,16},{65478,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {506,9},{65479,16},{65480,16},{65481,16},{65482,16},{65483,16},{65484,16},{65485,16},{65486,16},{65487,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {1017,10},{65488,16},{65489,16},{65490,16},{65491,16},{65492,16},{65493,16},{65494,16},{65495,16},{65496,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {1018,10},{65497,16},{65498,16},{65499,16},{65500,16},{65501,16},{65502,16},{65503,16},{65504,16},{65505,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {2040,11},{65506,16},{65507,16},{65508,16},{65509,16},{65510,16},{65511,16},{65512,16},{65513,16},{65514,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {65515,16},{65516,16},{65517,16},{65518,16},{65519,16},{65520,16},{65521,16},{65522,16},{65523,16},{65524,16},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {2041,11},{65525,16},{65526,16},{65527,16},{65528,16},{65529,16},{65530,16},{65531,16},{65532,16},{65533,16},{65534,16},{0,0},{0,0},{0,0},{0,0},{0,0}
+ };
+ static const unsigned short UVAC_HT[256][2] = {
+ {0,2},{1,2},{4,3},{10,4},{24,5},{25,5},{56,6},{120,7},{500,9},{1014,10},{4084,12},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {11,4},{57,6},{246,8},{501,9},{2038,11},{4085,12},{65416,16},{65417,16},{65418,16},{65419,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {26,5},{247,8},{1015,10},{4086,12},{32706,15},{65420,16},{65421,16},{65422,16},{65423,16},{65424,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {27,5},{248,8},{1016,10},{4087,12},{65425,16},{65426,16},{65427,16},{65428,16},{65429,16},{65430,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {58,6},{502,9},{65431,16},{65432,16},{65433,16},{65434,16},{65435,16},{65436,16},{65437,16},{65438,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {59,6},{1017,10},{65439,16},{65440,16},{65441,16},{65442,16},{65443,16},{65444,16},{65445,16},{65446,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {121,7},{2039,11},{65447,16},{65448,16},{65449,16},{65450,16},{65451,16},{65452,16},{65453,16},{65454,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {122,7},{2040,11},{65455,16},{65456,16},{65457,16},{65458,16},{65459,16},{65460,16},{65461,16},{65462,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {249,8},{65463,16},{65464,16},{65465,16},{65466,16},{65467,16},{65468,16},{65469,16},{65470,16},{65471,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {503,9},{65472,16},{65473,16},{65474,16},{65475,16},{65476,16},{65477,16},{65478,16},{65479,16},{65480,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {504,9},{65481,16},{65482,16},{65483,16},{65484,16},{65485,16},{65486,16},{65487,16},{65488,16},{65489,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {505,9},{65490,16},{65491,16},{65492,16},{65493,16},{65494,16},{65495,16},{65496,16},{65497,16},{65498,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {506,9},{65499,16},{65500,16},{65501,16},{65502,16},{65503,16},{65504,16},{65505,16},{65506,16},{65507,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {2041,11},{65508,16},{65509,16},{65510,16},{65511,16},{65512,16},{65513,16},{65514,16},{65515,16},{65516,16},{0,0},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {16352,14},{65517,16},{65518,16},{65519,16},{65520,16},{65521,16},{65522,16},{65523,16},{65524,16},{65525,16},{0,0},{0,0},{0,0},{0,0},{0,0},
+ {1018,10},{32707,15},{65526,16},{65527,16},{65528,16},{65529,16},{65530,16},{65531,16},{65532,16},{65533,16},{65534,16},{0,0},{0,0},{0,0},{0,0},{0,0}
+ };
+ static const int YQT[] = {16,11,10,16,24,40,51,61,12,12,14,19,26,58,60,55,14,13,16,24,40,57,69,56,14,17,22,29,51,87,80,62,18,22,
+ 37,56,68,109,103,77,24,35,55,64,81,104,113,92,49,64,78,87,103,121,120,101,72,92,95,98,112,100,103,99};
+ static const int UVQT[] = {17,18,24,47,99,99,99,99,18,21,26,66,99,99,99,99,24,26,56,99,99,99,99,99,47,66,99,99,99,99,99,99,
+ 99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99};
+ static const float aasf[] = { 1.0f * 2.828427125f, 1.387039845f * 2.828427125f, 1.306562965f * 2.828427125f, 1.175875602f * 2.828427125f,
+ 1.0f * 2.828427125f, 0.785694958f * 2.828427125f, 0.541196100f * 2.828427125f, 0.275899379f * 2.828427125f };
+
+ int row, col, i, k, subsample;
+ float fdtbl_Y[64], fdtbl_UV[64];
+ unsigned char YTable[64], UVTable[64];
+
+ if(!data || !width || !height || comp > 4 || comp < 1) {
+ return 0;
+ }
+
+ quality = quality ? quality : 90;
+ subsample = quality <= 90 ? 1 : 0;
+ quality = quality < 1 ? 1 : quality > 100 ? 100 : quality;
+ quality = quality < 50 ? 5000 / quality : 200 - quality * 2;
+
+ for(i = 0; i < 64; ++i) {
+ int uvti, yti = (YQT[i]*quality+50)/100;
+ YTable[stbiw__jpg_ZigZag[i]] = (unsigned char) (yti < 1 ? 1 : yti > 255 ? 255 : yti);
+ uvti = (UVQT[i]*quality+50)/100;
+ UVTable[stbiw__jpg_ZigZag[i]] = (unsigned char) (uvti < 1 ? 1 : uvti > 255 ? 255 : uvti);
+ }
+
+ for(row = 0, k = 0; row < 8; ++row) {
+ for(col = 0; col < 8; ++col, ++k) {
+ fdtbl_Y[k] = 1 / (YTable [stbiw__jpg_ZigZag[k]] * aasf[row] * aasf[col]);
+ fdtbl_UV[k] = 1 / (UVTable[stbiw__jpg_ZigZag[k]] * aasf[row] * aasf[col]);
+ }
+ }
+
+ // Write Headers
+ {
+ static const unsigned char head0[] = { 0xFF,0xD8,0xFF,0xE0,0,0x10,'J','F','I','F',0,1,1,0,0,1,0,1,0,0,0xFF,0xDB,0,0x84,0 };
+ static const unsigned char head2[] = { 0xFF,0xDA,0,0xC,3,1,0,2,0x11,3,0x11,0,0x3F,0 };
+ const unsigned char head1[] = { 0xFF,0xC0,0,0x11,8,(unsigned char)(height>>8),STBIW_UCHAR(height),(unsigned char)(width>>8),STBIW_UCHAR(width),
+ 3,1,(unsigned char)(subsample?0x22:0x11),0,2,0x11,1,3,0x11,1,0xFF,0xC4,0x01,0xA2,0 };
+ s->func(s->context, (void*)head0, sizeof(head0));
+ s->func(s->context, (void*)YTable, sizeof(YTable));
+ stbiw__putc(s, 1);
+ s->func(s->context, UVTable, sizeof(UVTable));
+ s->func(s->context, (void*)head1, sizeof(head1));
+ s->func(s->context, (void*)(std_dc_luminance_nrcodes+1), sizeof(std_dc_luminance_nrcodes)-1);
+ s->func(s->context, (void*)std_dc_luminance_values, sizeof(std_dc_luminance_values));
+ stbiw__putc(s, 0x10); // HTYACinfo
+ s->func(s->context, (void*)(std_ac_luminance_nrcodes+1), sizeof(std_ac_luminance_nrcodes)-1);
+ s->func(s->context, (void*)std_ac_luminance_values, sizeof(std_ac_luminance_values));
+ stbiw__putc(s, 1); // HTUDCinfo
+ s->func(s->context, (void*)(std_dc_chrominance_nrcodes+1), sizeof(std_dc_chrominance_nrcodes)-1);
+ s->func(s->context, (void*)std_dc_chrominance_values, sizeof(std_dc_chrominance_values));
+ stbiw__putc(s, 0x11); // HTUACinfo
+ s->func(s->context, (void*)(std_ac_chrominance_nrcodes+1), sizeof(std_ac_chrominance_nrcodes)-1);
+ s->func(s->context, (void*)std_ac_chrominance_values, sizeof(std_ac_chrominance_values));
+ s->func(s->context, (void*)head2, sizeof(head2));
+ }
+
+ // Encode 8x8 macroblocks
+ {
+ static const unsigned short fillBits[] = {0x7F, 7};
+ int DCY=0, DCU=0, DCV=0;
+ int bitBuf=0, bitCnt=0;
+ // comp == 2 is grey+alpha (alpha is ignored)
+ int ofsG = comp > 2 ? 1 : 0, ofsB = comp > 2 ? 2 : 0;
+ const unsigned char *dataR = (const unsigned char *)data;
+ const unsigned char *dataG = dataR + ofsG;
+ const unsigned char *dataB = dataR + ofsB;
+ int x, y, pos;
+ if(subsample) {
+ for(y = 0; y < height; y += 16) {
+ for(x = 0; x < width; x += 16) {
+ float Y[256], U[256], V[256];
+ for(row = y, pos = 0; row < y+16; ++row) {
+ // row >= height => use last input row
+ int clamped_row = (row < height) ? row : height - 1;
+ int base_p = (stbi__flip_vertically_on_write ? (height-1-clamped_row) : clamped_row)*width*comp;
+ for(col = x; col < x+16; ++col, ++pos) {
+ // if col >= width => use pixel from last input column
+ int p = base_p + ((col < width) ? col : (width-1))*comp;
+ float r = dataR[p], g = dataG[p], b = dataB[p];
+ Y[pos]= +0.29900f*r + 0.58700f*g + 0.11400f*b - 128;
+ U[pos]= -0.16874f*r - 0.33126f*g + 0.50000f*b;
+ V[pos]= +0.50000f*r - 0.41869f*g - 0.08131f*b;
+ }
+ }
+ DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+0, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT);
+ DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+8, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT);
+ DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+128, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT);
+ DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y+136, 16, fdtbl_Y, DCY, YDC_HT, YAC_HT);
+
+ // subsample U,V
+ {
+ float subU[64], subV[64];
+ int yy, xx;
+ for(yy = 0, pos = 0; yy < 8; ++yy) {
+ for(xx = 0; xx < 8; ++xx, ++pos) {
+ int j = yy*32+xx*2;
+ subU[pos] = (U[j+0] + U[j+1] + U[j+16] + U[j+17]) * 0.25f;
+ subV[pos] = (V[j+0] + V[j+1] + V[j+16] + V[j+17]) * 0.25f;
+ }
+ }
+ DCU = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, subU, 8, fdtbl_UV, DCU, UVDC_HT, UVAC_HT);
+ DCV = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, subV, 8, fdtbl_UV, DCV, UVDC_HT, UVAC_HT);
+ }
+ }
+ }
+ } else {
+ for(y = 0; y < height; y += 8) {
+ for(x = 0; x < width; x += 8) {
+ float Y[64], U[64], V[64];
+ for(row = y, pos = 0; row < y+8; ++row) {
+ // row >= height => use last input row
+ int clamped_row = (row < height) ? row : height - 1;
+ int base_p = (stbi__flip_vertically_on_write ? (height-1-clamped_row) : clamped_row)*width*comp;
+ for(col = x; col < x+8; ++col, ++pos) {
+ // if col >= width => use pixel from last input column
+ int p = base_p + ((col < width) ? col : (width-1))*comp;
+ float r = dataR[p], g = dataG[p], b = dataB[p];
+ Y[pos]= +0.29900f*r + 0.58700f*g + 0.11400f*b - 128;
+ U[pos]= -0.16874f*r - 0.33126f*g + 0.50000f*b;
+ V[pos]= +0.50000f*r - 0.41869f*g - 0.08131f*b;
+ }
+ }
+
+ DCY = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, Y, 8, fdtbl_Y, DCY, YDC_HT, YAC_HT);
+ DCU = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, U, 8, fdtbl_UV, DCU, UVDC_HT, UVAC_HT);
+ DCV = stbiw__jpg_processDU(s, &bitBuf, &bitCnt, V, 8, fdtbl_UV, DCV, UVDC_HT, UVAC_HT);
+ }
+ }
+ }
+
+ // Do the bit alignment of the EOI marker
+ stbiw__jpg_writeBits(s, &bitBuf, &bitCnt, fillBits);
+ }
+
+ // EOI
+ stbiw__putc(s, 0xFF);
+ stbiw__putc(s, 0xD9);
+
+ return 1;
+}
+
+STBIWDEF int stbi_write_jpg_to_func(stbi_write_func *func, void *context, int x, int y, int comp, const void *data, int quality)
+{
+ stbi__write_context s = { 0 };
+ stbi__start_write_callbacks(&s, func, context);
+ return stbi_write_jpg_core(&s, x, y, comp, (void *) data, quality);
+}
+
+
+#ifndef STBI_WRITE_NO_STDIO
+STBIWDEF int stbi_write_jpg(char const *filename, int x, int y, int comp, const void *data, int quality)
+{
+ stbi__write_context s = { 0 };
+ if (stbi__start_write_file(&s,filename)) {
+ int r = stbi_write_jpg_core(&s, x, y, comp, data, quality);
+ stbi__end_write_file(&s);
+ return r;
+ } else
+ return 0;
+}
+#endif
+
+#endif // STB_IMAGE_WRITE_IMPLEMENTATION
+
+/* Revision history
+ 1.16 (2021-07-11)
+ make Deflate code emit uncompressed blocks when it would otherwise expand
+ support writing BMPs with alpha channel
+ 1.15 (2020-07-13) unknown
+ 1.14 (2020-02-02) updated JPEG writer to downsample chroma channels
+ 1.13
+ 1.12
+ 1.11 (2019-08-11)
+
+ 1.10 (2019-02-07)
+ support utf8 filenames in Windows; fix warnings and platform ifdefs
+ 1.09 (2018-02-11)
+ fix typo in zlib quality API, improve STB_I_W_STATIC in C++
+ 1.08 (2018-01-29)
+ add stbi__flip_vertically_on_write, external zlib, zlib quality, choose PNG filter
+ 1.07 (2017-07-24)
+ doc fix
+ 1.06 (2017-07-23)
+ writing JPEG (using Jon Olick's code)
+ 1.05 ???
+ 1.04 (2017-03-03)
+ monochrome BMP expansion
+ 1.03 ???
+ 1.02 (2016-04-02)
+ avoid allocating large structures on the stack
+ 1.01 (2016-01-16)
+ STBIW_REALLOC_SIZED: support allocators with no realloc support
+ avoid race-condition in crc initialization
+ minor compile issues
+ 1.00 (2015-09-14)
+ installable file IO function
+ 0.99 (2015-09-13)
+ warning fixes; TGA rle support
+ 0.98 (2015-04-08)
+ added STBIW_MALLOC, STBIW_ASSERT etc
+ 0.97 (2015-01-18)
+ fixed HDR asserts, rewrote HDR rle logic
+ 0.96 (2015-01-17)
+ add HDR output
+ fix monochrome BMP
+ 0.95 (2014-08-17)
+ add monochrome TGA output
+ 0.94 (2014-05-31)
+ rename private functions to avoid conflicts with stb_image.h
+ 0.93 (2014-05-27)
+ warning fixes
+ 0.92 (2010-08-01)
+ casts to unsigned char to fix warnings
+ 0.91 (2010-07-17)
+ first public release
+ 0.90 first internal release
+*/
+
+/*
+------------------------------------------------------------------------------
+This software is available under 2 licenses -- choose whichever you prefer.
+------------------------------------------------------------------------------
+ALTERNATIVE A - MIT License
+Copyright (c) 2017 Sean Barrett
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+------------------------------------------------------------------------------
+ALTERNATIVE B - Public Domain (www.unlicense.org)
+This is free and unencumbered software released into the public domain.
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+------------------------------------------------------------------------------
+*/
diff --git a/tests/3d-regression/scenarios/scenario_tier1_model.cpp b/tests/3d-regression/scenarios/scenario_tier1_model.cpp
new file mode 100644
index 0000000..5b1258a
--- /dev/null
+++ b/tests/3d-regression/scenarios/scenario_tier1_model.cpp
@@ -0,0 +1,238 @@
+/**
+ * Tier-1 scenarios 21-28: MODEL_3D (VBO/IBO path), the navigator spheres
+ * gizmo, and camera projection/preset-view coverage.
+ */
+
+#include "scene3d_test_ctx.h"
+#include "test_board_data.h"
+
+#include "3d_rendering/opengl/3d_model.h"
+#include "3d_rendering/opengl/3d_spheres_gizmo.h"
+#include "3d_rendering/opengl/opengl_utils.h"
+#include "common_ogl/ogl_utils.h"
+
+#include
+#include
+
+// 21: opaque + per-vertex-color meshes through the VBO/glDrawElements path.
+void Scenario_Model3dOpaque( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ MODEL_3D model( TestS3DModel(), MATERIAL_MODE::NORMAL );
+
+ glPushMatrix();
+ glScalef( 2.0f, 2.0f, 2.0f );
+
+ MODEL_3D::BeginDrawMulti( true );
+ model.DrawOpaque( false );
+ MODEL_3D::EndDrawMulti();
+
+ glPopMatrix();
+}
+
+// 22: the transparent-model pass — blend + the glTexEnv COMBINE/INTERPOLATE
+// block Redraw() sets up around renderTransparentModels
+// (render_3d_opengl.cpp:800-831).
+void Scenario_Model3dTransparent( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ MODEL_3D model( TestS3DModel(), MATERIAL_MODE::NORMAL );
+
+ glPushMatrix();
+ glScalef( 2.0f, 2.0f, 2.0f );
+
+ MODEL_3D::BeginDrawMulti( true );
+ model.DrawOpaque( false );
+
+ // State block replicated from Redraw() lines 800-827.
+ glDepthMask( GL_FALSE );
+ glEnable( GL_BLEND );
+ glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
+
+ glEnable( GL_TEXTURE_2D );
+ glActiveTexture( GL_TEXTURE0 );
+ glBindTexture( GL_TEXTURE_2D, aCtx.GetCircleTexture() );
+
+ glTexEnvi( GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_COMBINE );
+ glTexEnvf( GL_TEXTURE_ENV, GL_COMBINE_RGB, GL_INTERPOLATE );
+ glTexEnvf( GL_TEXTURE_ENV, GL_COMBINE_ALPHA, GL_MODULATE );
+ glTexEnvi( GL_TEXTURE_ENV, GL_SRC0_RGB, GL_PRIMARY_COLOR );
+ glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND0_RGB, GL_SRC_COLOR );
+ glTexEnvi( GL_TEXTURE_ENV, GL_SRC1_RGB, GL_PREVIOUS );
+ glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND1_RGB, GL_SRC_COLOR );
+ glTexEnvi( GL_TEXTURE_ENV, GL_SRC0_ALPHA, GL_PRIMARY_COLOR );
+ glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND0_ALPHA, GL_SRC_ALPHA );
+ glTexEnvi( GL_TEXTURE_ENV, GL_SRC1_ALPHA, GL_CONSTANT );
+ glTexEnvi( GL_TEXTURE_ENV, GL_OPERAND1_ALPHA, GL_SRC_ALPHA );
+
+ model.DrawTransparent( 0.55f, false );
+
+ glDisable( GL_BLEND );
+ OglResetTextureState();
+ glDepthMask( GL_TRUE );
+
+ MODEL_3D::EndDrawMulti();
+ glPopMatrix();
+}
+
+// 23: MATERIAL_MODE branches — NORMAL / DIFFUSE_ONLY / CAD_MODE side by side.
+void Scenario_Model3dMaterialModes( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ const MATERIAL_MODE modes[3] = { MATERIAL_MODE::NORMAL, MATERIAL_MODE::DIFFUSE_ONLY,
+ MATERIAL_MODE::CAD_MODE };
+
+ for( int i = 0; i < 3; i++ )
+ {
+ MODEL_3D model( TestS3DModel(), modes[i] );
+
+ glPushMatrix();
+ glTranslatef( ( i - 1 ) * 5.2f, 0.0f, 0.0f );
+ glScalef( 1.1f, 1.1f, 1.1f );
+
+ MODEL_3D::BeginDrawMulti( true );
+ model.DrawOpaque( false );
+ MODEL_3D::EndDrawMulti();
+
+ glPopMatrix();
+ }
+}
+
+// 24: model + mesh bounding boxes (glLineWidth>1 GL_LINES — a known WebGL
+// port milestone: line width will need quad emulation there).
+void Scenario_Model3dBbox( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ MODEL_3D model( TestS3DModel(), MATERIAL_MODE::NORMAL );
+
+ glPushMatrix();
+ glScalef( 2.0f, 2.0f, 2.0f );
+
+ MODEL_3D::BeginDrawMulti( true );
+ model.DrawOpaque( false );
+
+ // Same state the show_model_bbox path uses inside renderModel(): unlit
+ // blended colored lines, drawn between BeginDrawMulti/EndDrawMulti (the
+ // bbox VBO draw needs the client vertex-array state enabled there).
+ glDisable( GL_LIGHTING );
+ glEnable( GL_BLEND );
+ glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
+
+ glColor4f( 0.4f, 1.0f, 0.4f, 0.75f );
+ model.DrawBboxes();
+
+ glColor4f( 1.0f, 0.3f, 0.3f, 0.9f );
+ model.DrawBbox();
+
+ glDisable( GL_BLEND );
+ glEnable( GL_LIGHTING );
+
+ MODEL_3D::EndDrawMulti();
+ glPopMatrix();
+}
+
+// 25: the navigator spheres gizmo — own corner viewport, gluPerspective,
+// gluSphere billboards (RENDER_3D_OPENGL ctor uses SPHERES_GIZMO(4,4)).
+void Scenario_SpheresGizmo( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ // Same construction/placement as RENDER_3D_OPENGL (render_3d_opengl.cpp:87,119):
+ // corner position (4,4), gizmo square is viewportHeight/8.
+ SPHERES_GIZMO gizmo( 4, 4 );
+ gizmo.setViewport( 0, 0, aCtx.m_width, aCtx.m_height );
+
+ gizmo.render3dSpheresGizmo( aCtx.m_camera.GetRotationMatrix() );
+
+ glViewport( 0, 0, aCtx.m_width, aCtx.m_height );
+}
+
+// Shared asymmetric marker so every camera pose is distinguishable: RGB axis
+// triad + an off-axis segment.
+static void drawCameraMarker( SCENE3D_CTX& aCtx )
+{
+ OglSetDiffuseMaterial( SFVEC3F( 0.9f, 0.1f, 0.1f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 3.5f, 0.0f, 0.0f ), 0.4f );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.1f, 0.9f, 0.1f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, 3.5f, 0.0f ), 0.4f );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.1f, 0.1f, 0.9f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, 0.0f, 3.5f ), 0.4f );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+
+ const ROUND_SEGMENT_2D segment( SFVEC2F( 1.5f, 1.5f ), SFVEC2F( 4.0f, 4.0f ), 1.0f,
+ DummyBoardItem() );
+ DrawSegment( segment, 24 );
+}
+
+// 26: perspective projection.
+void Scenario_CameraPersp( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.SetOrtho( false );
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ drawCameraMarker( aCtx );
+}
+
+// 27: orthographic projection of the identical scene.
+void Scenario_CameraOrtho( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.SetOrtho( true );
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ drawCameraMarker( aCtx );
+}
+
+// 28: the six preset views (ViewCommand_T1 T/B/L/R/F/Back), tiled 3x2.
+void Scenario_CameraPresetViews( SCENE3D_CTX& aCtx )
+{
+ aCtx.ResetCamera();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ const VIEW3D_TYPE views[6] = {
+ VIEW3D_TYPE::VIEW3D_TOP, VIEW3D_TYPE::VIEW3D_BOTTOM, VIEW3D_TYPE::VIEW3D_LEFT,
+ VIEW3D_TYPE::VIEW3D_RIGHT, VIEW3D_TYPE::VIEW3D_FRONT, VIEW3D_TYPE::VIEW3D_BACK,
+ };
+
+ const int tileW = aCtx.m_width / 3;
+ const int tileH = aCtx.m_height / 2;
+
+ for( int i = 0; i < 6; i++ )
+ {
+ aCtx.ResetCamera();
+ aCtx.SetView( views[i] );
+
+ glViewport( ( i % 3 ) * tileW, ( i / 3 ) * tileH, tileW, tileH );
+ glClear( GL_DEPTH_BUFFER_BIT );
+
+ // Re-upload the camera matrices for this tile (same calls Redraw makes).
+ glMatrixMode( GL_PROJECTION );
+ glLoadMatrixf( glm::value_ptr( aCtx.m_camera.GetProjectionMatrix() ) );
+ glMatrixMode( GL_MODELVIEW );
+ glLoadMatrixf( glm::value_ptr( aCtx.m_camera.GetViewMatrix() ) );
+
+ aCtx.PositionHeadlight();
+ drawCameraMarker( aCtx );
+ }
+
+ glViewport( 0, 0, aCtx.m_width, aCtx.m_height );
+}
diff --git a/tests/3d-regression/scenarios/scenario_tier1_tdl.cpp b/tests/3d-regression/scenarios/scenario_tier1_tdl.cpp
new file mode 100644
index 0000000..f13df49
--- /dev/null
+++ b/tests/3d-regression/scenarios/scenario_tier1_tdl.cpp
@@ -0,0 +1,207 @@
+/**
+ * Tier-1 scenarios 13-20: TRIANGLE_DISPLAY_LIST / OPENGL_RENDER_LIST — display
+ * lists, extruded plates, the segment-ends texture + alpha-test path and the
+ * stencil hole-subtraction (DrawCulled).
+ */
+
+#include "scene3d_test_ctx.h"
+#include "test_board_data.h"
+
+#include "common_ogl/ogl_utils.h"
+
+#include
+#include
+
+// Fan-triangulate a closed convex contour into a TRIANGLE_LIST at height aZ.
+// Data-filling only (mirrors what create_scene.cpp's generators feed the
+// containers); the winding follows addTopAndBottomTriangles: top faces CCW in
+// XY, bottom faces reversed.
+static void addFanTriangles( TRIANGLE_LIST* aDst, const std::vector& aClosedContour,
+ float aZ, bool aTopFace )
+{
+ const SFVEC2F& v0 = aClosedContour.front();
+
+ for( size_t i = 1; i + 1 < aClosedContour.size(); i++ )
+ {
+ const SFVEC2F& v1 = aClosedContour[i];
+ const SFVEC2F& v2 = aClosedContour[i + 1];
+
+ if( aTopFace )
+ aDst->AddTriangle( SFVEC3F( v0.x, v0.y, aZ ), SFVEC3F( v1.x, v1.y, aZ ),
+ SFVEC3F( v2.x, v2.y, aZ ) );
+ else
+ aDst->AddTriangle( SFVEC3F( v2.x, v2.y, aZ ), SFVEC3F( v1.x, v1.y, aZ ),
+ SFVEC3F( v0.x, v0.y, aZ ) );
+ }
+}
+
+
+// An extruded plate: top + bottom fans and real AddToMiddleContours walls.
+static std::unique_ptr makePlateTdl( const std::vector& aContour,
+ float aZBot, float aZTop )
+{
+ auto tdl = std::make_unique( 2 * aContour.size() );
+
+ addFanTriangles( tdl->m_layer_top_triangles, aContour, aZTop, true );
+ addFanTriangles( tdl->m_layer_bot_triangles, aContour, aZBot, false );
+
+ // Our contours are CCW in 3D space; board outlines arrive effectively CW
+ // (the BIU->3D conversion mirrors Y), so invert to get outward-facing walls.
+ tdl->AddToMiddleContours( aContour, aZBot, aZTop, true );
+
+ return tdl;
+}
+
+
+static std::unique_ptr makeHexPlateList( SCENE3D_CTX& aCtx, float aZBot,
+ float aZTop )
+{
+ auto tdl = makePlateTdl( MakeCircleContour( 4.5f, 6 ), aZBot, aZTop );
+ return std::unique_ptr( aCtx.MakeRenderList( *tdl, aZBot, aZTop ) );
+}
+
+
+static void beginTdlScene( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+}
+
+// 13: only the top-face display list.
+void Scenario_TdlDrawTop( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+ makeHexPlateList( aCtx, -0.6f, 0.6f )->DrawTop();
+}
+
+// 14: only the bottom-face display list, seen from below.
+void Scenario_TdlDrawBot( SCENE3D_CTX& aCtx )
+{
+ aCtx.ResetCamera();
+ aCtx.SetView( VIEW3D_TYPE::VIEW3D_BOTTOM );
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+
+ makeHexPlateList( aCtx, -0.6f, 0.6f )->DrawBot();
+}
+
+// 15: only the extruded side walls (middle contour quads, per-vertex normals).
+void Scenario_TdlDrawMiddle( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+ makeHexPlateList( aCtx, -1.2f, 1.2f )->DrawMiddle();
+}
+
+// 16: the closed extruded plate — all five sub-lists.
+void Scenario_TdlDrawAll( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+ makeHexPlateList( aCtx, -0.9f, 0.9f )->DrawAll();
+}
+
+// 17: the segment-ends path — circle texture + glAlphaFunc(GL_GREATER,0.2)
+// inside generate_top_or_bot_seg_ends (layer_triangles.cpp:600-624). The
+// triangle pattern mirrors addObjectTriangles(FILLED_CIRCLE_2D)
+// (create_scene.cpp:42-70): two triangles per circle whose UVs map the
+// blurred-circle texture into a round disc.
+void Scenario_TdlSegEndsTexture( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+
+ TRIANGLE_DISPLAY_LIST tdl( 8 );
+
+ const float texture_factor = ( 8.0f / 1024.0f ) + 1.0f; // SIZE_OF_CIRCLE_TEXTURE
+
+ const SFVEC2F centers[3] = { { -3.5f, 0.0f }, { 0.0f, 0.0f }, { 3.5f, 0.0f } };
+ const float radii[3] = { 1.2f, 1.7f, 2.2f };
+
+ for( int i = 0; i < 3; i++ )
+ {
+ const SFVEC2F& center = centers[i];
+ const float radius = radii[i] * 2.0f; // doubled like the generator
+ const float f = ( std::sqrt( 2.0f ) / 2.0f ) * radius * texture_factor;
+ const float z = 0.4f;
+
+ tdl.m_layer_top_segment_ends->AddTriangle(
+ SFVEC3F( center.x + f, center.y, z ), SFVEC3F( center.x - f, center.y, z ),
+ SFVEC3F( center.x, center.y - f, z ) );
+ tdl.m_layer_top_segment_ends->AddTriangle(
+ SFVEC3F( center.x - f, center.y, z ), SFVEC3F( center.x + f, center.y, z ),
+ SFVEC3F( center.x, center.y + f, z ) );
+ }
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, 0.0f, 0.4f ) );
+ list->DrawTop();
+}
+
+// 18: DrawCulled — the stencil-based hole subtraction (layer_triangles.cpp:459-543).
+// A plate with two hole volumes stenciled out of it.
+void Scenario_TdlCulledStencil( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+
+ const float zBot = -0.5f, zTop = 0.5f;
+
+ auto plateTdl = makePlateTdl( MakeCircleContour( 4.5f, 6 ), zBot, zTop );
+ std::unique_ptr plate( aCtx.MakeRenderList( *plateTdl, zBot, zTop ) );
+
+ // Hole volumes: same z-range plates (a round one and a square one), like
+ // the outer-through-holes subtract lists Redraw passes to DrawCulled.
+ auto holesTdl = makePlateTdl( MakeCircleContour( 1.1f, 16, -1.8f, 0.0f ), zBot, zTop );
+ auto holes2Tdl = makePlateTdl( MakeSquareContour( 0.9f, 1.8f, 0.9f ), zBot, zTop );
+
+ std::unique_ptr holes( aCtx.MakeRenderList( *holesTdl, zBot, zTop ) );
+ std::unique_ptr holes2( aCtx.MakeRenderList( *holes2Tdl, zBot, zTop ) );
+
+ plate->DrawCulled( true, holes.get(), holes2.get() );
+}
+
+// 19: ApplyScalePosition — the z-translate/z-scale transform used to place
+// layer plates at their board Z (layer_triangles.cpp beginTransformation).
+void Scenario_TdlZScale( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+
+ auto tdl = makePlateTdl( MakeCircleContour( 3.5f, 6 ), 0.0f, 1.0f );
+ std::unique_ptr list( aCtx.MakeRenderList( *tdl, 0.0f, 1.0f ) );
+
+ // Thin plate below...
+ list->ApplyScalePosition( -1.6f, 0.25f );
+ list->DrawAll();
+
+ // ...thick plate above.
+ OglSetDiffuseMaterial( SFVEC3F( 0.2f, 0.5f, 0.8f ), 1.0f );
+ list->ApplyScalePosition( 0.6f, 1.8f );
+ list->DrawAll();
+}
+
+// 20: SetItIsTransparent + blended DrawAll over an opaque plate.
+void Scenario_TdlTransparent( SCENE3D_CTX& aCtx )
+{
+ beginTdlScene( aCtx );
+
+ auto baseTdl = makePlateTdl( MakeSquareContour( 3.0f ), -1.0f, -0.4f );
+ std::unique_ptr base( aCtx.MakeRenderList( *baseTdl, -1.0f, -0.4f ) );
+ base->DrawAll();
+
+ // Epoxy-like translucent plate above it (renderBoardBody pattern:
+ // material transparency + SetItIsTransparent, render_3d_opengl.cpp:468-501).
+ SMATERIAL epoxy;
+ epoxy.m_Ambient = SFVEC3F( 0.1f, 0.1f, 0.12f );
+ epoxy.m_Diffuse = SFVEC3F( 0.4f, 0.4f, 0.5f ); // BOARD_ADAPTER default board body
+ epoxy.m_Emissive = SFVEC3F( 0.0f, 0.0f, 0.0f );
+ epoxy.m_Specular = SFVEC3F( 0.2f, 0.2f, 0.2f );
+ epoxy.m_Shininess = 0.3f;
+ epoxy.m_Transparency = 0.1f; // == 1 - default body alpha 0.9
+
+ OglSetMaterial( epoxy, 0.6f );
+
+ auto topTdl = makePlateTdl( MakeCircleContour( 4.2f, 6 ), 0.0f, 0.8f );
+ std::unique_ptr top( aCtx.MakeRenderList( *topTdl, 0.0f, 0.8f ) );
+
+ top->SetItIsTransparent( true );
+ top->DrawAll();
+}
diff --git a/tests/3d-regression/scenarios/scenario_tier1_utils.cpp b/tests/3d-regression/scenarios/scenario_tier1_utils.cpp
new file mode 100644
index 0000000..5215b7a
--- /dev/null
+++ b/tests/3d-regression/scenarios/scenario_tier1_utils.cpp
@@ -0,0 +1,269 @@
+/**
+ * Tier-1 scenarios 1-12: common_ogl/ogl_utils + opengl_utils free functions,
+ * FFP materials and lights. Standalone KiCad TUs only — no RENDER_3D_OPENGL
+ * members.
+ */
+
+#include "scene3d_test_ctx.h"
+#include "test_board_data.h"
+
+#include "3d_rendering/opengl/opengl_utils.h"
+#include "3d_rendering/raytracing/shapes3D/bbox_3d.h"
+#include "common_ogl/ogl_utils.h"
+
+#include
+
+// 1: the default viewer background gradient — the simplest possible render
+// (no geometry; identity matrices inside OglDrawBackground).
+void Scenario_BgGradient( SCENE3D_CTX& aCtx )
+{
+ aCtx.ResetCamera();
+ aCtx.BeginFrame();
+}
+
+// 2: translucent background colors exercising the premultiplied-alpha path
+// Redraw() feeds through (render_3d_opengl.cpp:576-577).
+void Scenario_BgGradientAlpha( SCENE3D_CTX& aCtx )
+{
+ aCtx.ResetCamera();
+ aCtx.BeginFrame( SFVEC4F( 0.9f, 0.3f, 0.1f, 0.5f ), SFVEC4F( 0.1f, 0.3f, 0.9f, 0.8f ) );
+}
+
+// 3: DrawBoundingBox — GL_LINE_LOOP/GL_LINE_STRIP wireframe, unlit colored lines.
+void Scenario_BoundingBox( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+
+ glDisable( GL_LIGHTING );
+
+ glColor4f( 0.9f, 0.9f, 0.2f, 1.0f );
+ DrawBoundingBox( BBOX_3D( SFVEC3F( -3.0f, -2.0f, -1.0f ), SFVEC3F( 3.0f, 2.0f, 1.0f ) ) );
+
+ glColor4f( 0.2f, 0.9f, 0.9f, 1.0f );
+ DrawBoundingBox( BBOX_3D( SFVEC3F( -1.0f, -1.0f, -2.0f ), SFVEC3F( 1.0f, 1.0f, 2.0f ) ) );
+}
+
+// 4: DrawHalfOpenCylinder — TRIANGLE_FAN caps + QUAD_STRIP wall, smooth normals, lit.
+void Scenario_HalfOpenCylinder( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.35f, 0.15f ), 1.0f );
+
+ // Unit-sized primitive (d=1, h=1, base at origin) — scale up to fill the
+ // frame; GL_NORMALIZE (set in BeginFrame, as in Redraw) fixes the normals.
+ // Laid on its side so the curved wall catches the near-vertical
+ // directional lights (upright walls are almost unlit under init_lights()).
+ glPushMatrix();
+ glRotatef( 90.0f, 0.0f, 1.0f, 0.0f ); // axis along +X (screen horizontal)
+ glRotatef( -90.0f, 0.0f, 0.0f, 1.0f ); // convex half toward camera+top light
+ glScalef( 5.0f, 5.0f, 8.0f );
+ glTranslatef( 0.0f, 0.0f, -0.5f );
+ DrawHalfOpenCylinder( 32 );
+ glPopMatrix();
+}
+
+// 5: DrawSegment — one thick rounded-end track segment (quads + half-cylinders
+// + matrix stack inside the helper).
+void Scenario_SegmentSingle( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+
+ const ROUND_SEGMENT_2D segment( SFVEC2F( -4.0f, -2.0f ), SFVEC2F( 4.0f, 2.0f ), 2.0f,
+ DummyBoardItem() );
+
+ DrawSegment( segment, 32 );
+}
+
+// 6: a star of DrawSegment calls with varying widths/angles.
+void Scenario_SegmentsStar( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+
+ for( int i = 0; i < 12; i++ )
+ {
+ const float a = 2.0f * glm::pi() * i / 12.0f;
+ const float r = 5.5f;
+ const float width = 0.25f + 0.09f * i;
+
+ const ROUND_SEGMENT_2D segment( SFVEC2F( 1.2f * std::cos( a ), 1.2f * std::sin( a ) ),
+ SFVEC2F( r * std::cos( a ), r * std::sin( a ) ), width,
+ DummyBoardItem() );
+
+ DrawSegment( segment, 24 );
+ }
+}
+
+// 7: DrawRoundArrow — GLU cylinder + cone + disk + sphere quadrics.
+void Scenario_RoundArrow( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.2f, 0.7f, 0.3f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( -2.0f, -2.0f, 0.0f ), SFVEC3F( 3.0f, 2.5f, 1.5f ), 0.5f );
+}
+
+// 8: the RGB axis triad the viewer draws — three arrows with per-axis materials.
+void Scenario_RoundArrowsAxes( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ // Same layout as RENDER_3D_OPENGL::Redraw()'s show_axis block.
+ const float arrow_size = SCENE3D_RANGE_SCALE_3D * 0.30f;
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.9f, 0.0f, 0.0f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( arrow_size, 0.0f, 0.0f ), 0.275f );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.0f, 0.9f, 0.0f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, arrow_size, 0.0f ), 0.275f );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.0f, 0.0f, 0.9f ), 1.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, 0.0f, arrow_size ), 0.275f );
+}
+
+// Shared geometry for the material scenarios: cylinder + star spokes.
+static void drawMaterialTestGeometry( SCENE3D_CTX& aCtx )
+{
+ glPushMatrix();
+ glScalef( 3.0f, 3.0f, 2.5f );
+ DrawHalfOpenCylinder( 32 );
+ glPopMatrix();
+
+ for( int i = 0; i < 6; i++ )
+ {
+ const float a = 2.0f * glm::pi() * i / 6.0f;
+
+ const ROUND_SEGMENT_2D segment( SFVEC2F( 2.2f * std::cos( a ), 2.2f * std::sin( a ) ),
+ SFVEC2F( 5.5f * std::cos( a ), 5.5f * std::sin( a ) ),
+ 0.8f, DummyBoardItem() );
+
+ DrawSegment( segment, 24 );
+ }
+}
+
+// 9: full SMATERIAL via OglSetMaterial — ambient/diffuse/specular/shininess (copper-like).
+void Scenario_MaterialCopper( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ SMATERIAL copper;
+ copper.m_Ambient = SFVEC3F( 0.26f, 0.23f, 0.11f );
+ copper.m_Diffuse = SFVEC3F( 0.75f, 0.61f, 0.23f ); // BOARD_ADAPTER default copper
+ copper.m_Emissive = SFVEC3F( 0.0f, 0.0f, 0.0f );
+ copper.m_Specular = SFVEC3F( 0.70f, 0.55f, 0.35f );
+ copper.m_Shininess = 0.4f;
+ copper.m_Transparency = 0.0f;
+
+ OglSetMaterial( copper, 1.0f );
+ drawMaterialTestGeometry( aCtx );
+}
+
+// 10: OglSetDiffuseMaterial — flat matte look, same geometry as #9.
+void Scenario_MaterialDiffuseOnly( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+ drawMaterialTestGeometry( aCtx );
+}
+
+// 11: transparent material + blending over opaque geometry.
+void Scenario_MaterialTransparent( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ // Opaque base plate of segments.
+ OglSetDiffuseMaterial( SFVEC3F( 0.3f, 0.3f, 0.35f ), 1.0f );
+
+ for( int i = -2; i <= 2; i++ )
+ {
+ const ROUND_SEGMENT_2D segment( SFVEC2F( -5.0f, i * 1.6f ), SFVEC2F( 5.0f, i * 1.6f ),
+ 1.2f, DummyBoardItem() );
+ DrawSegment( segment, 24 );
+ }
+
+ // Translucent solder-mask-like material on top (same blend state the
+ // renderer uses for transparent passes, layer_triangles.cpp setBlendfunction).
+ glEnable( GL_BLEND );
+ glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
+ glDepthMask( GL_FALSE );
+
+ SMATERIAL mask;
+ mask.m_Ambient = SFVEC3F( 0.1f, 0.2f, 0.1f );
+ mask.m_Diffuse = SFVEC3F( 0.1f, 0.6f, 0.2f );
+ mask.m_Emissive = SFVEC3F( 0.0f, 0.0f, 0.0f );
+ mask.m_Specular = SFVEC3F( 0.2f, 0.4f, 0.2f );
+ mask.m_Shininess = 0.5f;
+ mask.m_Transparency = 0.5f; // diffuse alpha = (1-transparency)*opacity
+
+ OglSetMaterial( mask, 1.0f );
+
+ glPushMatrix();
+ glTranslatef( 0.0f, 0.0f, 1.0f );
+ glScalef( 4.5f, 4.5f, 1.5f );
+ DrawHalfOpenCylinder( 32 );
+ glPopMatrix();
+
+ glDepthMask( GL_TRUE );
+ glDisable( GL_BLEND );
+}
+
+// 12-14: GL_LIGHT0/1/2 isolated — one scenario per light (front/headlight
+// point light, top directional, bottom directional; the eye-space-anchored
+// directions init_lights() bakes at context init). Same sideways cylinder so
+// the three renders are directly comparable.
+static void drawLightTestGeometry( SCENE3D_CTX& aCtx, bool aFront, bool aTop, bool aBottom )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ aCtx.EnableLights( aFront, aTop, aBottom );
+
+ OglSetDiffuseMaterial( SFVEC3F( 0.7f, 0.7f, 0.75f ), 1.0f );
+
+ glPushMatrix();
+ glRotatef( 90.0f, 0.0f, 1.0f, 0.0f );
+ glRotatef( -90.0f, 0.0f, 0.0f, 1.0f );
+ glScalef( 4.5f, 4.5f, 8.0f );
+ glTranslatef( 0.0f, 0.0f, -0.5f );
+ DrawHalfOpenCylinder( 32 );
+ glPopMatrix();
+
+ aCtx.EnableLights( true, true, true );
+}
+
+void Scenario_LightFront( SCENE3D_CTX& aCtx )
+{
+ drawLightTestGeometry( aCtx, true, false, false );
+}
+
+void Scenario_LightTop( SCENE3D_CTX& aCtx )
+{
+ drawLightTestGeometry( aCtx, false, true, false );
+}
+
+void Scenario_LightBottom( SCENE3D_CTX& aCtx )
+{
+ drawLightTestGeometry( aCtx, false, false, true );
+}
diff --git a/tests/3d-regression/scenarios/scenario_tier2_generators.cpp b/tests/3d-regression/scenarios/scenario_tier2_generators.cpp
new file mode 100644
index 0000000..c10e7ff
--- /dev/null
+++ b/tests/3d-regression/scenarios/scenario_tier2_generators.cpp
@@ -0,0 +1,336 @@
+/**
+ * Tier-2 scenarios: RENDER_3D_OPENGL's private geometry generators, grids and
+ * material setters, reached through the rob-template accessor
+ * (native/render3d_test_accessor.h) over a synthetic BOARD_ADAPTER
+ * (native/board_adapter_test_impl.cpp InitSettings).
+ */
+
+#include "scene3d_test_ctx.h"
+#include "scene3d_test_rig.h"
+#include "test_board_data.h"
+
+#include "3d_rendering/opengl/opengl_utils.h"
+#include "3d_rendering/raytracing/shapes2D/4pt_polygon_2d.h"
+#include "3d_rendering/raytracing/shapes2D/filled_circle_2d.h"
+#include "3d_rendering/raytracing/shapes2D/ring_2d.h"
+#include "3d_rendering/raytracing/shapes2D/round_segment_2d.h"
+#include "3d_rendering/raytracing/shapes2D/triangle_2d.h"
+#include "common_ogl/ogl_utils.h"
+
+#include // pcbIUScale
+#include
+#include
+
+using TIER2_RIG = SCENE3D_TEST_RIG;
+
+
+static void beginTier2Scene( SCENE3D_CTX& aCtx )
+{
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+ OglSetDiffuseMaterial( SFVEC3F( 0.75f, 0.61f, 0.23f ), 1.0f );
+}
+
+// 31: generateCylinder — the via/pad barrel wall generator.
+void Scenario_GenCylinder( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ TRIANGLE_DISPLAY_LIST tdl( 256 );
+ R3D_GenerateCylinder( *rig, SFVEC2F( 0.0f, 0.0f ), 2.2f, 3.0f, 1.8f, -1.8f, 32, &tdl );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, -1.8f, 1.8f ) );
+ list->DrawAll();
+}
+
+// 32: generateInvCone — the countersink cone generator.
+void Scenario_GenInvCone( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ TRIANGLE_DISPLAY_LIST tdl( 256 );
+ R3D_GenerateInvCone( *rig, SFVEC2F( 0.0f, 0.0f ), 1.2f, 3.2f, 1.5f, -1.5f, 32, &tdl,
+ EDA_ANGLE( 90.0, DEGREES_T ) );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, -1.5f, 1.5f ) );
+ list->DrawAll();
+}
+
+// 33: generateDisk — hole caps / annulus disks, top and bottom variants.
+void Scenario_GenDisk( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ TRIANGLE_DISPLAY_LIST tdl( 256 );
+ R3D_GenerateDisk( *rig, SFVEC2F( -2.6f, 0.0f ), 2.0f, 0.8f, 32, &tdl, true );
+ R3D_GenerateDisk( *rig, SFVEC2F( 2.6f, 0.0f ), 2.0f, -0.8f, 32, &tdl, false );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, -0.8f, 0.8f ) );
+ list->DrawAll();
+}
+
+// 34: generateDimple — the plated-hole cover bump.
+void Scenario_GenDimple( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ TRIANGLE_DISPLAY_LIST tdl( 1024 );
+ R3D_GenerateDimple( *rig, SFVEC2F( 0.0f, 0.0f ), 3.0f, 0.0f, 1.2f, 48, &tdl, true );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, 0.0f, 1.2f ) );
+ list->DrawAll();
+}
+
+// 35: all five addObjectTriangles overloads in a row.
+void Scenario_AddObjAllShapes( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ const float zTop = 0.5f, zBot = -0.5f;
+ TRIANGLE_DISPLAY_LIST tdl( 1024 );
+
+ const FILLED_CIRCLE_2D circle( SFVEC2F( -5.4f, 0.0f ), 1.1f, DummyBoardItem() );
+ R3D_AddObjTriangles( *rig, &circle, &tdl, zTop, zBot );
+
+ const RING_2D ring( SFVEC2F( -2.7f, 0.0f ), 0.6f, 1.2f, DummyBoardItem() );
+ R3D_AddObjTriangles( *rig, &ring, &tdl, zTop, zBot );
+
+ const POLYGON_4PT_2D poly( SFVEC2F( -1.0f, -1.0f ), SFVEC2F( 1.0f, -1.1f ),
+ SFVEC2F( 1.1f, 1.0f ), SFVEC2F( -0.9f, 1.1f ), DummyBoardItem() );
+ R3D_AddObjTriangles( *rig, &poly, &tdl, zTop, zBot );
+
+ const TRIANGLE_2D tri( SFVEC2F( 1.8f, -1.1f ), SFVEC2F( 3.6f, -1.1f ), SFVEC2F( 2.7f, 1.2f ),
+ DummyBoardItem() );
+ R3D_AddObjTriangles( *rig, &tri, &tdl, zTop, zBot );
+
+ const ROUND_SEGMENT_2D seg( SFVEC2F( 4.4f, -1.0f ), SFVEC2F( 6.0f, 1.0f ), 0.9f,
+ DummyBoardItem() );
+ R3D_AddObjTriangles( *rig, &seg, &tdl, zTop, zBot );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, zBot, zTop ) );
+ list->DrawAll();
+}
+
+// 36: appendPostMachiningGeometry — counterbore and countersink profiles.
+void Scenario_PostMachining( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ const float unitScale = static_cast( rig.m_adapter.BiuTo3dUnits() );
+ float zEnd = 0.0f;
+
+ // Separate lists on purpose. UPSTREAM BUG (create_scene.cpp countersink
+ // path): the cone quads are added with AddQuad but no AddNormal, so the
+ // middle-quads normals array is half the vertex count and
+ // generate_middle_triangles rejects the WHOLE list — a countersink batched
+ // with other geometry kills that list's walls in the real viewer too.
+ // Keeping them separate makes the counterbore render correctly while the
+ // countersink half documents the buggy (empty) upstream output.
+ TRIANGLE_DISPLAY_LIST cbTdl( 4096 );
+ const bool cb = R3D_AppendPostMachining( *rig, &cbTdl, SFVEC2F( -3.2f, 0.0f ),
+ PAD_DRILL_POST_MACHINING_MODE::COUNTERBORE,
+ pcbIUScale.mmToIU( 14 ), pcbIUScale.mmToIU( 6 ),
+ 1.0f, 1.0f, true, 0.4f, unitScale, &zEnd );
+
+ TRIANGLE_DISPLAY_LIST csTdl( 4096 );
+ const bool cs = R3D_AppendPostMachining( *rig, &csTdl, SFVEC2F( 3.2f, 0.0f ),
+ PAD_DRILL_POST_MACHINING_MODE::COUNTERSINK,
+ pcbIUScale.mmToIU( 14 ), pcbIUScale.mmToIU( 6 ),
+ 1.0f, 1.0f, true, 0.4f, unitScale, &zEnd );
+
+ wxASSERT( cb && cs );
+ (void) cb;
+ (void) cs;
+
+ std::unique_ptr cbList( aCtx.MakeRenderList( cbTdl, -1.0f, 1.0f ) );
+ cbList->DrawAll();
+
+ std::unique_ptr csList( aCtx.MakeRenderList( csTdl, -1.0f, 1.0f ) );
+ csList->DrawAll();
+}
+
+// 37: a complete via cross-section composed the way generateViaBarrels /
+// generateViaCovers do: barrel + annular disks + cover dimple.
+void Scenario_ViaComposite( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ R3D_SetupMaterials( *rig );
+ R3D_SetLayerMaterial( *rig, F_Cu );
+
+ TRIANGLE_DISPLAY_LIST tdl( 2048 );
+
+ R3D_GenerateCylinder( *rig, SFVEC2F( 0.0f, 0.0f ), 1.6f, 2.0f, 1.4f, -1.4f, 32, &tdl );
+ R3D_GenerateDisk( *rig, SFVEC2F( 0.0f, 0.0f ), 2.6f, 1.4f, 32, &tdl, true );
+ R3D_GenerateDisk( *rig, SFVEC2F( 0.0f, 0.0f ), 2.6f, -1.4f, 32, &tdl, false );
+ R3D_GenerateDimple( *rig, SFVEC2F( 0.0f, 0.0f ), 1.6f, 1.4f, 0.5f, 32, &tdl, true );
+
+ std::unique_ptr list( aCtx.MakeRenderList( tdl, -1.4f, 1.4f ) );
+ list->DrawAll();
+}
+
+// 38-41: generate3dGrid at each density — display list of blended GL_LINES
+// sized from the synthetic board adapter, drawn like Redraw()'s grid block.
+static void renderGridScenario( SCENE3D_CTX& aCtx, GRID3D_TYPE aType )
+{
+ TIER2_RIG rig( aCtx );
+
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ R3D_Generate3dGrid( *rig, aType );
+
+ glDisable( GL_LIGHTING );
+
+ const GLuint grid = R3D_GetGridList( *rig );
+
+ if( glIsList( grid ) )
+ glCallList( grid );
+
+ glEnable( GL_LIGHTING );
+}
+
+void Scenario_Grid1mm( SCENE3D_CTX& aCtx )
+{
+ renderGridScenario( aCtx, GRID3D_TYPE::GRID_1MM );
+}
+
+void Scenario_Grid2p5mm( SCENE3D_CTX& aCtx )
+{
+ renderGridScenario( aCtx, GRID3D_TYPE::GRID_2P5MM );
+}
+
+void Scenario_Grid5mm( SCENE3D_CTX& aCtx )
+{
+ renderGridScenario( aCtx, GRID3D_TYPE::GRID_5MM );
+}
+
+void Scenario_Grid10mm( SCENE3D_CTX& aCtx )
+{
+ renderGridScenario( aCtx, GRID3D_TYPE::GRID_10MM );
+}
+
+// Local extruded-plate builder (same construction as the tier-1 TDL scenarios).
+static void addFan( TRIANGLE_LIST* aDst, const std::vector& aContour, float aZ,
+ bool aTop )
+{
+ const SFVEC2F& v0 = aContour.front();
+
+ for( size_t i = 1; i + 1 < aContour.size(); i++ )
+ {
+ const SFVEC2F& v1 = aContour[i];
+ const SFVEC2F& v2 = aContour[i + 1];
+
+ if( aTop )
+ aDst->AddTriangle( SFVEC3F( v0.x, v0.y, aZ ), SFVEC3F( v1.x, v1.y, aZ ),
+ SFVEC3F( v2.x, v2.y, aZ ) );
+ else
+ aDst->AddTriangle( SFVEC3F( v2.x, v2.y, aZ ), SFVEC3F( v1.x, v1.y, aZ ),
+ SFVEC3F( v0.x, v0.y, aZ ) );
+ }
+}
+
+static std::unique_ptr makePlate( SCENE3D_CTX& aCtx, float aHalf, float aZBot,
+ float aZTop )
+{
+ const std::vector contour = MakeSquareContour( aHalf );
+
+ TRIANGLE_DISPLAY_LIST tdl( 64 );
+ addFan( tdl.m_layer_top_triangles, contour, aZTop, true );
+ addFan( tdl.m_layer_bot_triangles, contour, aZBot, false );
+ tdl.AddToMiddleContours( contour, aZBot, aZTop, true );
+
+ return std::unique_ptr( aCtx.MakeRenderList( tdl, aZBot, aZTop ) );
+}
+
+// 42: setupMaterials + setLayerMaterial — material swatch plates for the
+// technical layers (silk, mask, paste, copper).
+void Scenario_LayerMaterials( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ R3D_SetupMaterials( *rig );
+
+ const PCB_LAYER_ID layers[4] = { F_SilkS, F_Mask, F_Paste, B_Cu };
+
+ // Mask is translucent — same blend state the transparent passes use.
+ glEnable( GL_BLEND );
+ glBlendFunc( GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA );
+
+ for( int i = 0; i < 4; i++ )
+ {
+ R3D_SetLayerMaterial( *rig, layers[i] );
+
+ glPushMatrix();
+ glTranslatef( ( i - 1.5f ) * 3.4f, 0.0f, 0.0f );
+
+ makePlate( aCtx, 1.5f, -0.4f, 0.4f )->DrawAll();
+
+ glPopMatrix();
+ }
+
+ glDisable( GL_BLEND );
+}
+
+// 43: setArrowMaterial + glColor axes — the Redraw() show_axis block.
+void Scenario_ArrowMaterial( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+
+ aCtx.SetIsoView();
+ aCtx.BeginFrame();
+ aCtx.SetupLights();
+
+ R3D_SetArrowMaterial( *rig );
+
+ const float arrow_size = SCENE3D_RANGE_SCALE_3D * 0.30f;
+
+ glColor3f( 0.9f, 0.0f, 0.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( arrow_size, 0.0f, 0.0f ), 0.275f );
+
+ glColor3f( 0.0f, 0.9f, 0.0f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, arrow_size, 0.0f ), 0.275f );
+
+ glColor3f( 0.0f, 0.0f, 0.9f );
+ DrawRoundArrow( SFVEC3F( 0.0f ), SFVEC3F( 0.0f, 0.0f, arrow_size ), 0.275f );
+}
+
+// 44: createBoard — the real board-outline-to-display-list path (SHAPE_POLY_SET
+// triangulation + middle contours), drawn like renderBoardBody().
+void Scenario_CreateBoard( SCENE3D_CTX& aCtx )
+{
+ TIER2_RIG rig( aCtx );
+ beginTier2Scene( aCtx );
+
+ std::unique_ptr board(
+ R3D_CreateBoard( *rig, rig.m_adapter.GetBoardPoly(), &rig.m_adapter.GetTH_ODs() ) );
+
+ // renderBoardBody() material + transform (render_3d_opengl.cpp:468-501).
+ SMATERIAL epoxy;
+ epoxy.m_Ambient = SFVEC3F( 0.1f, 0.1f, 0.12f );
+ epoxy.m_Diffuse = SFVEC3F( 0.4f, 0.4f, 0.5f );
+ epoxy.m_Emissive = SFVEC3F( 0.0f, 0.0f, 0.0f );
+ epoxy.m_Specular = SFVEC3F( 0.2f, 0.2f, 0.2f );
+ epoxy.m_Shininess = 0.3f;
+ epoxy.m_Transparency = 0.1f;
+
+ OglSetMaterial( epoxy, 1.0f );
+
+ board->ApplyScalePosition( -rig.m_adapter.GetBoardBodyThickness() / 2.0f,
+ rig.m_adapter.GetBoardBodyThickness() );
+ board->SetItIsTransparent( true );
+ board->DrawAll();
+}
diff --git a/tests/3d-regression/scenarios/scenario_tier3_composite.cpp b/tests/3d-regression/scenarios/scenario_tier3_composite.cpp
new file mode 100644
index 0000000..8f7c87e
--- /dev/null
+++ b/tests/3d-regression/scenarios/scenario_tier3_composite.cpp
@@ -0,0 +1,65 @@
+/**
+ * Tier-3 scenarios: full RENDER_3D_OPENGL::Redraw() composites over the
+ * synthetic mini-board (board_adapter_test_impl.cpp InitSettings). Redraw's
+ * reload() re-runs InitSettings itself, so these exercise the complete real
+ * pipeline: board body triangulation, layer display lists, stencil hole
+ * subtraction, solder mask transparency, grid list and the navigator gizmo.
+ */
+
+#include "scene3d_test_ctx.h"
+#include "scene3d_test_rig.h"
+
+// The composites drive Redraw() exactly like EDA_3D_CANVAS::DoRePaint does:
+// window size, reload request, then Redraw(aIsMoving=false, no reporters).
+static void runRedraw( SCENE3D_CTX& aCtx, SCENE3D_TEST_RIG& aRig )
+{
+ aRig->SetCurWindowSize( wxSize( aCtx.m_width, aCtx.m_height ) );
+ aRig->ReloadRequest();
+ aRig->Redraw( false, nullptr, nullptr );
+}
+
+// 45: Redraw with every layer hidden — background, camera matrices, lights and
+// the frame plumbing only (the "empty viewer" reference frame).
+void Scenario_RedrawEmpty( SCENE3D_CTX& aCtx )
+{
+ SCENE3D_TEST_RIG rig( aCtx );
+
+ rig.m_cfg.m_Render.show_board_body = false;
+ rig.m_cfg.m_Render.show_copper_top = false;
+ rig.m_cfg.m_Render.show_copper_bottom = false;
+ rig.m_cfg.m_Render.show_silkscreen_top = false;
+ rig.m_cfg.m_Render.show_silkscreen_bottom = false;
+ rig.m_cfg.m_Render.show_soldermask_top = false;
+ rig.m_cfg.m_Render.show_soldermask_bottom = false;
+ rig.m_cfg.m_Render.show_solderpaste = false;
+ rig.m_cfg.m_Render.show_adhesive = false;
+ rig.m_cfg.m_Render.show_plated_barrels = false;
+
+ aCtx.SetIsoView();
+ runRedraw( aCtx, rig );
+}
+
+// 46: the full synthetic two-layer mini-board — copper tracks/pads with
+// stencil-subtracted through holes, silkscreen frame, translucent solder mask
+// and board body.
+void Scenario_RedrawMiniBoard( SCENE3D_CTX& aCtx )
+{
+ SCENE3D_TEST_RIG rig( aCtx );
+
+ aCtx.SetIsoView();
+ runRedraw( aCtx, rig );
+}
+
+// 47: everything at once — mini-board plus the 5mm grid and the navigator
+// spheres gizmo, straight top view. The port-complete gate.
+void Scenario_RedrawMiniBoardNavigator( SCENE3D_CTX& aCtx )
+{
+ SCENE3D_TEST_RIG rig( aCtx );
+
+ rig.m_cfg.m_Render.grid_type = GRID3D_TYPE::GRID_5MM;
+ rig.m_cfg.m_Render.show_navigator = true;
+
+ aCtx.ResetCamera();
+ aCtx.SetView( VIEW3D_TYPE::VIEW3D_TOP );
+ runRedraw( aCtx, rig );
+}
diff --git a/tests/3d-regression/scenarios/scene3d_test_ctx.cpp b/tests/3d-regression/scenarios/scene3d_test_ctx.cpp
new file mode 100644
index 0000000..5d1b371
--- /dev/null
+++ b/tests/3d-regression/scenarios/scene3d_test_ctx.cpp
@@ -0,0 +1,209 @@
+#include "scene3d_test_ctx.h"
+
+#include "3d_math.h" // SphericalToCartesian (inline, 3d-viewer/3d_math.h)
+#include "3d_rendering/image.h" // IMAGE for the circle texture
+#include "common_ogl/ogl_utils.h" // OglResetTextureState, OglDrawBackground, OglLoadTexture
+
+#include // value_ptr
+
+// SIZE_OF_CIRCLE_TEXTURE lives in render_3d_opengl.h (too heavy for Stage 1);
+// keep the value in sync (render_3d_opengl.h:53).
+static constexpr int CIRCLE_TEXTURE_SIZE = 1024;
+
+// Same premultiply the renderer applies before OglDrawBackground
+// (render_3d_opengl.cpp:505-508).
+static inline SFVEC4F premultiplyAlpha( const SFVEC4F& aInput )
+{
+ return SFVEC4F( aInput.r * aInput.a, aInput.g * aInput.a, aInput.b * aInput.a, aInput.a );
+}
+
+
+SCENE3D_CTX::SCENE3D_CTX( int aWidth, int aHeight ) :
+ m_width( aWidth ),
+ m_height( aHeight ),
+ m_camera( 2.0f * SCENE3D_RANGE_SCALE_3D )
+{
+ ResetCamera();
+}
+
+
+void SCENE3D_CTX::ResetCamera()
+{
+ m_camera.SetProjection( PROJECTION_TYPE::PERSPECTIVE );
+ m_camera.SetCurWindowSize( wxSize( m_width, m_height ) );
+ m_camera.Reset();
+}
+
+
+void SCENE3D_CTX::SetView( VIEW3D_TYPE aView )
+{
+ // The settled end state of EDA_3D_CANVAS::SetView3D's animation
+ // (eda_3d_canvas.cpp:1455-1481 + the Interpolate(1.0f) at :1354).
+ m_camera.SetT0_and_T1_current_T();
+ m_camera.ViewCommand_T1( aView );
+ m_camera.SetInterpolateMode( CAMERA_INTERPOLATION::LINEAR );
+ m_camera.Interpolate( 1.0f );
+}
+
+
+void SCENE3D_CTX::SetIsoView()
+{
+ ResetCamera();
+ // Tilt board toward the viewer, then spin — a deterministic 3/4 view that
+ // shows top faces, side walls and lighting gradients at once.
+ m_camera.RotateX( -glm::pi() / 3.0f ); // -60°
+ m_camera.RotateZ( glm::pi() / 6.0f ); // +30°
+}
+
+
+void SCENE3D_CTX::SetOrtho( bool aOrtho )
+{
+ m_camera.SetProjection( aOrtho ? PROJECTION_TYPE::ORTHO : PROJECTION_TYPE::PERSPECTIVE );
+ // Force a projection rebuild for the (possibly unchanged) window size:
+ // SetCurWindowSize only rebuilds on size change, so nudge through Reset-safe API.
+ m_camera.SetCurWindowSize( wxSize( m_width, m_height - 1 ) );
+ m_camera.SetCurWindowSize( wxSize( m_width, m_height ) );
+}
+
+
+void SCENE3D_CTX::BeginFrame()
+{
+ // Default viewer background (BOARD_ADAPTER ctor, board_adapter.cpp:132-133).
+ BeginFrame( SFVEC4F( 0.8f, 0.8f, 0.9f, 1.0f ), SFVEC4F( 0.4f, 0.4f, 0.5f, 1.0f ) );
+}
+
+
+void SCENE3D_CTX::BeginFrame( const SFVEC4F& aBgTop, const SFVEC4F& aBgBot )
+{
+ // Per-frame state block replicated verbatim from RENDER_3D_OPENGL::Redraw()
+ // (render_3d_opengl.cpp:553-586). Kept in the same order so the state the
+ // scenarios render under is auditable against the real renderer.
+ glDepthFunc( GL_LESS );
+ glEnable( GL_CULL_FACE );
+ glFrontFace( GL_CCW );
+ glEnable( GL_NORMALIZE );
+ glViewport( 0, 0, m_width, m_height );
+ glEnable( GL_MULTISAMPLE );
+
+ glClearColor( 0.0f, 0.0f, 0.0f, 0.0f );
+ glClearDepth( 1.0f );
+ glClearStencil( 0x00 );
+ glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT );
+
+ OglResetTextureState();
+
+ OglDrawBackground( premultiplyAlpha( aBgTop ), premultiplyAlpha( aBgBot ) );
+
+ glEnable( GL_DEPTH_TEST );
+
+ glMatrixMode( GL_PROJECTION );
+ glLoadMatrixf( glm::value_ptr( m_camera.GetProjectionMatrix() ) );
+ glMatrixMode( GL_MODELVIEW );
+ glLoadIdentity();
+ glLoadMatrixf( glm::value_ptr( m_camera.GetViewMatrix() ) );
+}
+
+
+void SCENE3D_CTX::SetupLights()
+{
+ // Per-frame part only: Redraw() enables the lights and repositions the
+ // headlight after loading the camera matrices (render_3d_opengl.cpp:589-611).
+ // The light PARAMETERS were set once by initLights() at init time — GL bakes
+ // directional-light positions in eye space using the modelview current at
+ // the glLightfv call, and the real renderer sets them under the identity
+ // matrix of a fresh context (initializeOpenGL -> init_lights).
+ EnableLights( true, true, true );
+ glEnable( GL_LIGHTING );
+
+ PositionHeadlight();
+}
+
+
+// The real light-rig initializer from render_3d_opengl.cpp:401 (free function
+// with external linkage; linked since Stage 2).
+void init_lights();
+
+void SCENE3D_CTX::initLights()
+{
+ ::init_lights();
+}
+
+
+void SCENE3D_CTX::EnableLights( bool aFront, bool aTop, bool aBottom )
+{
+ // Same GL_LIGHTx mapping as RENDER_3D_OPENGL::setLightFront/Top/Bottom
+ // (render_3d_opengl.cpp:128-148).
+ if( aFront )
+ glEnable( GL_LIGHT0 );
+ else
+ glDisable( GL_LIGHT0 );
+
+ if( aTop )
+ glEnable( GL_LIGHT1 );
+ else
+ glDisable( GL_LIGHT1 );
+
+ if( aBottom )
+ glEnable( GL_LIGHT2 );
+ else
+ glDisable( GL_LIGHT2 );
+}
+
+
+void SCENE3D_CTX::PositionHeadlight()
+{
+ // Exact headlight placement from Redraw() (render_3d_opengl.cpp:595-611).
+ const SFVEC3F& cameraPos = m_camera.GetPos();
+
+ float zpos;
+
+ if( cameraPos.z > 0.0f )
+ zpos = glm::max( cameraPos.z, 0.5f ) + cameraPos.z * cameraPos.z;
+ else
+ zpos = glm::min( cameraPos.z, -0.5f ) - cameraPos.z * cameraPos.z;
+
+ const GLfloat headlight_pos[] = { cameraPos.x, cameraPos.y, zpos, 1.0f };
+
+ glLightfv( GL_LIGHT0, GL_POSITION, headlight_pos );
+}
+
+
+void SCENE3D_CTX::InitOnce()
+{
+ if( m_circleTexture )
+ return;
+
+ // Replicates RENDER_3D_OPENGL::initializeOpenGL() (render_3d_opengl.cpp:858-896)
+ // minus init_lights() (SetupLights) and m_canvasInitialized bookkeeping.
+ glEnable( GL_LINE_SMOOTH );
+ glShadeModel( GL_SMOOTH );
+ glPixelStorei( GL_UNPACK_ALIGNMENT, 4 );
+
+ IMAGE circleImage( CIRCLE_TEXTURE_SIZE, CIRCLE_TEXTURE_SIZE );
+
+ const unsigned int circleRadius = ( CIRCLE_TEXTURE_SIZE / 2 ) - 4;
+
+ circleImage.CircleFilled( ( CIRCLE_TEXTURE_SIZE / 2 ) - 0, ( CIRCLE_TEXTURE_SIZE / 2 ) - 0,
+ circleRadius, 0xFF );
+
+ IMAGE circleImageBlured( circleImage.GetWidth(), circleImage.GetHeight() );
+
+ circleImageBlured.EfxFilter_SkipCenter( &circleImage, IMAGE_FILTER::GAUSSIAN_BLUR,
+ circleRadius - 8 );
+
+ m_circleTexture = OglLoadTexture( circleImageBlured );
+
+ // initializeOpenGL() ends with init_lights(): the directional lights are
+ // baked in EYE space under the fresh context's identity modelview — that is
+ // why the viewer's scene lighting follows the camera. Keep that semantic.
+ glMatrixMode( GL_MODELVIEW );
+ glLoadIdentity();
+ initLights();
+}
+
+
+OPENGL_RENDER_LIST* SCENE3D_CTX::MakeRenderList( const TRIANGLE_DISPLAY_LIST& aTdl, float aZBot,
+ float aZTop ) const
+{
+ return new OPENGL_RENDER_LIST( aTdl, m_circleTexture, aZBot, aZTop );
+}
diff --git a/tests/3d-regression/scenarios/scene3d_test_ctx.h b/tests/3d-regression/scenarios/scene3d_test_ctx.h
new file mode 100644
index 0000000..ce97339
--- /dev/null
+++ b/tests/3d-regression/scenarios/scene3d_test_ctx.h
@@ -0,0 +1,96 @@
+/**
+ * SCENE3D_CTX — the scenario seam for the 3D-renderer regression suite.
+ *
+ * The ctx provides frame plumbing only: viewport, camera matrices, buffer
+ * clears and light setup, replicating RENDER_3D_OPENGL::Redraw()'s per-frame
+ * state block (render_3d_opengl.cpp:553-611) so that primitive scenarios render
+ * under the same GL state as the real viewer. Everything that produces pixels
+ * (geometry, materials, display lists, textures) must be real KiCad code
+ * called from the scenario bodies.
+ *
+ * This header compiles for the native macOS build AND (later) under
+ * emscripten — no native-only includes.
+ */
+
+#ifndef SCENE3D_TEST_CTX_H
+#define SCENE3D_TEST_CTX_H
+
+#include // GL + GLU, platform-routed (glad native / GLES shim on wasm)
+
+#include
+#include "3d_rendering/track_ball.h"
+#include "3d_rendering/opengl/layer_triangles.h"
+
+// == RANGE_SCALE_3D from 3d-viewer/3d_canvas/board_adapter.h (that header is too
+// heavy to pull into every scenario TU). EDA_3D_VIEWER_FRAME constructs its
+// TRACK_BALL with 2 * RANGE_SCALE_3D (eda_3d_viewer_frame.cpp).
+static constexpr float SCENE3D_RANGE_SCALE_3D = 8.0f;
+
+struct SCENE3D_CTX
+{
+ int m_width;
+ int m_height;
+ TRACK_BALL m_camera;
+
+ SCENE3D_CTX( int aWidth, int aHeight );
+
+ // ---- Camera helpers (all real CAMERA / TRACK_BALL API) ----
+
+ /// Perspective projection, window size set, Reset() — the straight top-down default.
+ void ResetCamera();
+
+ /// Apply a preset view the way EDA_3D_CANVAS does, but settled instantly:
+ /// SetT0_and_T1_current_T() -> ViewCommand_T1(aView) -> Interpolate(1.0f).
+ void SetView( VIEW3D_TYPE aView );
+
+ /// Deterministic 3/4 view for lit-geometry scenarios: Reset + RotateX/RotateZ.
+ void SetIsoView();
+
+ void SetOrtho( bool aOrtho );
+
+ // ---- Frame plumbing (mirrors RENDER_3D_OPENGL::Redraw() lines 553-611) ----
+
+ /// Clears + background gradient + camera matrix upload. Default viewer
+ /// background colors (BOARD_ADAPTER ctor: top 0.8,0.8,0.9 / bot 0.4,0.4,0.5).
+ void BeginFrame();
+ void BeginFrame( const SFVEC4F& aBgTop, const SFVEC4F& aBgBot );
+
+ // ---- Lights ----
+
+ /// Per-frame light enable + headlight placement, as Redraw() does after
+ /// loading the camera matrices (render_3d_opengl.cpp:589-611). The light
+ /// parameters themselves were baked at init time (InitOnce -> initLights).
+ void SetupLights();
+
+ /// glEnable/glDisable GL_LIGHT0 (headlight/front), GL_LIGHT1 (top), GL_LIGHT2 (bottom)
+ /// — same mapping as RENDER_3D_OPENGL::setLightFront/Top/Bottom.
+ void EnableLights( bool aFront, bool aTop, bool aBottom );
+
+ /// Exact headlight placement formula from Redraw() (render_3d_opengl.cpp:595-611).
+ void PositionHeadlight();
+
+ // ---- Renderer-init state + circle texture ----
+
+ /// One-time GL state + the segment-ends circle texture, replicating the
+ /// non-member-state part of RENDER_3D_OPENGL::initializeOpenGL()
+ /// (render_3d_opengl.cpp:858-896): GL_LINE_SMOOTH, glShadeModel(GL_SMOOTH),
+ /// GL_UNPACK_ALIGNMENT=4, then the real IMAGE::CircleFilled +
+ /// EfxFilter_SkipCenter(GAUSSIAN_BLUR) + OglLoadTexture recipe.
+ void InitOnce();
+
+ GLuint GetCircleTexture() const { return m_circleTexture; }
+
+ /// Wrap a filled TRIANGLE_DISPLAY_LIST into GL display lists — thin sugar
+ /// over the real OPENGL_RENDER_LIST ctor with the ctx circle texture.
+ OPENGL_RENDER_LIST* MakeRenderList( const TRIANGLE_DISPLAY_LIST& aTdl, float aZBot,
+ float aZTop ) const;
+
+private:
+ /// Stage-1 verbatim copy of ::init_lights() (render_3d_opengl.cpp:401-445);
+ /// becomes a call to the real free function once Stage 2 links it.
+ void initLights();
+
+ GLuint m_circleTexture = 0;
+};
+
+#endif // SCENE3D_TEST_CTX_H
diff --git a/tests/3d-regression/scenarios/scene3d_test_rig.h b/tests/3d-regression/scenarios/scene3d_test_rig.h
new file mode 100644
index 0000000..f289ad6
--- /dev/null
+++ b/tests/3d-regression/scenarios/scene3d_test_rig.h
@@ -0,0 +1,44 @@
+/**
+ * Shared Tier-2/Tier-3 rig: stub settings + synthetic BOARD_ADAPTER
+ * (native/board_adapter_test_impl.cpp InitSettings) + a real RENDER_3D_OPENGL
+ * bound to the scenario camera, initialized the way the first real Redraw()
+ * would (initializeOpenGL under the fresh identity modelview — init_lights
+ * re-runs identically, keeping the directional lights eye-anchored).
+ */
+
+#ifndef SCENE3D_TEST_RIG_H
+#define SCENE3D_TEST_RIG_H
+
+#include "scene3d_test_ctx.h"
+
+#include "../native/render3d_test_accessor.h"
+
+#include "3d_canvas/board_adapter.h"
+#include "3d_rendering/opengl/render_3d_opengl.h"
+#include "3d_viewer/eda_3d_viewer_settings.h"
+
+#include
+
+struct SCENE3D_TEST_RIG
+{
+ EDA_3D_VIEWER_SETTINGS m_cfg;
+ BOARD_ADAPTER m_adapter;
+ std::unique_ptr m_renderer;
+
+ explicit SCENE3D_TEST_RIG( SCENE3D_CTX& aCtx )
+ {
+ m_adapter.m_Cfg = &m_cfg;
+ m_adapter.InitSettings( nullptr, nullptr );
+
+ m_renderer = std::make_unique( nullptr, m_adapter, aCtx.m_camera );
+
+ glMatrixMode( GL_MODELVIEW );
+ glLoadIdentity();
+ R3D_InitializeOpenGL( *m_renderer );
+ }
+
+ RENDER_3D_OPENGL& operator*() { return *m_renderer; }
+ RENDER_3D_OPENGL* operator->() { return m_renderer.get(); }
+};
+
+#endif // SCENE3D_TEST_RIG_H
diff --git a/tests/3d-regression/scenarios/scene3d_test_scenarios.cpp b/tests/3d-regression/scenarios/scene3d_test_scenarios.cpp
new file mode 100644
index 0000000..5bf3cd3
--- /dev/null
+++ b/tests/3d-regression/scenarios/scene3d_test_scenarios.cpp
@@ -0,0 +1,146 @@
+#include "scene3d_test_scenarios.h"
+#include "scene3d_test_ctx.h"
+
+#include
+
+// Tier-1 scenario functions (scenario_tier1_*.cpp)
+void Scenario_BgGradient( SCENE3D_CTX& aCtx );
+void Scenario_BgGradientAlpha( SCENE3D_CTX& aCtx );
+void Scenario_BoundingBox( SCENE3D_CTX& aCtx );
+void Scenario_HalfOpenCylinder( SCENE3D_CTX& aCtx );
+void Scenario_SegmentSingle( SCENE3D_CTX& aCtx );
+void Scenario_SegmentsStar( SCENE3D_CTX& aCtx );
+void Scenario_RoundArrow( SCENE3D_CTX& aCtx );
+void Scenario_RoundArrowsAxes( SCENE3D_CTX& aCtx );
+void Scenario_MaterialCopper( SCENE3D_CTX& aCtx );
+void Scenario_MaterialDiffuseOnly( SCENE3D_CTX& aCtx );
+void Scenario_MaterialTransparent( SCENE3D_CTX& aCtx );
+void Scenario_LightFront( SCENE3D_CTX& aCtx );
+void Scenario_LightTop( SCENE3D_CTX& aCtx );
+void Scenario_LightBottom( SCENE3D_CTX& aCtx );
+void Scenario_TdlDrawTop( SCENE3D_CTX& aCtx );
+void Scenario_TdlDrawBot( SCENE3D_CTX& aCtx );
+void Scenario_TdlDrawMiddle( SCENE3D_CTX& aCtx );
+void Scenario_TdlDrawAll( SCENE3D_CTX& aCtx );
+void Scenario_TdlSegEndsTexture( SCENE3D_CTX& aCtx );
+void Scenario_TdlCulledStencil( SCENE3D_CTX& aCtx );
+void Scenario_TdlZScale( SCENE3D_CTX& aCtx );
+void Scenario_TdlTransparent( SCENE3D_CTX& aCtx );
+void Scenario_Model3dOpaque( SCENE3D_CTX& aCtx );
+void Scenario_Model3dTransparent( SCENE3D_CTX& aCtx );
+void Scenario_Model3dMaterialModes( SCENE3D_CTX& aCtx );
+void Scenario_Model3dBbox( SCENE3D_CTX& aCtx );
+void Scenario_SpheresGizmo( SCENE3D_CTX& aCtx );
+void Scenario_CameraPersp( SCENE3D_CTX& aCtx );
+void Scenario_CameraOrtho( SCENE3D_CTX& aCtx );
+void Scenario_CameraPresetViews( SCENE3D_CTX& aCtx );
+
+// Tier-2 scenario functions (scenario_tier2_generators.cpp)
+void Scenario_GenCylinder( SCENE3D_CTX& aCtx );
+void Scenario_GenInvCone( SCENE3D_CTX& aCtx );
+void Scenario_GenDisk( SCENE3D_CTX& aCtx );
+void Scenario_GenDimple( SCENE3D_CTX& aCtx );
+void Scenario_AddObjAllShapes( SCENE3D_CTX& aCtx );
+void Scenario_PostMachining( SCENE3D_CTX& aCtx );
+void Scenario_ViaComposite( SCENE3D_CTX& aCtx );
+void Scenario_Grid1mm( SCENE3D_CTX& aCtx );
+void Scenario_Grid2p5mm( SCENE3D_CTX& aCtx );
+void Scenario_Grid5mm( SCENE3D_CTX& aCtx );
+void Scenario_Grid10mm( SCENE3D_CTX& aCtx );
+void Scenario_LayerMaterials( SCENE3D_CTX& aCtx );
+void Scenario_ArrowMaterial( SCENE3D_CTX& aCtx );
+void Scenario_CreateBoard( SCENE3D_CTX& aCtx );
+
+// Tier-3 scenario functions (scenario_tier3_composite.cpp)
+void Scenario_RedrawEmpty( SCENE3D_CTX& aCtx );
+void Scenario_RedrawMiniBoard( SCENE3D_CTX& aCtx );
+void Scenario_RedrawMiniBoardNavigator( SCENE3D_CTX& aCtx );
+
+namespace Scene3DTest
+{
+
+struct SCENARIO_ENTRY
+{
+ const char* name; // becomes 3d-.png — append-only, never rename
+ void ( *render )( SCENE3D_CTX& );
+};
+
+static const SCENARIO_ENTRY SCENARIOS[] = {
+ { "bg-gradient", Scenario_BgGradient },
+ { "bg-gradient-alpha", Scenario_BgGradientAlpha },
+ { "bounding-box", Scenario_BoundingBox },
+ { "half-open-cylinder", Scenario_HalfOpenCylinder },
+ { "segment-single", Scenario_SegmentSingle },
+ { "segments-star", Scenario_SegmentsStar },
+ { "round-arrow", Scenario_RoundArrow },
+ { "round-arrows-axes", Scenario_RoundArrowsAxes },
+ { "material-copper", Scenario_MaterialCopper },
+ { "material-diffuse-only", Scenario_MaterialDiffuseOnly },
+ { "material-transparent", Scenario_MaterialTransparent },
+ { "light-front", Scenario_LightFront },
+ { "light-top", Scenario_LightTop },
+ { "light-bottom", Scenario_LightBottom },
+ { "tdl-draw-top", Scenario_TdlDrawTop },
+ { "tdl-draw-bot", Scenario_TdlDrawBot },
+ { "tdl-draw-middle", Scenario_TdlDrawMiddle },
+ { "tdl-draw-all", Scenario_TdlDrawAll },
+ { "tdl-seg-ends-texture", Scenario_TdlSegEndsTexture },
+ { "tdl-culled-stencil", Scenario_TdlCulledStencil },
+ { "tdl-zscale", Scenario_TdlZScale },
+ { "tdl-transparent", Scenario_TdlTransparent },
+ { "model3d-opaque", Scenario_Model3dOpaque },
+ { "model3d-transparent", Scenario_Model3dTransparent },
+ { "model3d-material-modes", Scenario_Model3dMaterialModes },
+ { "model3d-bbox", Scenario_Model3dBbox },
+ { "spheres-gizmo", Scenario_SpheresGizmo },
+ { "camera-persp", Scenario_CameraPersp },
+ { "camera-ortho", Scenario_CameraOrtho },
+ { "camera-preset-views", Scenario_CameraPresetViews },
+ { "gen-cylinder", Scenario_GenCylinder },
+ { "gen-invcone", Scenario_GenInvCone },
+ { "gen-disk", Scenario_GenDisk },
+ { "gen-dimple", Scenario_GenDimple },
+ { "addobj-all-shapes", Scenario_AddObjAllShapes },
+ { "post-machining", Scenario_PostMachining },
+ { "via-composite", Scenario_ViaComposite },
+ { "grid-1mm", Scenario_Grid1mm },
+ { "grid-2p5mm", Scenario_Grid2p5mm },
+ { "grid-5mm", Scenario_Grid5mm },
+ { "grid-10mm", Scenario_Grid10mm },
+ { "layer-materials", Scenario_LayerMaterials },
+ { "arrow-material", Scenario_ArrowMaterial },
+ { "create-board", Scenario_CreateBoard },
+ { "redraw-empty", Scenario_RedrawEmpty },
+ { "redraw-mini-board", Scenario_RedrawMiniBoard },
+ { "redraw-mini-board-navigator", Scenario_RedrawMiniBoardNavigator },
+};
+
+static const int SCENARIO_COUNT = sizeof( SCENARIOS ) / sizeof( SCENARIOS[0] );
+
+
+int GetScenarioCount()
+{
+ return SCENARIO_COUNT;
+}
+
+
+const char* GetScenarioName( int aIndex )
+{
+ if( aIndex < 0 || aIndex >= SCENARIO_COUNT )
+ return nullptr;
+
+ return SCENARIOS[aIndex].name;
+}
+
+
+void RenderScenario( SCENE3D_CTX& aCtx, int aIndex )
+{
+ wxASSERT( aIndex >= 0 && aIndex < SCENARIO_COUNT );
+
+ if( aIndex < 0 || aIndex >= SCENARIO_COUNT )
+ return;
+
+ SCENARIOS[aIndex].render( aCtx );
+}
+
+} // namespace Scene3DTest
diff --git a/tests/3d-regression/scenarios/scene3d_test_scenarios.h b/tests/3d-regression/scenarios/scene3d_test_scenarios.h
new file mode 100644
index 0000000..c26f66e
--- /dev/null
+++ b/tests/3d-regression/scenarios/scene3d_test_scenarios.h
@@ -0,0 +1,26 @@
+/**
+ * Scenario registry for the 3D-renderer regression suite.
+ *
+ * Scenario names are the single source of truth: they become the committed
+ * baseline filenames (3d-.png), the entries of manifest.json and later
+ * the WebGL-port test IDs. Names are append-only — never renumber or rename.
+ */
+
+#ifndef SCENE3D_TEST_SCENARIOS_H
+#define SCENE3D_TEST_SCENARIOS_H
+
+struct SCENE3D_CTX;
+
+namespace Scene3DTest
+{
+
+int GetScenarioCount();
+const char* GetScenarioName( int aIndex );
+
+/// Render scenario aIndex. The scenario body calls aCtx.BeginFrame(...) itself
+/// (some scenarios use custom background colors) and then real KiCad 3D code.
+void RenderScenario( SCENE3D_CTX& aCtx, int aIndex );
+
+} // namespace Scene3DTest
+
+#endif // SCENE3D_TEST_SCENARIOS_H
diff --git a/tests/3d-regression/scenarios/test_board_data.cpp b/tests/3d-regression/scenarios/test_board_data.cpp
new file mode 100644
index 0000000..ad7ab8c
--- /dev/null
+++ b/tests/3d-regression/scenarios/test_board_data.cpp
@@ -0,0 +1,202 @@
+#include "test_board_data.h"
+
+#include
+#include
+
+const BOARD_ITEM& DummyBoardItem()
+{
+ // The reference is stored by OBJECT_2D but never dereferenced (object_2d.h:114);
+ // aligned opaque storage stands in so no pcbnew types need to link.
+ alignas( 16 ) static unsigned char storage[256] = {};
+ return *reinterpret_cast( storage );
+}
+
+
+// ---- S3DMODEL widget -------------------------------------------------------
+// Static storage: S3DMODEL/SMESH point into these arrays (the struct carries
+// raw pointers; MODEL_3D copies everything into VBOs on construction).
+
+// Axis-aligned box: 24 vertices (4 per face, flat normals), 36 indices.
+static void fillBox( SFVEC3F* aPos, SFVEC3F* aNorm, unsigned int* aIdx, const SFVEC3F& aMin,
+ const SFVEC3F& aMax )
+{
+ const SFVEC3F n[6] = {
+ { 0, 0, 1 }, { 0, 0, -1 }, { 1, 0, 0 }, { -1, 0, 0 }, { 0, 1, 0 }, { 0, -1, 0 },
+ };
+
+ // 4 corners per face, CCW seen from outside.
+ const SFVEC3F c[6][4] = {
+ // +Z
+ { { aMin.x, aMin.y, aMax.z }, { aMax.x, aMin.y, aMax.z }, { aMax.x, aMax.y, aMax.z },
+ { aMin.x, aMax.y, aMax.z } },
+ // -Z
+ { { aMin.x, aMin.y, aMin.z }, { aMin.x, aMax.y, aMin.z }, { aMax.x, aMax.y, aMin.z },
+ { aMax.x, aMin.y, aMin.z } },
+ // +X
+ { { aMax.x, aMin.y, aMin.z }, { aMax.x, aMax.y, aMin.z }, { aMax.x, aMax.y, aMax.z },
+ { aMax.x, aMin.y, aMax.z } },
+ // -X
+ { { aMin.x, aMin.y, aMin.z }, { aMin.x, aMin.y, aMax.z }, { aMin.x, aMax.y, aMax.z },
+ { aMin.x, aMax.y, aMin.z } },
+ // +Y
+ { { aMin.x, aMax.y, aMin.z }, { aMin.x, aMax.y, aMax.z }, { aMax.x, aMax.y, aMax.z },
+ { aMax.x, aMax.y, aMin.z } },
+ // -Y
+ { { aMin.x, aMin.y, aMin.z }, { aMax.x, aMin.y, aMin.z }, { aMax.x, aMin.y, aMax.z },
+ { aMin.x, aMin.y, aMax.z } },
+ };
+
+ for( int f = 0; f < 6; f++ )
+ {
+ for( int v = 0; v < 4; v++ )
+ {
+ aPos[f * 4 + v] = c[f][v];
+ aNorm[f * 4 + v] = n[f];
+ }
+
+ aIdx[f * 6 + 0] = f * 4 + 0;
+ aIdx[f * 6 + 1] = f * 4 + 1;
+ aIdx[f * 6 + 2] = f * 4 + 2;
+ aIdx[f * 6 + 3] = f * 4 + 0;
+ aIdx[f * 6 + 4] = f * 4 + 2;
+ aIdx[f * 6 + 5] = f * 4 + 3;
+ }
+}
+
+
+// Octahedron: 8 triangular faces, 24 vertices (flat normals), 24 indices.
+static void fillOctahedron( SFVEC3F* aPos, SFVEC3F* aNorm, unsigned int* aIdx,
+ const SFVEC3F& aCenter, float aRadius )
+{
+ const SFVEC3F apexTop = aCenter + SFVEC3F( 0, 0, aRadius );
+ const SFVEC3F apexBot = aCenter - SFVEC3F( 0, 0, aRadius );
+
+ const SFVEC3F equator[4] = {
+ aCenter + SFVEC3F( aRadius, 0, 0 ),
+ aCenter + SFVEC3F( 0, aRadius, 0 ),
+ aCenter + SFVEC3F( -aRadius, 0, 0 ),
+ aCenter + SFVEC3F( 0, -aRadius, 0 ),
+ };
+
+ unsigned int v = 0;
+
+ for( int i = 0; i < 4; i++ )
+ {
+ const SFVEC3F& e0 = equator[i];
+ const SFVEC3F& e1 = equator[( i + 1 ) % 4];
+
+ // top face (CCW from outside)
+ SFVEC3F nTop = glm::normalize( glm::cross( e1 - e0, apexTop - e0 ) );
+ aPos[v] = e0;
+ aPos[v + 1] = e1;
+ aPos[v + 2] = apexTop;
+ aNorm[v] = aNorm[v + 1] = aNorm[v + 2] = nTop;
+ aIdx[v] = v;
+ aIdx[v + 1] = v + 1;
+ aIdx[v + 2] = v + 2;
+ v += 3;
+
+ // bottom face
+ SFVEC3F nBot = glm::normalize( glm::cross( apexBot - e0, e1 - e0 ) );
+ aPos[v] = e1;
+ aPos[v + 1] = e0;
+ aPos[v + 2] = apexBot;
+ aNorm[v] = aNorm[v + 1] = aNorm[v + 2] = nBot;
+ aIdx[v] = v;
+ aIdx[v + 1] = v + 1;
+ aIdx[v + 2] = v + 2;
+ v += 3;
+ }
+}
+
+
+const S3DMODEL& TestS3DModel()
+{
+ // mesh 0: opaque red plastic box
+ static SFVEC3F boxPos[24];
+ static SFVEC3F boxNorm[24];
+ static unsigned int boxIdx[36];
+
+ // mesh 1: transparent blue octahedron
+ static SFVEC3F octPos[24];
+ static SFVEC3F octNorm[24];
+ static unsigned int octIdx[24];
+
+ // mesh 2: per-vertex-colored box (m_Color array exercises GL_COLOR_ARRAY)
+ static SFVEC3F colBoxPos[24];
+ static SFVEC3F colBoxNorm[24];
+ static SFVEC3F colBoxColor[24];
+ static unsigned int colBoxIdx[36];
+
+ static SMESH meshes[3];
+ static SMATERIAL materials[3];
+ static S3DMODEL model;
+ static bool initialized = false;
+
+ if( !initialized )
+ {
+ initialized = true;
+
+ fillBox( boxPos, boxNorm, boxIdx, SFVEC3F( -2.2f, -1.2f, 0.0f ),
+ SFVEC3F( -0.2f, 1.2f, 1.2f ) );
+
+ fillOctahedron( octPos, octNorm, octIdx, SFVEC3F( 1.4f, 0.0f, 0.9f ), 1.1f );
+
+ fillBox( colBoxPos, colBoxNorm, colBoxIdx, SFVEC3F( -0.6f, -2.4f, 0.0f ),
+ SFVEC3F( 1.0f, -1.2f, 0.7f ) );
+
+ for( int i = 0; i < 24; i++ )
+ {
+ colBoxColor[i] = SFVEC3F( ( i % 3 ) == 0 ? 1.0f : 0.2f, ( i % 3 ) == 1 ? 1.0f : 0.2f,
+ ( i % 3 ) == 2 ? 1.0f : 0.2f );
+ }
+
+ meshes[0] = { 24, boxPos, boxNorm, nullptr, nullptr, 36, boxIdx, 0 };
+ meshes[1] = { 24, octPos, octNorm, nullptr, nullptr, 24, octIdx, 1 };
+ meshes[2] = { 24, colBoxPos, colBoxNorm, nullptr, colBoxColor, 36, colBoxIdx, 2 };
+
+ // { Ambient, Diffuse, Emissive, Specular, Shininess, Transparency }
+ materials[0] = { { 0.30f, 0.05f, 0.05f }, { 0.80f, 0.10f, 0.10f }, { 0, 0, 0 },
+ { 0.30f, 0.30f, 0.30f }, 0.30f, 0.00f };
+ materials[1] = { { 0.05f, 0.05f, 0.30f }, { 0.15f, 0.25f, 0.90f }, { 0, 0, 0 },
+ { 0.60f, 0.60f, 0.70f }, 0.80f, 0.50f };
+ materials[2] = { { 0.15f, 0.15f, 0.15f }, { 0.70f, 0.70f, 0.70f }, { 0, 0, 0 },
+ { 0.90f, 0.90f, 0.90f }, 0.90f, 0.00f };
+
+ model = { 3, meshes, 3, materials };
+ }
+
+ return model;
+}
+
+
+std::vector MakeSquareContour( float aHalf, float aCenterX, float aCenterY )
+{
+ // CCW, closed (first point repeated last) — AddToMiddleContours processes
+ // size-1 segments (layer_triangles.cpp:123-133).
+ return {
+ SFVEC2F( aCenterX - aHalf, aCenterY - aHalf ),
+ SFVEC2F( aCenterX + aHalf, aCenterY - aHalf ),
+ SFVEC2F( aCenterX + aHalf, aCenterY + aHalf ),
+ SFVEC2F( aCenterX - aHalf, aCenterY + aHalf ),
+ SFVEC2F( aCenterX - aHalf, aCenterY - aHalf ),
+ };
+}
+
+
+std::vector MakeCircleContour( float aRadius, int aSides, float aCenterX,
+ float aCenterY )
+{
+ std::vector points;
+ points.reserve( aSides + 1 );
+
+ for( int i = 0; i < aSides; i++ )
+ {
+ const float a = 2.0f * static_cast( M_PI ) * i / aSides;
+ points.emplace_back( aCenterX + aRadius * std::cos( a ),
+ aCenterY + aRadius * std::sin( a ) );
+ }
+
+ points.push_back( points.front() );
+ return points;
+}
diff --git a/tests/3d-regression/scenarios/test_board_data.h b/tests/3d-regression/scenarios/test_board_data.h
new file mode 100644
index 0000000..c39ed73
--- /dev/null
+++ b/tests/3d-regression/scenarios/test_board_data.h
@@ -0,0 +1,35 @@
+/**
+ * Hand-built input DATA for the scenarios (geometry containers, S3DMODEL,
+ * contours). Data only — everything that draws must be real KiCad code.
+ */
+
+#ifndef TEST_BOARD_DATA_H
+#define TEST_BOARD_DATA_H
+
+#include // S3DMODEL / SMESH / SMATERIAL
+#include
+
+#include
+
+// The shapes2D constructors take a BOARD_ITEM& that is only stored for later
+// identification, never dereferenced by any code the suite runs
+// (object_2d.h:114). Only the forward declaration exists here.
+class BOARD_ITEM;
+
+/// An opaque never-dereferenced BOARD_ITEM reference for the shapes2D ctors.
+const BOARD_ITEM& DummyBoardItem();
+
+/// A small multi-mesh, multi-material widget: opaque box + transparent
+/// octahedron + per-vertex-colored box. Arrays live in static storage; the
+/// returned struct stays valid for the process lifetime.
+const S3DMODEL& TestS3DModel();
+
+/// Closed CCW square contour (first point repeated last) for AddToMiddleContours.
+std::vector MakeSquareContour( float aHalf, float aCenterX = 0.0f,
+ float aCenterY = 0.0f );
+
+/// Closed regular-polygon contour approximating a circle.
+std::vector MakeCircleContour( float aRadius, int aSides, float aCenterX = 0.0f,
+ float aCenterY = 0.0f );
+
+#endif // TEST_BOARD_DATA_H
diff --git a/tests/3d-regression/wasm/3d_webgl_test.cpp b/tests/3d-regression/wasm/3d_webgl_test.cpp
new file mode 100644
index 0000000..8134a12
--- /dev/null
+++ b/tests/3d-regression/wasm/3d_webgl_test.cpp
@@ -0,0 +1,120 @@
+/**
+ * 3D-renderer WebGL test harness (WASM) — runs the shared scenarios
+ * (tests/3d-regression/scenarios/) in the browser. Today the legacy GL
+ * surface is wasm/stubs/gl_ffp_stub.c no-ops, so every scenario renders a
+ * black canvas: the TDD red state the WebGL port turns green.
+ *
+ * Unlike the GAL harness this needs no wx window: the renderer draws into
+ * whatever context is current, so a direct emscripten WebGL2 context on
+ * #canvas is enough — created with the attributes the suite requires
+ * (stencil for DrawCulled, no MSAA to match the native single-sample FBO,
+ * preserveDrawingBuffer for Playwright canvas screenshots).
+ */
+
+#include
+#include
+
+#include "scene3d_test_ctx.h"
+#include "scene3d_test_scenarios.h"
+
+#include
+
+static const int CAPTURE_WIDTH = 800; // must match manifest.json + native FBO
+static const int CAPTURE_HEIGHT = 600;
+
+static EMSCRIPTEN_WEBGL_CONTEXT_HANDLE g_context = 0;
+static SCENE3D_CTX* g_ctx = nullptr;
+
+extern "C"
+{
+
+EMSCRIPTEN_KEEPALIVE
+int getTotalScenarios()
+{
+ return Scene3DTest::GetScenarioCount();
+}
+
+EMSCRIPTEN_KEEPALIVE
+const char* getScenarioName( int aIndex )
+{
+ return Scene3DTest::GetScenarioName( aIndex );
+}
+
+EMSCRIPTEN_KEEPALIVE
+int getCanvasWidth()
+{
+ return CAPTURE_WIDTH;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int getCanvasHeight()
+{
+ return CAPTURE_HEIGHT;
+}
+
+EMSCRIPTEN_KEEPALIVE
+int runScenario( int aIndex )
+{
+ if( aIndex < 0 || aIndex >= Scene3DTest::GetScenarioCount() )
+ return -1;
+
+ if( emscripten_webgl_make_context_current( g_context ) != EMSCRIPTEN_RESULT_SUCCESS )
+ return -2;
+
+ if( !g_ctx )
+ {
+ g_ctx = new SCENE3D_CTX( CAPTURE_WIDTH, CAPTURE_HEIGHT );
+ g_ctx->InitOnce();
+ }
+
+ std::printf( "[3d-webgl] scenario %d: %s\n", aIndex, Scene3DTest::GetScenarioName( aIndex ) );
+
+ // Start from a cleared frame; scenarios call BeginFrame themselves.
+ glViewport( 0, 0, CAPTURE_WIDTH, CAPTURE_HEIGHT );
+ glClearColor( 0.0f, 0.0f, 0.0f, 1.0f );
+ glClearDepth( 1.0f );
+ glClearStencil( 0 );
+ glClear( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT );
+
+ Scene3DTest::RenderScenario( *g_ctx, aIndex );
+
+ glFinish();
+ return 0;
+}
+
+} // extern "C"
+
+
+int main()
+{
+ EmscriptenWebGLContextAttributes attrs;
+ emscripten_webgl_init_context_attributes( &attrs );
+
+ attrs.majorVersion = 2;
+ attrs.minorVersion = 0;
+ attrs.alpha = false;
+ attrs.depth = true;
+ attrs.stencil = true; // DrawCulled hole subtraction
+ attrs.antialias = false; // native goldens are single-sample
+ attrs.preserveDrawingBuffer = true; // Playwright canvas.screenshot()
+
+ emscripten_set_canvas_element_size( "#canvas", CAPTURE_WIDTH, CAPTURE_HEIGHT );
+
+ g_context = emscripten_webgl_create_context( "#canvas", &attrs );
+
+ if( g_context <= 0 )
+ {
+ std::fprintf( stderr, "[3d-webgl] failed to create WebGL2 context (%ld)\n",
+ (long) g_context );
+ return 1;
+ }
+
+ emscripten_webgl_make_context_current( g_context );
+
+ std::printf( "[3d-webgl] ready: %d scenarios, %dx%d\n", Scene3DTest::GetScenarioCount(),
+ CAPTURE_WIDTH, CAPTURE_HEIGHT );
+
+ EM_ASM( { if( window._threeDTestReady ) window._threeDTestReady(); } );
+
+ return 0;
+}
diff --git a/tests/3d-regression/wasm/3d_webgl_test.html b/tests/3d-regression/wasm/3d_webgl_test.html
new file mode 100644
index 0000000..519c42c
--- /dev/null
+++ b/tests/3d-regression/wasm/3d_webgl_test.html
@@ -0,0 +1,35 @@
+
+
+
+
+ 3D Renderer WebGL Test
+
+
+
+
+
+
+
+
+
diff --git a/tests/3d-regression/wasm/Makefile b/tests/3d-regression/wasm/Makefile
new file mode 100644
index 0000000..3199816
--- /dev/null
+++ b/tests/3d-regression/wasm/Makefile
@@ -0,0 +1,194 @@
+# Makefile for the 3D-renderer WebGL test harness (WASM) — TDD RED STATE.
+#
+# Compiles the SAME shared scenarios + real KiCad 3D-viewer TUs as the native
+# golden generator (tests/3d-regression/native/CMakeLists.txt), but links the
+# fixed-function GL surface against wasm/stubs/gl_ffp_stub.c no-ops — exactly
+# how the production kicad build satisfies RENDER_3D_OPENGL's link today.
+# Every scenario therefore renders BLANK until the WebGL port replaces those
+# stubs; `npm run 3d:check:parity` is the port-progress meter.
+#
+# Usage:
+# make # Build
+# make clean # Clean build artifacts
+# make DEBUG=1 # Debug build with source maps
+
+CXX = em++
+CC = emcc
+
+PROJECT_ROOT = ../../..
+KICAD_ROOT = $(PROJECT_ROOT)/kicad
+WX_BUILD = $(PROJECT_ROOT)/build-wasm/wxwidgets
+
+OUTPUT_DIR = ../../apps/3d-webgl
+
+# wxWidgets WASM (the KiCad TUs reference wxString/wxLogTrace/wxASSERT).
+WXCONFIG = $(WX_BUILD)/wx-config
+WX_CXXFLAGS := $(shell $(WXCONFIG) --cxxflags)
+WX_LDFLAGS := $(shell $(WXCONFIG) --libs base,core,gl)
+
+# Sysroot (boost, glm, ...)
+SYSROOT = $(PROJECT_ROOT)/build-wasm/sysroot
+
+# Mirrors the include set of tests/3d-regression/native/CMakeLists.txt.
+KICAD_INCLUDES = -I../native \
+ -I../scenarios \
+ -I$(KICAD_ROOT)/include \
+ -I$(KICAD_ROOT)/3d-viewer \
+ -I$(KICAD_ROOT)/3d-viewer/3d_rendering \
+ -I$(KICAD_ROOT)/3d-viewer/3d_viewer \
+ -I$(KICAD_ROOT)/pcbnew \
+ -I$(KICAD_ROOT)/common \
+ -I$(KICAD_ROOT)/libs/kimath/include \
+ -I$(KICAD_ROOT)/libs/core/include \
+ -I$(KICAD_ROOT)/thirdparty/clipper2/Clipper2Lib/include \
+ -I$(KICAD_ROOT)/thirdparty/dynamic_bitset \
+ -I$(KICAD_ROOT)/thirdparty/rtree \
+ -I$(KICAD_ROOT)/thirdparty/magic_enum/magic_enum \
+ -I$(KICAD_ROOT)/thirdparty/expected/include \
+ -I$(KICAD_ROOT)/thirdparty/nlohmann_json \
+ -I$(KICAD_ROOT)/thirdparty \
+ -I$(PROJECT_ROOT)/wasm/stubs \
+ -I$(SYSROOT)/include
+
+ifdef DEBUG
+ OPT_FLAGS = -g -O0
+ DEBUG_LDFLAGS = -g -gsource-map
+else
+ OPT_FLAGS = -O1
+ DEBUG_LDFLAGS =
+endif
+
+# Match the wx/KiCad WASM exception model (scripts/common/env.sh DEPS_EH_FLAGS).
+DEPS_EH_FLAGS ?= -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1
+
+# USINGZ: KiCad builds clipper2 with it (PUBLIC compile definition).
+CXXFLAGS = $(OPT_FLAGS) $(DEPS_EH_FLAGS) -DUSINGZ $(WX_CXXFLAGS) $(KICAD_INCLUDES) -std=c++20 -MMD -MP
+CFLAGS = $(OPT_FLAGS) $(DEPS_EH_FLAGS) -I$(PROJECT_ROOT)/wasm/stubs
+
+BASE_LDFLAGS = $(DEPS_EH_FLAGS) \
+ -sALLOW_MEMORY_GROWTH=1 \
+ -sERROR_ON_UNDEFINED_SYMBOLS=0 \
+ -sEXPORTED_FUNCTIONS=['_main','_runScenario','_getTotalScenarios','_getScenarioName','_getCanvasWidth','_getCanvasHeight'] \
+ -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap'] \
+ -sMODULARIZE=1 \
+ -sEXPORT_NAME='create3DTest' \
+ -sENVIRONMENT=web,worker
+
+# Pure WebGL 2.0 context; the FFP calls are stubbed C no-ops (no
+# LEGACY_GL_EMULATION), same as the production kicad_editor build.
+EM_GL_FLAGS = -sMAX_WEBGL_VERSION=2
+
+LDFLAGS = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(EM_GL_FLAGS) $(WX_LDFLAGS)
+
+MAIN_SRCS = 3d_webgl_test.cpp
+
+SCENARIO_SRCS = ../scenarios/scene3d_test_scenarios.cpp \
+ ../scenarios/scene3d_test_ctx.cpp \
+ ../scenarios/test_board_data.cpp \
+ $(wildcard ../scenarios/scenario_*.cpp)
+
+# Test-impl TUs shared with the native harness (BOARD_ADAPTER seam, settings
+# stub, link stubs, private-member accessor).
+TESTIMPL_SRCS = ../native/board_adapter_test_impl.cpp \
+ ../native/settings_3d_stub.cpp \
+ ../native/kicad_stubs_3d.cpp \
+ ../native/render3d_test_accessor.cpp
+
+# Same real-KiCad TU list as the native CMakeLists (minus glad — Emscripten
+# provides the modern GL surface, gl_ffp_stub.c the legacy one).
+KICAD_3D_SRCS = \
+ $(KICAD_ROOT)/3d-viewer/common_ogl/ogl_utils.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/image.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/buffers_debug.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/color_rgba.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/track_ball.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/trackball.cpp \
+ $(KICAD_ROOT)/common/gal/3d/camera.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/layer_triangles.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/opengl_utils.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/3d_model.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/3d_spheres_gizmo.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/ray.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/accelerators/container_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/object_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/bbox_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/round_segment_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/filled_circle_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes3D/bbox_3d.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/trigo.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/eda_angle.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/seg.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/math/util.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/render_3d_opengl.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/opengl/create_scene.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/render_3d_base.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/ring_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/triangle_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/4pt_polygon_2d.cpp \
+ $(KICAD_ROOT)/3d-viewer/3d_rendering/raytracing/shapes2D/polygon_2d.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_poly_set.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_line_chain.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_arc.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/vertex_set.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/geometry_utils.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/convert_basic_shapes_to_polygon.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/md5_hash.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/bezier_curves.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/circle.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/arc_chord_params.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/corner_operations.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_collisions.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_compound.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_rect.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_nearest_points.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/half_line.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_utils.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/roundrect.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/line.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/geometry/shape_segment.cpp \
+ $(KICAD_ROOT)/libs/kimath/src/math/vector2.cpp \
+ $(KICAD_ROOT)/common/layer_id.cpp \
+ $(KICAD_ROOT)/common/gal/color4d.cpp \
+ $(KICAD_ROOT)/common/kicad_gl/gl_context_mgr.cpp \
+ $(KICAD_ROOT)/common/lset.cpp \
+ $(KICAD_ROOT)/libs/core/utf8.cpp \
+ $(KICAD_ROOT)/thirdparty/clipper2/Clipper2Lib/src/clipper.engine.cpp \
+ $(KICAD_ROOT)/thirdparty/clipper2/Clipper2Lib/src/clipper.offset.cpp \
+ $(KICAD_ROOT)/thirdparty/clipper2/Clipper2Lib/src/clipper.rectclip.cpp
+
+# THE RED STATE: legacy fixed-function GL as no-ops.
+FFP_STUB_SRCS = $(PROJECT_ROOT)/wasm/stubs/gl_ffp_stub.c
+
+SRCS = $(MAIN_SRCS) $(SCENARIO_SRCS) $(TESTIMPL_SRCS) $(KICAD_3D_SRCS)
+OBJS = $(SRCS:.cpp=.o) $(FFP_STUB_SRCS:.c=.o)
+DEPS = $(SRCS:.cpp=.d)
+
+TARGET = $(OUTPUT_DIR)/3d_webgl_test.js
+
+all: $(OUTPUT_DIR) $(TARGET)
+
+$(OUTPUT_DIR):
+ mkdir -p $(OUTPUT_DIR)
+
+%.o: %.cpp
+ $(CXX) -c $(CXXFLAGS) $< -o $@
+
+%.o: %.c
+ $(CC) -c $(CFLAGS) $< -o $@
+
+$(TARGET): $(OBJS)
+ @echo "Linking $(words $(OBJS)) objects..."
+ $(CXX) $(OBJS) $(LDFLAGS) -o $@
+ cp 3d_webgl_test.html $(OUTPUT_DIR)/
+
+clean:
+ rm -f $(OBJS) $(DEPS)
+ rm -f $(OUTPUT_DIR)/3d_webgl_test.js
+ rm -f $(OUTPUT_DIR)/3d_webgl_test.wasm
+ rm -f $(OUTPUT_DIR)/3d_webgl_test.html
+ rm -f ../scenarios/*.o ../scenarios/*.d ../native/*.o ../native/*.d
+
+.PHONY: all clean
+
+-include $(DEPS)
diff --git a/tests/apps/3d-webgl/3d_webgl_test.html b/tests/apps/3d-webgl/3d_webgl_test.html
new file mode 100644
index 0000000..519c42c
--- /dev/null
+++ b/tests/apps/3d-webgl/3d_webgl_test.html
@@ -0,0 +1,35 @@
+
+
+
+
+ 3D Renderer WebGL Test
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/3d-webgl.spec.ts b/tests/e2e/3d-webgl.spec.ts
new file mode 100644
index 0000000..ff45033
--- /dev/null
+++ b/tests/e2e/3d-webgl.spec.ts
@@ -0,0 +1,81 @@
+/**
+ * 3D Renderer WebGL Regression — capture-only.
+ *
+ * Renders every scenario of the 3D suite (tests/3d-regression) in the browser
+ * and writes 3d-.png into tests/3d-regression/output/webgl/. This spec
+ * never compares pixels: the gates live in `npm run 3d:check:webgl`
+ * (browser-regression, once baseline-webgl exists) and the informational
+ * `npm run 3d:check:parity` port-progress meter (expected ~100% changed while
+ * the FFP stubs render blank — the TDD red state).
+ *
+ * Anti-drift: no hand-typed scenario list. The committed
+ * tests/3d-regression/manifest.json (written by the native golden generator,
+ * cmp-guarded by scripts/test-3d-regression.sh) is the single source of truth,
+ * and the wasm registry is asserted against it name-by-name.
+ */
+
+import { test, expect } from './utils/fixtures';
+import * as path from 'path';
+import * as fs from 'fs';
+
+const MANIFEST_PATH = path.join(__dirname, '../3d-regression/manifest.json');
+const OUTPUT_DIR = path.join(__dirname, '../3d-regression/output/webgl');
+const APP_JS = path.join(__dirname, '../apps/3d-webgl/3d_webgl_test.js');
+
+const MANIFEST: { width: number; height: number; scenarios: string[] } = JSON.parse(
+ fs.readFileSync(MANIFEST_PATH, 'utf8')
+);
+
+test.describe('3D WebGL Regression', () => {
+ test.skip(
+ !fs.existsSync(APP_JS),
+ '3d-webgl harness not built (run scripts/build-3d-webgl-test.sh)'
+ );
+
+ test.beforeAll(async () => {
+ fs.mkdirSync(OUTPUT_DIR, { recursive: true });
+ });
+
+ test('module loads and registry matches the committed manifest', async ({ page }) => {
+ await page.goto('/3d-webgl/3d_webgl_test.html');
+
+ await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, {
+ timeout: 60000,
+ });
+
+ const total = await page.evaluate(() => (window as any).threeDTest.getTotalScenarios());
+ expect(total).toBe(MANIFEST.scenarios.length);
+
+ const names = await page.evaluate((count) => {
+ const t = (window as any).threeDTest;
+ return Array.from({ length: count }, (_, i) => t.getScenarioName(i));
+ }, total);
+ expect(names).toEqual(MANIFEST.scenarios);
+
+ const width = await page.evaluate(() => (window as any).threeDTest.getCanvasWidth());
+ const height = await page.evaluate(() => (window as any).threeDTest.getCanvasHeight());
+ expect(width).toBe(MANIFEST.width);
+ expect(height).toBe(MANIFEST.height);
+ });
+
+ test('render all scenarios', async ({ page }) => {
+ test.setTimeout(300000);
+
+ await page.goto('/3d-webgl/3d_webgl_test.html');
+ await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, {
+ timeout: 60000,
+ });
+
+ for (const [i, name] of MANIFEST.scenarios.entries()) {
+ const rc = await page.evaluate((idx) => (window as any).threeDTest.runScenario(idx), i);
+ expect(rc, `runScenario(${i}) [${name}]`).toBe(0);
+
+ // One composite tick so the preserved drawing buffer is presentable.
+ await page.evaluate(() => new Promise(requestAnimationFrame));
+
+ await page
+ .locator('#canvas')
+ .screenshot({ path: path.join(OUTPUT_DIR, `3d-${name}.png`) });
+ }
+ });
+});
diff --git a/tests/package.json b/tests/package.json
index 83aa729..4545190 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -48,7 +48,12 @@
"screenshots:noise": "tsx tools/screenshots/noise.ts",
"screenshots:report": "tsx tools/screenshots/post-discord.ts",
"screenshots:changelog": "tsx tools/screenshots/changelog.ts",
- "screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts"
+ "screenshots:manifest": "tsx tools/screenshots/gen-manifest.ts",
+ "3d:compare": "tsx tools/screenshots/compare-dirs.ts",
+ "3d:check": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/native --out 3d-regression/output/diff/native-self --floors 3d-regression/floors.json --level native-self --label 3d-native --fail-on-change",
+ "3d:check:webgl": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline-webgl --new 3d-regression/output/webgl --out 3d-regression/output/diff/webgl-self --floors 3d-regression/floors.json --level webgl-self --label 3d-webgl --fail-on-change",
+ "3d:check:parity": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/webgl --out 3d-regression/output/diff/parity --floors 3d-regression/floors.json --level webgl-vs-native --label 3d-parity",
+ "3d:test:webgl": "playwright test e2e/3d-webgl.spec.ts"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
diff --git a/tests/tools/screenshots/compare-dirs.ts b/tests/tools/screenshots/compare-dirs.ts
new file mode 100644
index 0000000..870cdaa
--- /dev/null
+++ b/tests/tools/screenshots/compare-dirs.ts
@@ -0,0 +1,188 @@
+/**
+ * Directory-pair screenshot comparison for the standalone regression suites
+ * (first consumer: tests/3d-regression). Reuses the one comparison engine
+ * (comparePair + image-ops) without touching the product gate in compare.ts —
+ * that gate is welded to BASELINE_DIRS/RESULTS_DIR/manifest and must stay
+ * byte-identical in behavior.
+ *
+ * Run modes (from the `tests/` directory):
+ * tsx tools/screenshots/compare-dirs.ts --old --new --out \
+ * [--floors --level ] # per-suite floor config
+ * [--floor ] # ad-hoc floor (wins over --floors)
+ * [--fail-on-change] # exit 1 on changed/missing/extra
+ * [--label ] # caption suffix on triptychs
+ *
+ * The old dir is the reference side. A PNG present only in --old is MISSING,
+ * only in --new is EXTRA; with a name-stable scenario registry both are
+ * failures under --fail-on-change (an extra PNG means registry and baselines
+ * diverged). Exit codes: 0 ok (or report-only), 1 fail-on-change tripped,
+ * 2 usage/IO error.
+ */
+import * as fs from 'fs';
+import * as path from 'path';
+import { LABEL, labelText, type EngineFloor } from './config';
+import { comparePair, type ChangedEntry } from './compare';
+import { loadPng, savePng, withBottomLabel } from './image-ops';
+
+const DEFAULT_FLOOR: EngineFloor = { changedRatio: 0.005, meanChannelGuard: 2.0 };
+
+type LevelFloors = { default?: EngineFloor; overrides?: Record };
+type FloorsFile = Record;
+
+type DirReport = {
+ old: string;
+ new: string;
+ level: string | null;
+ changed: ChangedEntry[];
+ /** present in --new only (kept as `added` to match compare.ts's Report shape) */
+ added: Array<{ name: string; image: string }>;
+ /** present in --old only */
+ removed: Array<{ name: string; image: string }>;
+ unchangedCount: number;
+};
+
+function listPngs(dir: string): string[] {
+ return fs
+ .readdirSync(dir)
+ .filter((f) => f.toLowerCase().endsWith('.png'))
+ .sort();
+}
+
+function parseArgs(argv: string[]): Record {
+ const out: Record = {};
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--old') out.old = argv[++i];
+ else if (a === '--new') out.new = argv[++i];
+ else if (a === '--out') out.out = argv[++i];
+ else if (a === '--floors') out.floors = argv[++i];
+ else if (a === '--level') out.level = argv[++i];
+ else if (a === '--floor') out.floor = argv[++i];
+ else if (a === '--label') out.label = argv[++i];
+ else if (a === '--fail-on-change') out.failOnChange = true;
+ else {
+ console.error(`[compare-dirs] unknown argument: ${a}`);
+ process.exit(2);
+ }
+ }
+ return out;
+}
+
+function usageError(msg: string): never {
+ console.error(`[compare-dirs] ${msg}`);
+ console.error(
+ '[compare-dirs] usage: --old --new --out ' +
+ '[--floors --level ] [--floor ] [--fail-on-change] [--label ]'
+ );
+ process.exit(2);
+}
+
+/** Resolution: --floor (ad hoc) → floors.json per-name override → level default → built-in. */
+function makeFloorResolver(args: Record): (name: string) => EngineFloor {
+ if (args.floor !== undefined) {
+ const ratio = Number(args.floor);
+ if (!Number.isFinite(ratio) || ratio < 0) usageError(`--floor must be a non-negative number, got "${args.floor}"`);
+ const adHoc: EngineFloor = { changedRatio: ratio, meanChannelGuard: DEFAULT_FLOOR.meanChannelGuard };
+ return () => adHoc;
+ }
+ if (args.floors === undefined) return () => DEFAULT_FLOOR;
+ if (args.level === undefined) usageError('--floors requires --level');
+ if (!fs.existsSync(args.floors as string)) usageError(`floors file not found: ${args.floors}`);
+ let floorsFile: FloorsFile;
+ try {
+ floorsFile = JSON.parse(fs.readFileSync(args.floors as string, 'utf8'));
+ } catch (e) {
+ usageError(`could not parse ${args.floors}: ${(e as Error).message}`);
+ }
+ const level = floorsFile[args.level as string];
+ if (!level) usageError(`level "${args.level}" not found in ${args.floors}`);
+ return (name) => level.overrides?.[name] ?? level.default ?? DEFAULT_FLOOR;
+}
+
+function main(): void {
+ const args = parseArgs(process.argv.slice(2));
+ for (const req of ['old', 'new', 'out'] as const) {
+ if (!args[req]) usageError(`--${req} is required`);
+ }
+ const oldDir = args.old as string;
+ const newDir = args.new as string;
+ const outDir = args.out as string;
+ if (!fs.existsSync(oldDir)) usageError(`--old dir not found: ${oldDir}`);
+ if (!fs.existsSync(newDir)) usageError(`--new dir not found: ${newDir}`);
+ const label = (args.label as string) || null;
+ const levelName = (args.level as string) || null;
+ const floorFor = makeFloorResolver(args);
+
+ fs.mkdirSync(outDir, { recursive: true });
+ const oldNames = listPngs(oldDir);
+ const newNames = new Set(listPngs(newDir));
+
+ const report: DirReport = {
+ old: oldDir,
+ new: newDir,
+ level: levelName,
+ changed: [],
+ added: [],
+ removed: [],
+ unchangedCount: 0,
+ };
+
+ const caption = (status: 'added' | 'removed' | 'changed', name: string) => labelText(status, name, label);
+
+ for (const name of oldNames) {
+ if (!newNames.has(name)) {
+ const imagePath = path.join(outDir, `${name}.removed.png`);
+ savePng(imagePath, withBottomLabel(loadPng(path.join(oldDir, name)), caption('removed', name), LABEL.colors.removed));
+ report.removed.push({ name, image: imagePath });
+ continue;
+ }
+ let pair: ReturnType;
+ try {
+ pair = comparePair(
+ loadPng(path.join(oldDir, name)),
+ loadPng(path.join(newDir, name)),
+ name,
+ floorFor(name)
+ );
+ } catch (e) {
+ console.error(`[compare-dirs] failed to compare ${name}: ${(e as Error).message}`);
+ process.exit(2);
+ }
+ const { result, heatmap, triptych } = pair;
+ if (result.verdict === 'unchanged') {
+ report.unchangedCount++;
+ continue;
+ }
+ const triptychPath = path.join(outDir, `${name}.triptych.png`);
+ const heatmapPath = path.join(outDir, `${name}.heatmap.png`);
+ savePng(triptychPath, withBottomLabel(triptych, caption('changed', name), LABEL.colors.changed));
+ savePng(heatmapPath, heatmap);
+ report.changed.push({ ...result, triptych: triptychPath, heatmap: heatmapPath });
+ }
+
+ for (const name of newNames) {
+ if (oldNames.includes(name)) continue;
+ const imagePath = path.join(outDir, `${name}.added.png`);
+ savePng(imagePath, withBottomLabel(loadPng(path.join(newDir, name)), caption('added', name), LABEL.colors.added));
+ report.added.push({ name, image: imagePath });
+ }
+
+ report.changed.sort((a, b) => b.changedRatio - a.changedRatio);
+ fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2));
+
+ console.log(
+ `[compare-dirs]${levelName ? ` level=${levelName}` : ''} changed=${report.changed.length} ` +
+ `missing=${report.removed.length} extra=${report.added.length} unchanged=${report.unchangedCount}`
+ );
+ for (const c of report.changed.slice(0, 10)) {
+ console.log(` CHANGED ${c.name} ratio=${(c.changedRatio * 100).toFixed(3)}% ${c.driftHint}`);
+ }
+ for (const r of report.removed) console.log(` MISSING ${r.name} (in --old only)`);
+ for (const a of report.added) console.log(` EXTRA ${a.name} (in --new only)`);
+
+ if (args.failOnChange && (report.changed.length || report.added.length || report.removed.length)) {
+ process.exitCode = 1;
+ }
+}
+
+main();