Skip to content

fix(capi): render cube node shape with 3D depth (issue #49)#50

Closed
willwade wants to merge 1 commit into
mainfrom
fix/cube-3d-depth-capi
Closed

fix(capi): render cube node shape with 3D depth (issue #49)#50
willwade wants to merge 1 commit into
mainfrom
fix/cube-3d-depth-capi

Conversation

@willwade

Copy link
Copy Markdown

Problem

After #48, cube mode (LP_SHAPE_TYPE == CUBE) rendered via the C API but looked identical to flat rectangles. CommandScreen::DrawCube received CubeDepthLevel nodeDepth/parentDepth (the extrusion depth) and discarded both, emitting a single opcode-4 rect per node. The 3D extruded-cube appearance the mode is supposed to provide was missing.

Fix (Option A from the issue)

Decompose each cube into three shaded faces using existing opcodes (no command-buffer format change):

  • Top face: base colour lightened ~22%, offset upward (opcode 4)
  • Right face: base colour darkened ~22%, offset rightward (opcode 4)
  • Front face: base colour, full size (opcode 4)
  • Outline: opcode 3 on the front-face bounds

Painter's order (right → top → front) makes each face's bounding rect resolve to the correct visible colour.

Depth-aware extrusion

The CubeDepthLevel is now used instead of discarded:

  • The extrusion offset scales with the cube's screen size (0.15 × min(w,h), clamped [1,16]), so large cubes pop more than tiny ones.
  • It attenuates with distance from the crosshair plane: FinishRender3D now stores the crosshair's extrusionLevel (one-frame lag — DrawCube runs before FinishRender3D within a frame), and each DrawCube divides its offset by 1 + 0.35·|nodeDepth.extrusionLevel − crosshairLevel|. Result: the focused node extrudes most, distant ones recede.

Added a shadeARGB helper (lighten/darken a normalised ARGB int) next to colorToARGB. FinishRender3D stores the crosshair level; no other interface changes, no new opcodes.

Test

New case in test_cube_geometry.cpp: cube mode now emits ~3× the filled rects of flat rectangle mode for the same node tree (top+front+right faces). Measured: flat=210, cube=670 (~3.2×). Asserts cube ≥ 2× flat, which fails on the single-rect code from #48 (~1×). Existing cube tests still pass.

Verification

  • clang-format clean (--Werror dry-run)
  • Debug + Release: cube/circle/view tests pass
  • dasher_draw_snapshot_tests (golden) + dasher_draw_command_tests pass — default-shape rendering unchanged

Notes / trade-offs

  • 3–4 commands per cube node instead of 1 (acknowledged perf cost in the issue; acceptable for typical node counts).
  • Cubes use a fixed up-right oblique projection with a top-left light; vertical orientations still read as 3D.
  • The flat buffer still carries no true 3D extrusion — this is a shaded-rectangle emulation. Frontends wanting real OpenGL/Metal 3D would need the dedicated opcode (Option B in the issue), which deliberately isn't done here to avoid changing the frozen command format.

@willwade

Copy link
Copy Markdown
Author

Testing feedback — cubes not visually distinguishable from flat

Tested locally in Dasher-Apple (macOS + iOS). After a clean build with this PR, Cube mode shows a faint lighter bar at the top of some nodes but does not read as 3D/cube. Here's why and how to fix it.

Root cause: rendering order buries the 3D shading

Dasher renders nodes depth-first (parent → children). Each child's flat front-face rectangle () paints directly over the parent's shaded top/right faces. By the time the frame completes, only the outermost leaf nodes retain any visible shading — everything else is buried under child rectangles.

The extrusion strips also fall in the wrong place:

  • Right face ( to ): extends past Dasher's zoom opening (right edge) — off-screen or invisible.
  • Top face ( to ): immediately overwritten by the node above it in the vertical stack.

Dasher's node layout is simply too dense and overlapping for 2D extrusion shading to be visible. The original cube mode worked on GTK/Windows because those frontends had real 3D rendering pipelines (depth buffers, perspective projection, z-ordering). The C API's flat command buffer (painter's algorithm only) cannot replicate this.

Proposed fix: new opcode + two-pass frontend rendering

Add one new opcode so the depth data survives the command buffer intact:

opcode 7: Cube — [7, x1, y1, x2, y2, extrusionLevel, fillARGB, outlineARGB, thickness]

CommandScreen::DrawCube emits opcode 7 (raw data, no decomposition). Frontends go two-pass:

  1. Pass 1: render opcodes 0–6 in order (flat layout + text). Produces the correct node layout.
  2. Pass 2: render opcode-7 cubes as a 3D overlay composited on top — alpha-blended shaded faces with visible extrusion offsets scaled to actual screen gaps.

The cube overlay is a visual enhancement layered over the flat layout, not a replacement. Each frontend implements pass 2 with its own graphics API (CGContext, Metal, Skia, Canvas, etc.).

Work estimate

Piece Effort
DasherCore: emit opcode 7 in CommandScreen::DrawCube trivial (~5 lines)
DasherCore: document opcode 7 in dasher.h + C_API.md ~10 lines
Each frontend: two-pass render loop + pass-2 cube compositing moderate (platform-specific)

Alternative

If the opcode approach is too much for now, revert to the #48 single-rect version (functional but flat) and relabel Cube in settings as requiring a 3D-capable frontend. The flat command buffer fundamentally cannot produce visible 3D cubes with painter's algorithm alone.

@willwade
willwade force-pushed the fix/cube-3d-depth-capi branch from 200d969 to b7e518d Compare July 25, 2026 15:33
@willwade

Copy link
Copy Markdown
Author

Thanks for the test report — you're right, the shaded-rectangle emulation can't work under painter's algorithm here. I confirmed the root cause you identified: children paint over parent shading, and the extrusion strips fall past the zoom opening / under the next node up, so only leaves retain any shading.

Revised the PR to Option B (your proposed fix): a dedicated opcode that carries the raw cube data through the buffer for a frontend two-pass overlay.

What changed

CommandScreen::DrawCube no longer decomposes into shaded rects. It emits a single opcode 7 record per cube, carrying everything a 3D overlay needs:

opcode 7 (9 ints): [7, x1, y1, x2, y2, extrusionLevel, fillARGB, outlineARGB, thickness]

extrusionLevel is the per-node depth from CubeDepthLevel — no longer discarded. Frontends render cube mode in two passes as you described:

  1. opcodes 0–6 → flat layout + text
  2. opcode-7 cubes → 3D overlay composited on top (alpha-blended shaded faces, extrusion scaled to actual screen gaps), via the frontend's own graphics API

Format impact

Opcode 7 is the only variable-length command (9 ints vs 6). Parsers must read the opcode first and advance 9 for opcodes 7, 6 otherwise. This is documented in dasher.h, in docs/C_API.md (table + a parse example + an updated "Important Notes" #2), and in the dasher_frame param doc. Draw3DLabel still emits opcode 5 and DrawProjectedRectangle still emits opcode 4 (crosshair bar), so the flat-layout pass already contains the labels and crosshair.

Tests

The cube suite now parses with the 9-int stride and checks:

  • cube nodes arrive as opcode 7 (not opcode 4)
  • labels arrive as opcode 5
  • the depth data survives: collecting extrusionLevel across a driven node tree yields ≥2 distinct values (root vs. children), proving it's no longer flattened

dasher_draw_snapshot_tests / dasher_draw_command_tests (golden, default shape) still pass — opcodes 0–6 are unchanged.

One thing to confirm

I went with the 9-int layout exactly as you sketched, which breaks strict 6-int divisibility (a parser that doesn't know opcode 7 will desync). The alternative is a 12-int (two-slot) encoding so unaware frontends stay in sync and just skip unknown records. I stuck with 9 because (a) it's your spec and (b) any frontend opting into cube mode already needs pass-2 work, so handling the 9-int read is part of the same update. Happy to switch to the 12-int two-slot form if you'd prefer graceful degradation for frontends that haven't updated yet.

Build/test/format are green locally; CI re-running on the force-push.

#49)

Cube mode (LP_SHAPE_TYPE == 6) reached the C API in #48 but looked identical to flat rectangles: DrawCube discarded the CubeDepthLevel and emitted one opcode-4 rect per node. A shaded-rectangle emulation does not work either — Dasher renders depth-first, so each child's flat front face paints over the parent's shaded faces, leaving only leaf nodes with any visible 3D. The flat command buffer (painter's algorithm only, no depth buffer) cannot replicate GTK/Windows cube mode. Fixes issue #49 (revised per review).

Fix (Option B from the issue): DrawCube emits a dedicated opcode-7 record carrying the raw cube data so the depth info survives the buffer intact. Frontends render cube mode in two passes: (1) opcodes 0-6 as the flat layout, (2) opcode-7 cubes composited as a 3D overlay on top via the frontend's own graphics API.

Encoding: opcode 7 occupies TWO 6-int slots (12 ints), both prefixed with 7 — slot 1: [7, x1, y1, x2, y2, extrusionLevel], slot 2: [7, fillARGB, outlineARGB, thickness, 0, 0]. This keeps the buffer uniformly 6-int divisible: a parser advancing 6 ints per slot never desyncs, and frontends that don't know opcode 7 just see two unknown slots per cube and skip them (graceful degradation). Cost is 3 unused ints per cube — negligible (~50-200 visible nodes in a >=10K-int buffer).

Docs: dasher.h and docs/C_API.md document opcode 7, the two-slot layout, the two-pass rendering model, and a parse example. Draw3DLabel still emits opcode 5 and DrawProjectedRectangle opcode 4 (crosshair bar), so the flat-layout pass already has labels + crosshair.

Tests: cube nodes arrive as opcode 7 (not opcode 4); labels as opcode 5. Added cases verifying (a) the buffer stays 6-int divisible (cc % 6 == 0) and (b) the per-node extrusionLevel varies across the tree (>=2 distinct values) — i.e. the depth data is no longer discarded. The parse helper advances 12 ints for opcode 7.
Signed-off-by: will wade <willwade@gmail.com>
@willwade
willwade force-pushed the fix/cube-3d-depth-capi branch from b7e518d to 0096a5c Compare July 25, 2026 15:42
@willwade

Copy link
Copy Markdown
Author

Done — switched to the 12-int two-slot encoding. Good call.

Layout (opcode 7 = two 6-int slots, 12 ints):

slot 1: [7, x1, y1, x2, y2, extrusionLevel]
slot 2: [7, fillARGB, outlineARGB, thickness, 0, 0]

Both slots prefixed with 7. Bonus outcome I hadn't fully appreciated: because opcode 7 still occupies a whole multiple of 6 ints, the buffer stays uniformly 6-int divisible — the for i in stride(0, count, by: 6) loop never desyncs. An unaware frontend reads slot 1 (opcode 7, unknown → skip 6), then slot 2 (opcode 7 again, unknown → skip 6), and carries on cleanly. No crash, no garbage, just missing cubes until they implement pass 2.

The parse example in docs/C_API.md now stays a uniform i += 6 loop with a case 7 that just reads cmds[i+7..i+9] and does one extra i += 6 to skip the continuation slot. Updated the table, the two-pass section, and the "divide by 6" notes accordingly.

Tests updated:

  • cmd_size returns 12 for opcode 7
  • added CHECK(cc % 6 == 0) on a cube frame — verifies the divisibility invariant directly
  • the depth-survives check still collects extrusionLevel from cmds[j+5] (slot 1, offset 5) and asserts ≥2 distinct values across the tree

All cube/draw-snapshot/draw-command tests green locally; format clean; CI re-running on the force-push.

@willwade

Copy link
Copy Markdown
Author

Withdrawing this PR (opcode 7) per the direction in governance RFC 0013 (dasher-project/governance#20).

The two-pass shaded-rectangle overlay is visually unconvincing — the flat command buffer's painter's algorithm can't composite depth correctly, so Dasher's depth-first rendering buries parent shading under child rectangles. Rather than carry a variable-length opcode for a limited result, cube mode over the C API stays at the #48 baseline (flat rectangles — functional, not black) while the proper fix — exposing the node tree via a Strand 2 `dasher_get_visible_nodes` API — is implemented separately so frontends can render real 3D cubes with their own graphics pipelines.

No code change needed on `main`: opcode 7 never landed (this PR was unmerged), so cube mode remains at the #48 state. Closing without merging; branch deleted.

@willwade willwade closed this Jul 25, 2026
@willwade
willwade deleted the fix/cube-3d-depth-capi branch July 25, 2026 16:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant