Skip to content

add text-layer data model#14

Open
luckydye wants to merge 13 commits into
mainfrom
claude/text-layers-design-OA09U
Open

add text-layer data model#14
luckydye wants to merge 13 commits into
mainfrom
claude/text-layers-design-OA09U

Conversation

@luckydye
Copy link
Copy Markdown
Owner

Introduces a declarative Layer::Text variant alongside Image/Crop/
Adjustment, plus a per-stack font registry. Glyph rasterization and
GPU placement land in follow-up commits; the renderer currently skips
text layers so the rest of the stack continues to render unchanged.

  • shade-lib: new text module with TextContent, TextStyle, TextSpan,
    FontEntry, TextAlign, TextAnchor; FNV-1a 64-bit content hashing for
    font dedup.
  • LayerStack gains fonts and next_font_id, both #[serde(default)]
    for backward-compat with pre-text documents. New methods:
    add_text_layer, add_font (dedups by hash), find_font_by_hash,
    remove_unused_fonts.
  • Renderer treats Text layers as no-ops via a diverging match arm.
  • shade-wasm/bridge.rs and shade-tauri/commands.rs exhaustive matches
    on Layer extended with Text arms.

claude added 9 commits April 26, 2026 21:43
Introduces a declarative Layer::Text variant alongside Image/Crop/
Adjustment, plus a per-stack font registry. Glyph rasterization and
GPU placement land in follow-up commits; the renderer currently skips
text layers so the rest of the stack continues to render unchanged.

- shade-lib: new `text` module with TextContent, TextStyle, TextSpan,
  FontEntry, TextAlign, TextAnchor; FNV-1a 64-bit content hashing for
  font dedup.
- LayerStack gains `fonts` and `next_font_id`, both #[serde(default)]
  for backward-compat with pre-text documents. New methods:
  add_text_layer, add_font (dedups by hash), find_font_by_hash,
  remove_unused_fonts.
- Renderer treats Text layers as no-ops via a diverging match arm.
- shade-wasm/bridge.rs and shade-tauri/commands.rs exhaustive matches
  on `Layer` extended with Text arms.
Adds the CPU-side geometry pipeline that the GPU rasterizer (Slug-style
direct curve rendering, PR3) will consume. Pure CPU, fully unit-tested
without a font fixture or GPU.

- shade-lib now depends on ttf-parser 0.25 for OTF/TTF parsing.
- New `text_outline` module:
  - QuadBezier / Rect / GlyphBand / GlyphCurves types in em-units.
  - OutlineCollector implements ttf_parser::OutlineBuilder; widens
    line_to into degenerate quadratics with midpoint controls and
    recursively subdivides cubics into quadratics under a closed-form
    error bound (Sederberg, sqrt(3)/36 · |Δ|), with a depth cap.
  - quad_y_extent solves for the interior y' = 0 extremum so band
    assignment includes apex bands the endpoints miss.
  - outline_glyph(blob, glyph_id, num_bands) is the public entry point.
- 18 unit tests cover the builder, cubic subdivision (including the
  degenerate S-curve that defeats midpoint-distance error proxies),
  y-extent solving, and band assignment.
The composite kernel was multiplying mask × opacity but ignoring the
layer's own alpha channel, so transparent regions of a layer still
overrode the base. That works for opaque image layers but is wrong for
text (alpha-coverage everywhere outside glyph fills) and for alpha-PNG
image layers, which incorrectly blend zeroed RGB over the base.

Multiply by layer.a so transparent pixels preserve the base, opaque
pixels blend through, and partial coverage interpolates correctly.

Note: GPU tests are skipped in sandboxed environments without a wgpu
adapter; CI will exercise the visual path on real hardware.
Adds the CPU-side packing that turns text_outline's GlyphCurves into the
four shared storage buffers consumed by the upcoming text render pass,
plus a per-layer instance buffer encoder. All bytemuck Pod, fixed
16-byte-safe layouts, fully unit-tested.

- GpuGlyphMeta (48 B), GpuBand (16 B), GpuPlacedGlyph (48 B), all Pod
  with compile-time size assertions.
- GlyphBufferLayout accumulates curves (Vec<f32>, 6 per quad), bands
  (Vec<GpuBand>), the sparse band->curve index (Vec<u32>), and metas
  with a (FontId, glyph_id) lookup.
- add_glyph is idempotent: repeated calls return the existing meta
  index without growing any buffer.
- build_instances maps PlacedGlyph -> GpuPlacedGlyph by meta index,
  erroring with a descriptive message if a glyph wasn't registered.
- 11 tests cover empty layout, single-glyph encoding, idempotency,
  multi-glyph offsets, empty (whitespace) glyphs, instance encoding,
  unregistered-glyph error path, band-curve index remapping, and
  bytemuck round-trip.
Adds the layout stage that bridges TextContent/TextStyle to a flat list
of PlacedGlyphs in canvas pixels. The GPU rasterizer (PR3c) consumes
this output unchanged via text_buffer::build_instances.

- Dep: cosmic-text 0.12 with default-features off; explicit features
  ["std", "swash", "shape-run-cache"] keep system-font discovery and
  wasm-web bits out of the build, satisfying the "no web feature"
  constraint while pulling shaping (rustybuzz) and rasterization
  (swash) for future GPU-pass use.
- TextLayoutEngine owns a FontSystem populated from a manually built
  fontdb::Database — no system probe — and a bidirectional id mapping
  between shade FontId and fontdb::ID so shaped glyphs translate back
  cleanly. layout() walks layout_runs and emits PlacedGlyphs at the
  pen position (run.line_y + GPOS y_offset), with glyph.font_size as
  the rendered px size.
- layout_text() is a one-shot convenience wrapper.
- Tests:
  - 4 logic tests: empty content, unregistered font_id, empty font
    map accepted, garbage blob rejected.
  - 3 live cosmic-text tests using a system font (DejaVu/Loma/etc.):
    LTR monotonicity, max_width forces a wrap, advance scales
    proportionally with font size. Skip gracefully on minimal CI
    where no font is installed.
After reading the reference shaders at EricLengyel/Slug (MIT, public-
domain patent dedication), redesign the per-glyph CPU/GPU data layout
to match what the Slug pixel shader actually consumes.

Three concrete changes versus the prior single-axis design:

1. Two perpendicular band sets per glyph. The fragment shader needs an
   H band (curves crossing y=pixel.y) AND a V band (crossing x=pixel.x)
   to compute coverage on both axes; combining them is what gives
   Slug's analytic AA. GlyphCurves now carries h_bands + v_bands; the
   header buffer is laid out [h..., v...] per glyph so the shader's
   `glyphLoc.x + bandMax.y + 1 + bandIndex.x` arithmetic translates
   directly.

2. Uniform-stride bands driven by a per-glyph band_transform. Replace
   per-band y_min/y_max with a (scale_x, scale_y, offset_x, offset_y)
   tuple satisfying band_index = clamp(floor(coord·scale + offset),
   0, band_max) — the exact formula from Slug's pixel shader.

3. Sort curves descending by perpendicular-axis max within each band.
   H bands sort by descending max-x, V bands by descending max-y. This
   enables the shader's left-of-pixel early-out
   (`if max_x · pixels_per_em.x < -0.5 break`).

text_buffer changes:
- GpuGlyphMeta grew from 48 B to 64 B; now carries band_headers_offset,
  band_max_x/y, band_transform, em_bbox, units_per_em.
- GpuBand replaced by GpuBandHeader (8 B): just (curve_count, offset).
- band_curves stores GLOBAL curve indices (so the shader doesn't need
  to know which glyph a curve belongs to).
- add_glyph remaps each band's local indices into globals.

Tests:
- 11 text_outline tests including new x-extent solver, band-transform
  formula assertion, descending-sort order on both axes.
- 9 text_buffer tests including h-then-v header layout, global index
  remapping across multiple glyphs, multi-band glyph header counts,
  and the existing idempotency / instance-encoding suite.

99/99 unit tests pass; downstream crates clean.
Ports the EricLengyel/Slug reference shaders (MIT, public-domain
patent dedication 2026-03-17) to WGSL and wires them into a render
pipeline that produces an Rgba16Float color attachment for the
existing CompositePipeline to consume.

shaders/text_glyph.wgsl
- Vertex shader: instanced quad per placed glyph, six vertices forming
  two CCW triangles, sized to the glyph's em bbox plus a half-pixel
  margin in em-units. TrueType Y is flipped to screen Y. No dilation
  yet — will show ≤1-px cracks under heavy transforms; v1.1 follow-up.
- Pixel shader: full Slug analytic-coverage path. fwidth-derived
  pixels-per-em, dual-axis band lookup, root-finding via
  CalcRootCode/SolveHorizPoly/SolveVertPoly, weighted xcov+ycov
  combination with min-of-magnitudes fallback. Outputs straight RGBA
  so the CompositePipeline contract is preserved.
- Storage-buffer adaptation of Slug's 2D-texture data path: curves,
  band_headers, band_curves_idx, glyph_metas, instances all in one
  bind group. Defensive 256-curves-per-band loop cap.

text_pipeline.rs
- TextPipeline owns the render pipeline and bind-group layout.
- process(layout, instances, w, h) acquires a work texture (now with
  RENDER_ATTACHMENT in WORK_TEXTURE_USAGE), uploads the five storage
  buffers (16-byte zero pad if any are empty so wgpu validation
  accepts the binding), encodes a render pass that clears to
  transparent and draws 6×N vertices, and returns the texture.
- Standard src-over straight-alpha blending so adjacent glyph quads
  with overlapping margins composite cleanly without darkening.

Tests: WGSL is parsed + validated under naga (re-exported via wgpu)
at cargo-test time, no GPU required. Catches reserved-keyword and
type-checking errors locally — already caught one (`meta` is reserved).

101 unit tests pass; downstream crates clean.
Replaces the placeholder `continue` arm with a full text-layer render
pass that produces an Rgba16Float `layer_result` consumed by the
existing CompositePipeline alongside Image / Crop / Adjustment layers.

shade-lib/src/renderer.rs
- Renderer gains a TextPipeline field, constructed in Renderer::new.
- New helper render_text_layer:
  1. Build a TextLayoutEngine from stack.fonts (rebuilt per call for
     v1; caching by stack.generation is a follow-up).
  2. Lay out content+style → Vec<PlacedGlyph>.
  3. Apply the layer transform's translation (rotation/scale deferred
     until the vertex shader takes a matrix).
  4. Extract outlines via outline_glyph for each unique glyph and pack
     into a fresh GlyphBufferLayout. Whitespace glyphs are dropped
     before instance encoding so the draw call stays tight.
  5. Run TextPipeline::process to produce the layer texture.
  6. Returns Option<Texture> — None means empty/all-whitespace, in
     which case the composite step is skipped entirely.
- The Layer::Text match arm now calls this and falls through to the
  unchanged composite path with the produced texture.

shade-lib/tests/text_layer_e2e.rs
- GPU + system-font skipping integration test, mirroring the existing
  crop_rotation_e2e pattern. Two cases:
  - "Hi" rendered black-on-white at 48px and asserts ≥16 dark pixels
    survive the composite.
  - Empty TextContent: every pixel stays near-white (the helper
    short-circuits before TextPipeline runs).

101 unit tests + 2 integration tests pass; downstream crates clean.
On the sandboxed runner without an adapter both e2e tests skip cleanly;
real hardware in CI will exercise the full chain.
Wires the existing text-layer + font data model end-to-end through the
bridge, web worker, and Inspector so users can create text layers,
upload fonts, and edit content + style from the SolidJS UI.

Backend (shade-wasm + shade-tauri, mirrored):
- Engine methods on WasmEngine: add_font, list_fonts, add_text_layer,
  update_text_content, update_text_style, set_text_transform,
  prune_unused_fonts.
- wasm-bindgen wrappers + Tauri commands for each. update_text_style
  takes a JSON patch so omitted fields stay untouched while
  max_width=null explicitly clears it.
- LayerEntryInfo / get_stack_json now expose text content, style, and
  transform alongside crop/adjustment fields.

Frontend (shade-ui):
- TS bridge: addTextLayer, updateTextContent, updateTextStyle,
  setTextTransform, addFont, listFonts, pruneUnusedFonts. New
  interfaces TextLayerValues, TextStyleValues, TextStylePatch,
  TextTransformValues, FontInfo, plus TextAlignName/TextAnchorName.
- editor-store gains a `fonts` field; editor-layers gains store
  helpers around the bridge calls and refreshFontList.
- New TextLayerEditor SolidJS component: textarea for content, font
  picker with inline upload (.ttf/.otf/.ttc), sliders for size /
  line-height / letter-spacing, color picker (sRGB <-> linear), weight
  dropdown, italic toggle, alignment buttons, max-width input, and
  X/Y position fields. Color values round-trip through linear-sRGB
  exactly as stored on Layer::Text.
- Inspector wires TextLayerEditor into the desktop and mobile selected
  -layer panels via a new selectedTextLayer() guard, and adds an
  "Add Text" button next to "Add Adjustment". When no font is
  registered the new layer uses font_id=0 as a placeholder; the user
  uploads a font from the editor's font picker.

Verified:
- `cargo test -p shade-lib --lib`: 101/101 pass.
- `cargo test -p shade-wasm`: 4/4 pass.
- `cargo check -p shade-wasm` clean.
- `tsc --noEmit` on shade-ui clean. Pre-existing BinaryFile.ts errors
  in shade-web are unrelated and unchanged.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
shade Ready Ready Preview, Comment May 5, 2026 8:59pm

The Add Text button was visually identical to Add Adjustment and the
text layer rows fell back to the unlabelled border-square placeholder,
making them easy to overlook. Adds a "T" glyph in both the LayerTypeIcon
for text layers and the icon column of the Add Text button so the entry
points are visually distinct from the adjustment chain controls.
The switch was missing the "text" case, so getLayerDisplayName would
throw whenever a text layer was in the stack. This crash inside the
DesktopLayerList render prevented both Add Adjustment and Add Text
buttons from appearing.
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.

2 participants