15 Commits

Author SHA1 Message Date
Sergio a3c2ea9ffa docs(llimphi): README del meta-crate (pitch + GIFs) → bump 0.1.1 2026-06-19 12:10:09 +00:00
Sergio e548698fc4 feat: meta-crate llimphi 0.1.0 (facade re-exporta llimphi-ui + theme + 3d) → cargo add llimphi
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:54:00 +00:00
Sergio 5ae7c73703 chore: publish con --no-verify (evita disco lleno por recompilar cada crate) 2026-06-19 03:00:14 +00:00
Sergio 38d3dddaea chore: publish script reintenta también en errores de red transitorios 2026-06-18 23:13:10 +00:00
Sergio 92dd0e5bfb fix: dev-deps internas path-only (se descartan al publicar; sin ciclos de orden) 2026-06-18 19:00:56 +00:00
Sergio e2102d6057 fix: orden topológico sin dev-deps (ui antes que los widgets) 2026-06-18 18:55:46 +00:00
Sergio efe361545d chore: publish-crates.sh espera/reintenta en rate-limit (429) 2026-06-18 18:40:42 +00:00
Sergio 993b7625b8 fix: versión en deps path directas de los crates (no solo en workspace.deps) para crates.io
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:34:22 +00:00
Sergio dba855e446 chore: preparar publicación a crates.io (publish=true, versiones en deps, descriptions, repository, orden topológico + script)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 18:27:14 +00:00
Sergio 1a205c764d docs: GIF del README = vuelo sobre mundo infinito (reemplaza el turntable)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:39:09 +00:00
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00
Sergio e74800d9da chore: migra host gioser.net → tawasuyu.net (mismo repo, rebrand tawasuyu)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:47:07 +00:00
Sergio 5b7ac4fd5d docs: showreel de widgets reales como hero del README
GIF arriba (widgets reales en movimiento: switch/slider/progress/
segmented/button/radial → morph de layout); el counter (~124 LOC, el
Elm loop completo) queda debajo. Solo docs+assets, no toca código.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:23:20 +00:00
sergio 14d76bd4af docs(readme): URL de clone canónica git.tawasuyu.net/tawasuyu (era gitea.gioser.net/sergio)
el clone/deps del README apuntaban al brand viejo (host gitea.gioser.net + org
sergio). corregido al canónico del rebrand — git.tawasuyu.net/tawasuyu/llimphi —
para que el `git clone` del launch no falle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 02:22:33 +00:00
sergio fbd3dc5ca4 chore: actualizar git-deps a tawasuyu/ (auto-migración) 2026-06-08 17:30:06 +00:00
267 changed files with 44515 additions and 1949 deletions
Generated
+2071 -216
View File
File diff suppressed because it is too large Load Diff
+119 -114
View File
@@ -6,16 +6,17 @@
[workspace]
resolver = "2"
members = [
"llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text",
"llimphi", "llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text",
"llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion",
"llimphi-icons", "llimphi-compositor", "llimphi-workspace",
"widgets/*", "modules/*", "shared/app-bus",
"llimphi-3d", "llimphi-voxel", "shared/foreign-vox",
]
exclude = [
"android",
"llimphi-gallery", "llimphi-gpu-bench",
"widgets/gallery",
"modules/shuma-term", "modules/plugin-host",
"modules/shuma-term", "modules/plugin-host", "modules/allichay",
]
[workspace.package]
@@ -24,12 +25,13 @@ edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://gitea.gioser.net/sergio/llimphi"
publish = true
repository = "https://git.tawasuyu.net/tawasuyu/llimphi"
[workspace.dependencies]
# === Registro de apps / menú global ===
app-bus = { path = "shared/app-bus" }
app-bus = { path = "shared/app-bus", version = "0.1.0" }
foreign-vox = { path = "shared/foreign-vox", version = "0.1.0" }
# === Serialización ===
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -158,87 +160,90 @@ tempfile = "3"
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
wgpu = "24"
wgpu = "27"
winit = "0.30"
raw-window-handle = "0.6"
pollster = "0.4"
vello = "0.5"
vello = "0.7"
taffy = "0.9"
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
parley = "0.4"
parley = "0.6"
accesskit = "0.24"
accesskit_winit = "0.33"
vello_hybrid = "0.0.9"
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
llimphi-ui = { path = "llimphi-ui" }
llimphi-ui = { path = "llimphi-ui", version = "0.1.0" }
# Paleta semántica compartida por las apps y los widgets.
llimphi-theme = { path = "llimphi-theme" }
llimphi-theme = { path = "llimphi-theme", version = "0.1.0" }
# Tweens y helpers de animación sobre el bucle Elm.
llimphi-motion = { path = "llimphi-motion" }
llimphi-motion = { path = "llimphi-motion", version = "0.1.0" }
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
llimphi-icons = { path = "llimphi-icons" }
llimphi-icons = { path = "llimphi-icons", version = "0.1.0" }
# Widgets reusables sobre llimphi-ui — uno por crate.
llimphi-widget-app-header = { path = "widgets/app-header" }
llimphi-widget-banner = { path = "widgets/banner" }
llimphi-widget-button = { path = "widgets/button" }
llimphi-widget-card = { path = "widgets/card" }
llimphi-clipboard = { path = "widgets/clipboard" }
llimphi-widget-context-menu = { path = "widgets/context-menu" }
llimphi-widget-edit-menu = { path = "widgets/edit-menu" }
llimphi-widget-menubar = { path = "widgets/menubar" }
llimphi-widget-list = { path = "widgets/list" }
llimphi-widget-grid = { path = "widgets/grid" }
llimphi-widget-slider = { path = "widgets/slider" }
llimphi-widget-scroll = { path = "widgets/scroll" }
llimphi-widget-splitter = { path = "widgets/splitter" }
llimphi-widget-stat-card = { path = "widgets/stat-card" }
llimphi-widget-tabs = { path = "widgets/tabs" }
llimphi-module-command-palette = { path = "modules/command-palette" }
llimphi-module-diff-viewer = { path = "modules/diff-viewer" }
llimphi-module-fif = { path = "modules/fif" }
llimphi-module-file-picker = { path = "modules/file-picker" }
llimphi-module-bookmarks = { path = "modules/bookmarks" }
llimphi-module-mini-map = { path = "modules/mini-map" }
llimphi-module-shuma-term = { path = "modules/shuma-term" }
llimphi-module-symbol-outline = { path = "modules/symbol-outline" }
llimphi-plugin-host = { path = "modules/plugin-host" }
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher" }
llimphi-widget-text-area = { path = "widgets/text-area" }
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core" }
llimphi-widget-text-editor = { path = "widgets/text-editor" }
llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp" }
llimphi-widget-text-input = { path = "widgets/text-input" }
llimphi-widget-tiled = { path = "widgets/tiled" }
llimphi-widget-nodegraph = { path = "widgets/nodegraph" }
llimphi-widget-tree = { path = "widgets/tree" }
llimphi-widget-navigator = { path = "widgets/navigator" }
llimphi-widget-app-header = { path = "widgets/app-header", version = "0.1.0" }
llimphi-widget-banner = { path = "widgets/banner", version = "0.1.0" }
llimphi-widget-button = { path = "widgets/button", version = "0.1.0" }
llimphi-widget-card = { path = "widgets/card", version = "0.1.0" }
llimphi-clipboard = { path = "widgets/clipboard", version = "0.1.0" }
llimphi-widget-context-menu = { path = "widgets/context-menu", version = "0.1.0" }
llimphi-widget-edit-menu = { path = "widgets/edit-menu", version = "0.1.0" }
llimphi-widget-menubar = { path = "widgets/menubar", version = "0.1.0" }
llimphi-widget-list = { path = "widgets/list", version = "0.1.0" }
llimphi-widget-grid = { path = "widgets/grid", version = "0.1.0" }
llimphi-widget-slider = { path = "widgets/slider", version = "0.1.0" }
llimphi-widget-scroll = { path = "widgets/scroll", version = "0.1.0" }
llimphi-widget-splitter = { path = "widgets/splitter", version = "0.1.0" }
llimphi-widget-stat-card = { path = "widgets/stat-card", version = "0.1.0" }
llimphi-widget-tabs = { path = "widgets/tabs", version = "0.1.0" }
llimphi-module-command-palette = { path = "modules/command-palette", version = "0.1.0" }
llimphi-module-diff-viewer = { path = "modules/diff-viewer", version = "0.1.0" }
llimphi-module-fif = { path = "modules/fif", version = "0.1.0" }
llimphi-module-file-picker = { path = "modules/file-picker", version = "0.1.0" }
llimphi-module-bookmarks = { path = "modules/bookmarks", version = "0.1.0" }
llimphi-module-mini-map = { path = "modules/mini-map", version = "0.1.0" }
llimphi-module-shuma-term = { path = "modules/shuma-term", version = "0.1.0" }
llimphi-module-symbol-outline = { path = "modules/symbol-outline", version = "0.1.0" }
llimphi-plugin-host = { path = "modules/plugin-host", version = "0.1.0" }
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher", version = "0.1.0" }
llimphi-widget-text-area = { path = "widgets/text-area", version = "0.1.0" }
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core", version = "0.1.0" }
llimphi-widget-text-editor = { path = "widgets/text-editor", version = "0.1.0" }
llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp", version = "0.1.0" }
llimphi-widget-text-input = { path = "widgets/text-input", version = "0.1.0" }
llimphi-widget-tiled = { path = "widgets/tiled", version = "0.1.0" }
llimphi-widget-nodegraph = { path = "widgets/nodegraph", version = "0.1.0" }
llimphi-widget-tree = { path = "widgets/tree", version = "0.1.0" }
llimphi-widget-navigator = { path = "widgets/navigator", version = "0.1.0" }
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
llimphi-widget-wawa-mark = { path = "widgets/wawa-mark" }
llimphi-widget-wawa-mark = { path = "widgets/wawa-mark", version = "0.1.0" }
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
# modal, empty, status-bar, shortcuts-help, splash).
llimphi-widget-tooltip = { path = "widgets/tooltip" }
llimphi-widget-spinner = { path = "widgets/spinner" }
llimphi-widget-progress = { path = "widgets/progress" }
llimphi-widget-toast = { path = "widgets/toast" }
llimphi-widget-modal = { path = "widgets/modal" }
llimphi-widget-empty = { path = "widgets/empty" }
llimphi-widget-status-bar = { path = "widgets/status-bar" }
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help" }
llimphi-widget-timeline = { path = "widgets/timeline" }
llimphi-widget-splash = { path = "widgets/splash" }
llimphi-widget-tooltip = { path = "widgets/tooltip", version = "0.1.0" }
llimphi-widget-spinner = { path = "widgets/spinner", version = "0.1.0" }
llimphi-widget-progress = { path = "widgets/progress", version = "0.1.0" }
llimphi-widget-toast = { path = "widgets/toast", version = "0.1.0" }
llimphi-widget-modal = { path = "widgets/modal", version = "0.1.0" }
llimphi-widget-empty = { path = "widgets/empty", version = "0.1.0" }
llimphi-widget-status-bar = { path = "widgets/status-bar", version = "0.1.0" }
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help", version = "0.1.0" }
llimphi-widget-timeline = { path = "widgets/timeline", version = "0.1.0" }
llimphi-widget-splash = { path = "widgets/splash", version = "0.1.0" }
# Controles de formulario y signaling (switch, segmented, breadcrumb,
# badge, avatar, skeleton, field).
llimphi-widget-switch = { path = "widgets/switch" }
llimphi-widget-segmented = { path = "widgets/segmented" }
llimphi-widget-dock-rail = { path = "widgets/dock-rail" }
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb" }
llimphi-widget-badge = { path = "widgets/badge" }
llimphi-widget-avatar = { path = "widgets/avatar" }
llimphi-widget-skeleton = { path = "widgets/skeleton" }
llimphi-widget-field = { path = "widgets/field" }
llimphi-widget-switch = { path = "widgets/switch", version = "0.1.0" }
llimphi-widget-segmented = { path = "widgets/segmented", version = "0.1.0" }
llimphi-widget-dock-rail = { path = "widgets/dock-rail", version = "0.1.0" }
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb", version = "0.1.0" }
llimphi-widget-badge = { path = "widgets/badge", version = "0.1.0" }
llimphi-widget-avatar = { path = "widgets/avatar", version = "0.1.0" }
llimphi-widget-skeleton = { path = "widgets/skeleton", version = "0.1.0" }
llimphi-widget-field = { path = "widgets/field", version = "0.1.0" }
# Firma visual transversal (gradient sutil + hairline accent).
llimphi-widget-panel = { path = "widgets/panel" }
llimphi-widget-panes = { path = "widgets/panes" }
llimphi-workspace = { path = "llimphi-workspace" }
llimphi-widget-panel = { path = "widgets/panel", version = "0.1.0" }
llimphi-widget-panes = { path = "widgets/panes", version = "0.1.0" }
llimphi-workspace = { path = "llimphi-workspace", version = "0.1.0" }
# Abstracción Selector — host (paths) + wawa (khipus).
llimphi-module-selector = { path = "modules/selector" }
llimphi-module-selector = { path = "modules/selector", version = "0.1.0" }
# === Filesystem helpers ===
directories = "5"
@@ -297,61 +302,61 @@ ttf-parser = "0.25"
# ============================================================
# Intra-workspace deps de nahual (referenciadas por workspace = true)
# ============================================================
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi" }
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi" }
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core" }
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" }
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi" }
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi" }
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi" }
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi" }
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi" }
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi" }
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi" }
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi" }
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi" }
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi" }
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core" }
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" }
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi" }
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-llimphi", version = "0.1.0" }
nahual-image-viewer-llimphi = { path = "02_ruway/nahual/nahual-image-viewer-llimphi", version = "0.1.0" }
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core", version = "0.1.0" }
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi", version = "0.1.0" }
nahual-video-viewer-llimphi = { path = "02_ruway/nahual/nahual-video-viewer-llimphi", version = "0.1.0" }
nahual-card-viewer-llimphi = { path = "02_ruway/nahual/nahual-card-viewer-llimphi", version = "0.1.0" }
nahual-audio-viewer-llimphi = { path = "02_ruway/nahual/nahual-audio-viewer-llimphi", version = "0.1.0" }
nahual-tree-viewer-llimphi = { path = "02_ruway/nahual/nahual-tree-viewer-llimphi", version = "0.1.0" }
nahual-hex-viewer-llimphi = { path = "02_ruway/nahual/nahual-hex-viewer-llimphi", version = "0.1.0" }
nahual-table-viewer-llimphi = { path = "02_ruway/nahual/nahual-table-viewer-llimphi", version = "0.1.0" }
nahual-markdown-viewer-llimphi = { path = "02_ruway/nahual/nahual-markdown-viewer-llimphi", version = "0.1.0" }
nahual-archive-viewer-llimphi = { path = "02_ruway/nahual/nahual-archive-viewer-llimphi", version = "0.1.0" }
nahual-font-viewer-llimphi = { path = "02_ruway/nahual/nahual-font-viewer-llimphi", version = "0.1.0" }
nahual-map-viewer-llimphi = { path = "02_ruway/nahual/nahual-map-viewer-llimphi", version = "0.1.0" }
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core", version = "0.1.0" }
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core", version = "0.1.0" }
nahual-file-explorer-llimphi = { path = "02_ruway/nahual/nahual-file-explorer-llimphi", version = "0.1.0" }
# ============================================================
# Intra-workspace deps de pineal (módulo de gráficos)
# ============================================================
pineal-core = { path = "00_unanchay/pineal/pineal-core" }
pineal-render = { path = "00_unanchay/pineal/pineal-render" }
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" }
pineal-stream = { path = "00_unanchay/pineal/pineal-stream" }
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" }
pineal-financial = { path = "00_unanchay/pineal/pineal-financial" }
pineal-polar = { path = "00_unanchay/pineal/pineal-polar" }
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" }
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" }
pineal-flow = { path = "00_unanchay/pineal/pineal-flow" }
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" }
pineal-export = { path = "00_unanchay/pineal/pineal-export" }
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" }
pineal-contour = { path = "00_unanchay/pineal/pineal-contour" }
pineal-bars = { path = "00_unanchay/pineal/pineal-bars" }
pineal = { path = "00_unanchay/pineal/pineal-umbrella" }
pineal-core = { path = "00_unanchay/pineal/pineal-core", version = "0.1.0" }
pineal-render = { path = "00_unanchay/pineal/pineal-render", version = "0.1.0" }
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian", version = "0.1.0" }
pineal-stream = { path = "00_unanchay/pineal/pineal-stream", version = "0.1.0" }
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh", version = "0.1.0" }
pineal-financial = { path = "00_unanchay/pineal/pineal-financial", version = "0.1.0" }
pineal-polar = { path = "00_unanchay/pineal/pineal-polar", version = "0.1.0" }
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap", version = "0.1.0" }
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap", version = "0.1.0" }
pineal-flow = { path = "00_unanchay/pineal/pineal-flow", version = "0.1.0" }
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor", version = "0.1.0" }
pineal-export = { path = "00_unanchay/pineal/pineal-export", version = "0.1.0" }
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin", version = "0.1.0" }
pineal-contour = { path = "00_unanchay/pineal/pineal-contour", version = "0.1.0" }
pineal-bars = { path = "00_unanchay/pineal/pineal-bars", version = "0.1.0" }
pineal = { path = "00_unanchay/pineal/pineal-umbrella", version = "0.1.0" }
# ============================================================
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
# ============================================================
iniy-core = { path = "01_yachay/iniy/iniy-core" }
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest" }
iniy-extract = { path = "01_yachay/iniy/iniy-extract" }
iniy-nli = { path = "01_yachay/iniy/iniy-nli" }
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm" }
iniy-graph = { path = "01_yachay/iniy/iniy-graph" }
iniy-store = { path = "01_yachay/iniy/iniy-store" }
iniy-core = { path = "01_yachay/iniy/iniy-core", version = "0.1.0" }
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest", version = "0.1.0" }
iniy-extract = { path = "01_yachay/iniy/iniy-extract", version = "0.1.0" }
iniy-nli = { path = "01_yachay/iniy/iniy-nli", version = "0.1.0" }
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm", version = "0.1.0" }
iniy-graph = { path = "01_yachay/iniy/iniy-graph", version = "0.1.0" }
iniy-store = { path = "01_yachay/iniy/iniy-store", version = "0.1.0" }
# === auto: declarados por crates internos faltantes ===
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords" }
cosmos-core = { path = "01_yachay/cosmos/cosmos-core" }
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris" }
cosmos-time = { path = "01_yachay/cosmos/cosmos-time" }
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs" }
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords", version = "0.1.0" }
cosmos-core = { path = "01_yachay/cosmos/cosmos-core", version = "0.1.0" }
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris", version = "0.1.0" }
cosmos-time = { path = "01_yachay/cosmos/cosmos-time", version = "0.1.0" }
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs", version = "0.1.0" }
# === auto: externas de eternal ===
celestial-eop-data = { version = "0.1"}
+18
View File
@@ -0,0 +1,18 @@
PARA: help@crates.io
(enviar desde el email de tu cuenta de crates.io)
SUBJECT:
Rate limit increase for publishing the llimphi workspace (~88 crates)
CUERPO:
Hi! I'm publishing a multi-crate workspace (llimphi, an MIT-licensed native
Rust UI framework) — about 88 interdependent crates that must go up together.
I've hit the "too many new crates in a short period" limit. Could you
temporarily raise my new-crate rate limit so I can publish the rest?
crates.io user: sergiovelasquezzeballos
Already published: app-bus, foreign-vox, llimphi-hal, llimphi-raster,
llimphi-layout, llimphi-text, llimphi-theme, llimphi-ui, llimphi-3d, ...
Repo: https://github.com/tawasuyu/llimphi
Thanks!
+88
View File
@@ -0,0 +1,88 @@
app-bus
foreign-vox
llimphi-3d
llimphi-hal
llimphi-raster
llimphi-theme
llimphi-layout
llimphi-text
llimphi-compositor
llimphi-ui
llimphi-widget-text-editor-core
llimphi-widget-text-editor
llimphi-clipboard
llimphi-icons
llimphi-widget-text-input
llimphi-module-bookmarks
llimphi-module-command-palette
llimphi-module-diff-viewer
llimphi-module-fif
llimphi-module-file-picker
llimphi-module-mini-map
llimphi-module-selector
llimphi-module-symbol-outline
llimphi-motion
llimphi-surface
llimphi-voxel
llimphi-widget-panel
llimphi-widget-app-header
llimphi-widget-avatar
llimphi-widget-badge
llimphi-widget-banner
llimphi-widget-breadcrumb
llimphi-widget-button
llimphi-widget-calendar
llimphi-widget-card
llimphi-widget-carousel
llimphi-widget-chip
llimphi-widget-slider
llimphi-widget-color-picker
llimphi-widget-context-menu
llimphi-widget-detail-table
llimphi-widget-dock-rail
llimphi-widget-edit-menu
llimphi-widget-empty
llimphi-widget-fab
llimphi-widget-field
llimphi-widget-fitted-box
llimphi-widget-gauge
llimphi-widget-grid
llimphi-widget-hero
llimphi-widget-list
llimphi-widget-menubar
llimphi-widget-modal
llimphi-widget-nodegraph
llimphi-widget-tree
llimphi-widget-navigator
llimphi-widget-panes
llimphi-widget-progress
llimphi-widget-range-slider
llimphi-widget-rating
llimphi-widget-scaffold
llimphi-widget-scroll
llimphi-widget-segmented
llimphi-widget-select
llimphi-widget-shortcuts-help
llimphi-widget-skeleton
llimphi-widget-spinner
llimphi-widget-splash
llimphi-widget-splitter
llimphi-widget-stat-card
llimphi-widget-status-bar
llimphi-widget-switch
llimphi-widget-table
llimphi-widget-tabs
llimphi-widget-terminal
llimphi-widget-text-area
llimphi-widget-text-editor-lsp
llimphi-widget-theme-switcher
llimphi-widget-tiled
llimphi-widget-timeline
llimphi-widget-toast
llimphi-widget-toolbar
llimphi-widget-tooltip
llimphi-widget-transport
llimphi-widget-waveform
llimphi-widget-wawa-mark
llimphi-widget-wrap
llimphi-workspace
+25 -7
View File
@@ -1,15 +1,33 @@
# llimphi
> Native UI framework: HAL · raster · layout · text · theme · ui — plus widgets and modules.
> Native UI framework — 2D **and** 3D: HAL · raster · layout · text · theme · ui · 3D voxel engine — plus widgets and modules.
`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` + `wgpu` + `taffy` + `parley`, with `Dark/Light/Aurora/Sunset` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
`llimphi` is a sovereign, retained-mode UI framework with an Elm-style loop (`input → update → view → layout → raster → present`). Declarative pipeline over `vello` 0.7 + `wgpu` 27 + `taffy` + `parley` 0.6, with `Dark/Light/Aurora/Sunset/Tawa` themes and a multi-platform HAL (Wayland · X11 · Win32 · Android · Wawa bare-metal). It powers a full Rust application suite; this repository is the framework extracted to stand on its own.
<p align="center">
<img src="docs/showreel.gif" alt="Llimphi showreel — real widgets (switch, slider, progress, segmented control, buttons, radial) animating live, then reflowing across layouts" width="900">
<br>
<sub>real widgets in motion — rendered headless, frame-by-frame, fully deterministic</sub>
</p>
<p align="center">
<img src="docs/counter.gif" alt="counter example — the full Elm loop in ~124 LOC" width="480">
<br>
<sub><code>cargo run -p llimphi-ui --example counter</code></sub>
<sub>…and the entire Elm loop in ~124 LOC — <code>cargo run -p llimphi-ui --example counter</code></sub>
</p>
## Not just 2D — a 3D voxel engine
<p align="center">
<img src="docs/llimphi_voxel.gif" alt="Flying over an endless procedural voxel world — rendered by llimphi-3d" width="720">
<br>
<sub>flying over an endless voxel world — rendered headless by <code>llimphi-3d</code>, frame-by-frame</sub>
</p>
`llimphi-3d` is a **3D engine** on the same `wgpu`: it composes voxels (a GPU ray-march) and triangle meshes in one shared depth pass, with a keyframed cinema camera. It mounts into the ordinary 2D `View` tree through the GPU paint node (`set_viewport` + scissor), so a 3D viewport can live in a panel next to regular widgets — no second window, same Elm loop.
`llimphi-voxel` adds the *content* layer on top: procedural world-gen (`WorldRecipe`), articulated characters (age + animation clips) and a scripted scene **director**. The GIF above is one such world. (A full voxel **world studio** — edit worlds, cast characters, direct filmed scenes, export to video — is built on these in the wider project.)
**Usage manual:** [MANUAL.md](MANUAL.md) — full reference (Elm loop, `View<Msg>` DSL, the ~44 widgets and 10 modules, GPU path, gotchas) for humans and AI. Design rationale and roadmap: [SDD.md](SDD.md).
Philosophy: **widgets aren't designed against mockups; they're designed with what `vello` and `taffy` can do.**
@@ -17,7 +35,7 @@ Philosophy: **widgets aren't designed against mockups; they're designed with wha
## Quick start
```sh
git clone https://gitea.gioser.net/sergio/llimphi.git
git clone https://git.tawasuyu.net/tawasuyu/llimphi.git
cd llimphi
cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on screen
```
@@ -26,10 +44,10 @@ cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on scr
```toml
[dependencies]
llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
# widgets are one crate each — pull only what you use:
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
```
## Compatibility
+2 -1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Demo Android Tier 1: pinta la pantalla con LEAD_GRAY usando llimphi-hal sobre Android NativeActivity."
# Android NativeActivity carga la lib nativa como .so via dlopen; el
@@ -14,7 +15,7 @@ description = "Demo Android Tier 1: pinta la pantalla con LEAD_GRAY usando llimp
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-hal = { path = "../../llimphi-hal", version = "0.1.0" }
# Activamos el feature de NativeActivity en winit para que linkee con la
# clase NativeActivity del NDK y reciba eventos de surface/input desde la
# Activity Java/Kotlin generada por android-activity.
+3 -2
View File
@@ -5,14 +5,15 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Tier 1.5 Android: vello + llimphi-raster pintando una chacana animada como smoke test del stack completo."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
llimphi-hal = { path = "../../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../../llimphi-raster", version = "0.1.0" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
+4 -3
View File
@@ -5,15 +5,16 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Tier 1.75 Android: parley + vello + llimphi-text rasterizando texto multi-script con fallback CJK/Arabic via fontique."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
llimphi-text = { path = "../../llimphi-text" }
llimphi-hal = { path = "../../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../../llimphi-raster", version = "0.1.0" }
llimphi-text = { path = "../../llimphi-text", version = "0.1.0" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "llimphi-3d"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-3d — pase 3D base de Llimphi sobre wgpu: cámara view/proj (glam), depth buffer propio y un pipeline que compone su render dentro del `View` por la misma firma que `gpu_paint_with`. M0 del motor 3D general (ver 01_yachay/dominium/MOTOR-VOXEL.md §11). No mete un segundo motor: va sobre el mismo wgpu que ya usa Llimphi."
[dependencies]
# Sólo los tipos GPU (Device/Queue/Encoder/View/Texture) — mismo wgpu que el
# resto de Llimphi, sin windowing. No agrega un segundo stack gráfico.
wgpu = { workspace = true }
glam = { workspace = true }
[dev-dependencies]
# Volcado headless del render 3D a PNG (llvmpipe en sandbox) para VER el cubo
# sin levantar ventana — mismo patrón que gpu_primitivos_demo.
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
png = { workspace = true }
pollster = { workspace = true }
# Demo interactivo: bucle Elm + ventana + mouse (orbita/zoom) sobre gpu_paint_with.
llimphi-ui = { path = "../llimphi-ui" }
+145
View File
@@ -0,0 +1,145 @@
//! Demo headless de M0: un cubo 3D con depth test, compuesto sobre un fondo
//! vello — el mismo orden que aplica el runtime de Llimphi para
//! `View::gpu_paint_with` (`[vello base] → [GPU 3D]`).
//!
//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm` (misma
//! mecánica que el frame real) y vuelca a PNG.
//!
//! `cargo run -p llimphi-3d --example cubo_demo --release -- [out.png] [yaw_deg]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, Renderer3d};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "cubo_demo.png".to_string());
let yaw_deg: f32 = std::env::args()
.nth(2)
.and_then(|s| s.parse().ok())
.unwrap_or(35.0);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut r3d = Renderer3d::new(&hal.device, FMT);
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// (1) Fondo vello: limpia la intermedia a un azul oscuro (render_to_view
// escribe todos los pixels con base_color).
let base = vello::Scene::new();
renderer
.render_to_view(
&hal,
&base,
&inter_view,
W,
H,
Color::from_rgba8(18, 22, 32, 255),
)
.expect("render base");
// (2) Pase 3D: cubo orbitado, depth test propio, LoadOp::Load sobre el fondo.
let camera = Camera3d::orbit(
Vec3::ZERO,
yaw_deg.to_radians(),
25_f32.to_radians(),
4.0,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("3d-pass"),
});
r3d.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&hal, &inter, &out);
eprintln!("cubo_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°)");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+177
View File
@@ -0,0 +1,177 @@
//! Demo de **luces puntuales coloreadas** en el ray-march voxel: antorchas/
//! lámparas que tiñen los voxels cercanos con caída por distancia. Útil para
//! mood cinematográfico (la rama machinima) y para juegos (antorchas).
//!
//! Rinde tres PNG para el contraste:
//! - `/tmp/lights_off.png` — sólo sol + ambiente (la escena base).
//! - `/tmp/lights_noshadow.png` — + una luz cálida y una fría (MVP plano, sin sombra).
//! - `/tmp/lights_on.png` — las mismas luces **con sombra dura** (default):
//! los pilares/esfera bloquean la luz puntual y proyectan su sombra en el piso.
//!
//! La diferencia `noshadow` → `on` aísla la sombra de las puntuales (el feature
//! nuevo): se ven los conos oscuros detrás de cada obstáculo respecto de la luz.
//!
//! `cargo run -p llimphi-3d --example lights_demo --release`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, PointLight, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim = [96u32, 96, 96];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = VoxelGrid::demo_scene(dim);
// Losa flotante en una zona despejada del piso: con una luz puntual justo
// ENCIMA, proyecta una sombra rectangular nítida en el piso de abajo — la
// prueba más legible de que las puntuales ya ocluyen.
for z in 58..74 {
for x in 16..34 {
grid.set(x, 20, z, [180, 180, 190]);
grid.set(x, 21, z, [180, 180, 190]);
}
}
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
// Sol bajo y tenue para que las luces puntuales destaquen.
vr.sun_dir = [0.3, 0.35, 0.5];
let camera = Camera3d::orbit(
Vec3::new(0.0, 4.0, 0.0),
40_f32.to_radians(),
24_f32.to_radians(),
dim[0] as f32 * 1.6,
);
// Toma 1: sin luces puntuales.
let off = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&off, "/tmp/lights_off.png");
// Toma 2: una luz cálida (naranja, junto a un pilar) y una fría (cian, junto a
// la esfera). Color > 1.0 = brillo intenso; `range` en voxels.
// Cerca del piso (gris neutro = lee bien el color) y de un pilar, intensas.
vr.lights = vec![
// Cálida JUSTO sobre la losa flotante → sombra rectangular nítida abajo.
PointLight { pos: [25.0, 40.0, 66.0], color: [3.6, 1.7, 0.7], range: 70.0, radius: 0.0 },
// Fría junto a la esfera, a media altura → la esfera corta su luz.
PointLight { pos: [70.0, 30.0, 60.0], color: [0.6, 1.7, 3.6], range: 70.0, radius: 0.0 },
];
// 2a: MVP plano (sin sombra) — para aislar el feature nuevo.
vr.point_shadows = false;
let noshadow = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&noshadow, "/tmp/lights_noshadow.png");
// 2b: con sombra DURA (radius = 0) — los obstáculos cortan la luz de golpe.
vr.point_shadows = true;
let on = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&on, "/tmp/lights_on.png");
// 2c: con sombra BLANDA (radius > 0) — la luz pasa a fuente de área: el borde
// de la sombra se abre en penumbra (más cuanto más lejos el ocluyente).
for l in vr.lights.iter_mut() {
l.radius = 7.0;
}
let soft = render(&hal, &mut renderer, &mut vr, &camera);
write_png(&soft, "/tmp/lights_soft.png");
eprintln!(
"escritos /tmp/lights_off.png (sin luces), /tmp/lights_noshadow.png (sin \
sombra), /tmp/lights_on.png (sombra dura) y /tmp/lights_soft.png (penumbra)"
);
}
fn render(hal: &Hal, renderer: &mut Renderer, vr: &mut VoxelRenderer, camera: &Camera3d) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("lights") });
vr.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+133
View File
@@ -0,0 +1,133 @@
//! Demo headless del **motor 3D general**: voxels + mallas de triángulos en
//! UNA escena con depth compartido ([`Scene3d`]). Prueba de oclusión mutua: un
//! cubo-malla y la esfera voxel se **interpenetran** — la esfera asoma por las
//! caras del cubo. Si el depth NO se compartiera, uno taparía al otro entero;
//! con `Scene3d` se ve una intersección limpia (cada píxel = lo más cercano,
//! sea voxel o triángulo).
//!
//! `cargo run -p llimphi-3d --example scene_mixed --release -- [out.png] [yaw_deg]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::{Mat4, Vec3};
use llimphi_3d::{Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 800;
const H: u32 = 600;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const D: u32 = 80;
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "/tmp/scene_mixed.png".to_string());
let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(40.0);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Voxel: esfera + piso + pilares, centro de la esfera en mundo ≈ (0, 4, 0).
let grid = VoxelGrid::demo_scene([D, D, D]);
let voxel = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
// Malla: cubo coloreado escalado a ~0.45·D, centrado en la esfera → la
// esfera (r≈0.3·D) lo atraviesa y asoma por las caras.
let mut mesh = Renderer3d::new(&hal.device, FMT);
mesh.set_model(Mat4::from_translation(Vec3::new(0.0, 4.0, 0.0)) * Mat4::from_scale(Vec3::splat(0.45 * D as f32)));
let mut scene = Scene3d::new();
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// (1) Fondo vello oscuro.
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(16, 18, 24, 255))
.expect("render base");
// (2) Escena 3D mixta (voxels + malla, depth compartido).
let camera = Camera3d::orbit(
Vec3::new(0.0, 4.0, 0.0),
yaw_deg.to_radians(),
20_f32.to_radians(),
D as f32 * 1.7,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("scene") });
scene.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera, Some(&voxel), &[&mesh]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&hal, &inter, &out);
eprintln!("scene_mixed: escrito {out} ({W}x{H}, yaw={yaw_deg}°) — voxel ∩ malla con depth compartido");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+144
View File
@@ -0,0 +1,144 @@
//! Demo headless de M1: una grilla de voxels densa renderizada por
//! **ray-marching DDA** (sin meshear), compuesta sobre un fondo vello — el
//! mismo orden que el runtime aplica a `View::gpu_paint_with`.
//!
//! `cargo run -p llimphi-3d --example voxel_demo --release -- [out.png] [yaw_deg] [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "voxel_demo.png".to_string());
let yaw_deg: f32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(35.0);
let dim: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(64);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let (used, total) = vr.brick_usage();
let (pool, dense) = vr.memory_bytes();
eprintln!(
"brick pool: {used}/{total} bricks ocupados ({:.1}%) — pool {} KiB vs denso {} KiB ({:.1}× menos)",
used as f32 / total as f32 * 100.0,
pool / 1024,
dense / 1024,
dense as f32 / pool.max(1) as f32,
);
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// (1) Fondo vello.
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
.expect("render base");
// (2) Pase voxel ray-march. Cámara orbitando el centro de la grilla (origen).
let d = dim as f32;
let camera = Camera3d::orbit(
Vec3::ZERO,
yaw_deg.to_radians(),
30_f32.to_radians(),
d * 1.7,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("voxel-pass"),
});
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(&hal, &inter, &out);
eprintln!("voxel_demo: escrito {out} ({W}x{H}, yaw={yaw_deg}°, dim={dim}³)");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+430
View File
@@ -0,0 +1,430 @@
//! Demo de M5 — **dimensiones / mundos paralelos**. Tres mundos voxel
//! independientes (Jardín, Inframundo, Cristal), cada uno con su grid, su cielo,
//! su sol y sus entidades. La cámara ve la dimensión activa; "viajar" = cambiar
//! cuál se renderiza.
//!
//! - **Arrastrar**: orbita. **Rueda**: zoom.
//! - **Tab / N**: siguiente dimensión. **P**: anterior. **1/2/3**: ir a una.
//! - Las entidades de la dimensión activa orbitan solas.
//!
//! `cargo run -p llimphi-3d --example voxel_dimensiones --release`
//! `… --release -- --shot` → vuelca un PNG por dimensión a /tmp/m5_*.png
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Dimension, Entity3d, Multiverse, VoxelGrid};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::{
mount, paint_gpu, App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View,
WheelDelta,
};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const DIM: u32 = 64;
// ── Construcción de los tres mundos ─────────────────────────────────────────
fn world_jardin(d: u32) -> Dimension {
Dimension::new("Jardín", VoxelGrid::demo_scene([d, d, d]))
.with_sky([20, 30, 26])
.with_sun([0.5, 1.0, 0.35])
.with_atmosphere(Atmosphere {
sky_zenith: [70, 130, 90],
sky_horizon: [196, 222, 188],
fog_density: 0.22 / d as f32,
})
.with_entities(orbit_entities(
d,
&[[235, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]],
))
}
fn world_inframundo(d: u32) -> Dimension {
let mut g = VoxelGrid::new([d, d, d]);
// Piso de lava (damero rojo/naranja).
for z in 0..d {
for x in 0..d {
let chk = ((x / 4 + z / 4) % 2) == 0;
let c = if chk { [150, 45, 22] } else { [185, 70, 28] };
for y in 0..2 {
g.set(x, y, z, c);
}
}
}
// Estalagmitas (columnas que se afinan hacia arriba).
for &(sx, sz, h) in &[(d / 4, d / 4, d * 2 / 5), (d * 3 / 4, d / 3, d / 2), (d / 2, d * 3 / 4, d * 3 / 5), (d / 5, d * 4 / 5, d * 3 / 10)] {
for y in 2..(2 + h).min(d) {
let t = (y - 2) as f32 / h as f32;
let r = ((1.0 - t) * 3.0).round() as i32;
for dx in -r..=r {
for dz in -r..=r {
let x = sx as i32 + dx;
let z = sz as i32 + dz;
if x >= 0 && z >= 0 {
let shade = 60 + (t * 70.0) as u8;
g.set(x as u32, y, z as u32, [120 + shade / 2, 50, 30]);
}
}
}
}
}
Dimension::new("Inframundo", g)
.with_sky([28, 8, 8])
.with_sun([0.35, 0.7, 0.5])
.with_atmosphere(Atmosphere {
sky_zenith: [60, 12, 10],
sky_horizon: [180, 70, 24],
fog_density: 0.4 / d as f32,
})
.with_entities(orbit_entities(d, &[[255, 140, 30], [255, 90, 20], [255, 200, 60]]))
}
fn world_cristal(d: u32) -> Dimension {
let mut g = VoxelGrid::new([d, d, d]);
// Cristales octaédricos flotando en el vacío (sin piso).
let crystals: [(u32, u32, u32, [u8; 3]); 6] = [
(d / 2, d * 3 / 4, d / 2, [120, 220, 255]),
(d / 3, d / 2, d * 2 / 3, [200, 160, 255]),
(d * 2 / 3, d * 3 / 5, d / 3, [160, 255, 220]),
(d / 4, d * 2 / 3, d / 4, [255, 240, 200]),
(d * 3 / 4, d / 2, d * 3 / 4, [180, 200, 255]),
(d / 2, d / 3, d * 4 / 5, [220, 180, 255]),
];
for (cx, cy, cz, col) in crystals {
let r = 4i32;
for dx in -r..=r {
for dy in -r..=r {
for dz in -r..=r {
if dx.abs() + dy.abs() + dz.abs() <= r {
let x = cx as i32 + dx;
let y = cy as i32 + dy;
let z = cz as i32 + dz;
if x >= 0 && y >= 0 && z >= 0 {
g.set(x as u32, y as u32, z as u32, col);
}
}
}
}
}
}
Dimension::new("Cristal", g)
.with_sky([10, 10, 22])
.with_sun([0.4, 0.8, 0.45])
.with_atmosphere(Atmosphere {
sky_zenith: [24, 18, 60],
sky_horizon: [120, 90, 200],
fog_density: 0.28 / d as f32,
})
.with_entities(orbit_entities(d, &[[120, 240, 255], [220, 180, 255]]))
}
/// Entidades distribuidas en una órbita ecuatorial (se animan girando).
fn orbit_entities(d: u32, colors: &[[u8; 3]]) -> Vec<Entity3d> {
let n = colors.len();
let df = d as f32;
(0..n)
.map(|k| {
let a = k as f32 / n as f32 * std::f32::consts::TAU;
Entity3d {
pos: [df * 0.5 + a.cos() * df * 0.42, df * 0.45, df * 0.5 + a.sin() * df * 0.42],
half: [df * 0.05, df * 0.05, df * 0.05],
color: colors[k],
}
})
.collect()
}
fn build_multiverse(d: u32) -> Multiverse {
Multiverse::new(vec![world_jardin(d), world_inframundo(d), world_cristal(d)])
}
fn rotate_y(e: &mut Entity3d, center: [f32; 3], ang: f32) {
let dx = e.pos[0] - center[0];
let dz = e.pos[2] - center[2];
let (s, c) = ang.sin_cos();
e.pos[0] = center[0] + dx * c - dz * s;
e.pos[2] = center[2] + dx * s + dz * c;
}
// ── App interactiva ─────────────────────────────────────────────────────────
#[derive(Clone)]
enum Msg {
Orbit(f32, f32),
Zoom(f32),
Tick,
Next,
Prev,
Go(usize),
}
struct Model {
yaw: f32,
pitch: f32,
dist: f32,
active: usize,
names: Vec<String>,
skies: Vec<[u8; 3]>,
mv: Arc<Mutex<Multiverse>>,
}
struct DimApp;
impl App for DimApp {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi-3d · dimensiones"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(handle: &Handle<Msg>) -> Model {
handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
let mv = build_multiverse(DIM);
Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: DIM as f32 * 1.7,
active: mv.active(),
names: mv.names(),
skies: mv.skies(),
mv: Arc::new(Mutex::new(mv)),
}
}
fn window_title(model: &Model) -> Option<String> {
Some(format!(
"llimphi-3d · {} ({}/{}) — Tab=siguiente",
model.names[model.active],
model.active + 1,
model.names.len()
))
}
fn on_key(_model: &Model, ev: &KeyEvent) -> Option<Msg> {
if !matches!(ev.state, KeyState::Pressed) {
return None;
}
match &ev.key {
Key::Named(NamedKey::Tab) => Some(Msg::Next),
Key::Character(c) => match c.as_str() {
"n" | "N" => Some(Msg::Next),
"p" | "P" => Some(Msg::Prev),
"1" => Some(Msg::Go(0)),
"2" => Some(Msg::Go(1)),
"3" => Some(Msg::Go(2)),
_ => None,
},
_ => None,
}
}
fn on_wheel(_m: &Model, delta: WheelDelta, _c: (f32, f32), _mods: Modifiers) -> Option<Msg> {
Some(Msg::Zoom(delta.y))
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
match msg {
Msg::Orbit(dx, dy) => {
model.yaw -= dx * 0.008;
model.pitch += dy * 0.008;
}
Msg::Zoom(dy) => {
let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
model.dist = (model.dist * f).clamp(DIM as f32 * 0.5, DIM as f32 * 4.0);
}
Msg::Tick => {
// Anima las entidades de la dimensión activa.
let mut mv = model.mv.lock().unwrap();
let c = [DIM as f32 * 0.5, DIM as f32 * 0.45, DIM as f32 * 0.5];
for e in &mut mv.active_dim_mut().entities {
rotate_y(e, c, 0.02);
}
}
Msg::Next => {
model.mv.lock().unwrap().next();
model.active = model.mv.lock().unwrap().active();
}
Msg::Prev => {
model.mv.lock().unwrap().prev();
model.active = model.mv.lock().unwrap().active();
}
Msg::Go(i) => {
let mut mv = model.mv.lock().unwrap();
mv.switch(i);
model.active = mv.active();
}
}
model
}
fn view(model: &Model) -> View<Msg> {
let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
let mv = model.mv.clone();
let canvas = View::new(fill())
.gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
mv.lock().unwrap().render(device, queue, encoder, target, vp, &camera);
})
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Orbit(dx, dy)),
DragPhase::End => None,
});
View::new(fill()).children(vec![canvas])
}
}
fn fill() -> Style {
Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--shot") {
shot();
return;
}
llimphi_ui::run::<DimApp>();
}
/// Vuelca un PNG por dimensión por el compositor real (mount → paint_gpu).
fn shot() {
const W: u32 = 1000;
const H: u32 = 720;
let mv = Arc::new(Mutex::new(build_multiverse(DIM)));
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), DIM as f32 * 1.7);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let count = mv.lock().unwrap().count();
for i in 0..count {
let (name, sky) = {
let mut g = mv.lock().unwrap();
g.switch(i);
(g.active_name().to_string(), g.active_dim().sky)
};
let model_mv = mv.clone();
let cam = camera;
let canvas: View<Msg> = View::new(fill()).gpu_paint_with(
move |device, queue, encoder, target, _rect, vp| {
model_mv.lock().unwrap().render(device, queue, encoder, target, vp, &cam);
},
);
let view: View<Msg> = View::new(fill()).children(vec![canvas]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, view);
let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(sky[0], sky[1], sky[2], 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
assert!(any, "gpu_painter no corrió");
let out = format!("/tmp/m5_{i}_{}.png", name.to_lowercase());
write_png(&hal, &inter, W, H, &out);
eprintln!("dimensión {i} = {name}{out}");
}
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
use std::fs::File;
use std::io::BufWriter;
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut penc = png::Encoder::new(BufWriter::new(file), w, h);
penc.set_color(png::ColorType::Rgba);
penc.set_depth(png::BitDepth::Eight);
let mut wr = penc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
+173
View File
@@ -0,0 +1,173 @@
//! Demo headless de M3: **mutación incremental** de la grilla en GPU.
//!
//! Renderiza la escena, luego (a) agrega un bloque flotante en aire antes vacío
//! y (b) carva un mordisco en la esfera — cada edición sube SÓLO su sub-caja vía
//! `VoxelRenderer::sync` (no re-sube el grid ni remesha). Vuelca un PNG "antes"
//! y uno "después", e imprime los bytes subidos vs el grid completo.
//!
//! El bloque flotante es el test clave del coarse map: si `sync` no actualizara
//! la ocupación gruesa, el brick seguiría marcado vacío y el bloque sería
//! invisible (lo saltaría el DDA grueso).
//!
//! `cargo run -p llimphi-3d --example voxel_dynamic_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), dim as f32 * 1.7);
// ── Frame ANTES ──────────────────────────────────────────────────────
render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_antes.png");
let full = dim * dim * dim * 4;
// ── Edición (a): bloque flotante en aire vacío (arriba, a un costado) ──
let bx = dim / 6;
let by = dim * 4 / 5;
let bz = dim / 6;
for z in 0..8 {
for y in 0..8 {
for x in 0..8 {
grid.set(bx + x, by + y, bz + z, [240, 150, 40]);
}
}
}
let n_a = vr.sync(&hal.queue, &mut grid);
eprintln!("edición (a) bloque flotante: subidos {n_a} B ({:.3}% del grid completo)", n_a as f32 / full as f32 * 100.0);
// ── Edición (b): mordisco cúbico en lo alto de la esfera ──────────────
let cx = dim / 2;
let cy = dim * 7 / 10;
let cz = dim / 2;
for z in 0..(dim / 4) {
for y in 0..(dim / 4) {
for x in 0..(dim / 4) {
grid.clear(cx + x, cy + y, cz - dim / 8 + z);
}
}
}
let n_b = vr.sync(&hal.queue, &mut grid);
eprintln!("edición (b) mordisco esfera: subidos {n_b} B ({:.3}% del grid completo)", n_b as f32 / full as f32 * 100.0);
// ── Frame DESPUÉS ────────────────────────────────────────────────────
render_frame(&hal, &mut renderer, &mut vr, &camera, "/tmp/m3_despues.png");
eprintln!("voxel_dynamic_demo: /tmp/m3_antes.png + /tmp/m3_despues.png (dim={dim}³)");
}
fn render_frame(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
camera: &Camera3d,
out: &str,
) {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(hal, &inter, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+160
View File
@@ -0,0 +1,160 @@
//! Demo headless de M4: **entidades** (agentes) ray-marcheadas como cajas
//! analíticas en el mismo pase que los voxels. Se mueven con posición sub-voxel
//! (suave, no snapeada a la grilla), ocluyen y son ocluidas por el mundo voxel
//! (esfera/pilares) por comparación de `t`, y proyectan sombras sobre el piso.
//!
//! Genera 3 frames con las entidades en distintas posiciones de una órbita para
//! evidenciar el movimiento + oclusión + sombras.
//!
//! `cargo run -p llimphi-3d --example voxel_entities_demo --release -- [dim]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, Entity3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
let d = dim as f32;
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = VoxelGrid::demo_scene([dim, dim, dim]);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let camera = Camera3d::orbit(Vec3::ZERO, 35_f32.to_radians(), 30_f32.to_radians(), d * 1.7);
let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
for (fi, phase) in [0.0_f32, 0.9, 1.8].iter().enumerate() {
// 4 entidades orbitando el centro a media altura, con bobeo vertical.
// Una pasa por delante de la esfera y otra por detrás → oclusión mutua.
vr.entities.clear();
for k in 0..4 {
let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
let radius = d * 0.42;
let pos = [
d * 0.5 + a.cos() * radius,
d * (0.45 + 0.12 * (a * 1.3).sin()),
d * 0.5 + a.sin() * radius,
];
vr.entities.push(Entity3d {
pos,
half: [d * 0.05, d * 0.05, d * 0.05],
color: colors[k],
});
}
let out = format!("/tmp/m4_frame{fi}.png");
render_frame(&hal, &mut renderer, &mut vr, &camera, &out);
eprintln!("frame {fi}: {} entidades → {out}", vr.entities.len());
}
eprintln!("voxel_entities_demo: /tmp/m4_frame0..2.png (dim={dim}³)");
}
fn render_frame(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
camera: &Camera3d,
out: &str,
) {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let base = vello::Scene::new();
renderer
.render_to_view(hal, &base, &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
.expect("render base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(hal, &inter, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+296
View File
@@ -0,0 +1,296 @@
//! Demo **interactivo** del motor 3D: el mundo voxel (M1-M4) dentro de un
//! `View` vivo de Llimphi, manejado con el mouse.
//!
//! - **Arrastrar** (botón izquierdo): orbita la cámara (yaw/pitch).
//! - **Rueda**: zoom (acerca/aleja).
//! - Las 4 entidades de colores orbitan solas (animación por `spawn_periodic`).
//!
//! Es el cableado real a una app: el `VoxelRenderer` se compone dentro del
//! árbol `View<Msg>` por `View::gpu_paint_with` (corre DESPUÉS de la pasada
//! vello, con `LoadOp::Load`). El renderer se crea perezosamente en la primera
//! llamada GPU (ahí recién hay `Device`/`Queue`) y se cachea en el Model tras
//! un `Arc<Mutex<…>>`.
//!
//! `cargo run -p llimphi-3d --example voxel_interactivo --release -- [dim]`
use std::sync::{Arc, Mutex};
use std::time::Duration;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Camera3d, Entity3d, VoxelGrid, VoxelRenderer};
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::{mount, paint_gpu, App, DragPhase, Handle, Modifiers, View, WheelDelta};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
#[derive(Clone)]
enum Msg {
Orbit(f32, f32),
Zoom(f32),
Tick,
}
struct Model {
yaw: f32,
pitch: f32,
dist: f32,
phase: f32,
dim: u32,
grid: Arc<VoxelGrid>,
/// Renderer voxel, creado en la 1ª pintada GPU (necesita el Device).
engine: Arc<Mutex<Option<VoxelRenderer>>>,
}
fn entities_at(phase: f32, dim: u32) -> Vec<Entity3d> {
let d = dim as f32;
let colors = [[235u8, 70, 70], [70, 220, 110], [90, 130, 250], [240, 200, 60]];
(0..4)
.map(|k| {
let a = phase + k as f32 * std::f32::consts::FRAC_PI_2;
let radius = d * 0.42;
Entity3d {
pos: [
d * 0.5 + a.cos() * radius,
d * (0.45 + 0.12 * (a * 1.3).sin()),
d * 0.5 + a.sin() * radius,
],
half: [d * 0.05, d * 0.05, d * 0.05],
color: colors[k],
}
})
.collect()
}
struct VoxelApp;
impl App for VoxelApp {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi-3d · motor voxel interactivo"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(handle: &Handle<Msg>) -> Model {
let dim: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(64);
// Anima las entidades a ~30 fps.
handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: dim as f32 * 1.7,
phase: 0.0,
dim,
grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
engine: Arc::new(Mutex::new(None)),
}
}
fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
match msg {
Msg::Orbit(dx, dy) => {
model.yaw -= dx * 0.008;
model.pitch += dy * 0.008;
}
Msg::Zoom(dy) => {
// Rueda hacia adelante = acercar (reduce la distancia). El signo
// va invertido respecto del delta crudo para que sea natural.
let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
let d = model.dim as f32;
model.dist = (model.dist * f).clamp(d * 0.5, d * 4.0);
}
Msg::Tick => {
model.phase += 0.035;
}
}
model
}
fn on_wheel(
_model: &Model,
delta: WheelDelta,
_cursor: (f32, f32),
_mods: Modifiers,
) -> Option<Msg> {
Some(Msg::Zoom(delta.y))
}
fn view(model: &Model) -> View<Msg> {
let camera = Camera3d::orbit(Vec3::ZERO, model.yaw, model.pitch, model.dist);
let entities = entities_at(model.phase, model.dim);
let engine = model.engine.clone();
let grid = model.grid.clone();
let canvas = View::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
})
.gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
let mut guard = engine.lock().unwrap();
let er = guard.get_or_insert_with(|| VoxelRenderer::new(device, queue, FMT, &grid));
er.entities = entities.clone();
er.render(device, queue, encoder, target, vp, &camera);
})
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Orbit(dx, dy)),
DragPhase::End => None,
});
View::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
..Default::default()
})
.children(vec![canvas])
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
// Modo verificación headless: monta el MISMO View por el compositor real
// (mount → compute → paint_gpu) y vuelca un PNG, sin abrir ventana.
if let Some(i) = args.iter().position(|a| a == "--shot") {
let out = args
.get(i + 1)
.cloned()
.unwrap_or_else(|| "/tmp/voxel_interactivo.png".to_string());
shot(&out);
return;
}
llimphi_ui::run::<VoxelApp>();
}
/// Render headless del árbol `View` de la app a través del compositor real.
fn shot(out: &str) {
const W: u32 = 1000;
const H: u32 = 720;
let dim = 64u32;
let model = Model {
yaw: 35_f32.to_radians(),
pitch: 30_f32.to_radians(),
dist: dim as f32 * 1.7,
phase: 0.6,
dim,
grid: Arc::new(VoxelGrid::demo_scene([dim, dim, dim])),
engine: Arc::new(Mutex::new(None)),
};
// Árbol real de la app → mount + layout (igual que el runtime por frame).
let view = VoxelApp::view(&model);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, view);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// Pasada vello base (fondo) — igual que el frame real.
renderer
.render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(18, 22, 32, 255))
.expect("base");
// Pasada GPU directo: dispara los gpu_painter del árbol (nuestro voxel).
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
assert!(any, "ningún gpu_painter corrió — el cableado no llegó al compositor");
write_png(&hal, &inter, W, H, out);
eprintln!("voxel_interactivo --shot: {out} ({W}x{H}) — gpu_painter del View ejecutado por el compositor");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
use std::fs::File;
use std::io::BufWriter;
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut penc = png::Encoder::new(BufWriter::new(file), w, h);
penc.set_color(png::ColorType::Rgba);
penc.set_depth(png::BitDepth::Eight);
let mut wr = penc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
+84
View File
@@ -0,0 +1,84 @@
//! Cámara 3D — produce la matriz `view_proj` que el shader aplica a cada
//! vértice. Convención de mano derecha y profundidad `0..1` (la de wgpu/
//! Vulkan/Metal/DX12, **no** la `-1..1` de OpenGL).
use glam::{Mat4, Vec3};
/// Cámara en perspectiva. `eye` mira a `target` con `up` como vertical.
#[derive(Debug, Clone, Copy)]
pub struct Camera3d {
/// Posición del ojo en mundo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Vector "arriba" (normalmente `Vec3::Y`).
pub up: Vec3,
/// Campo de visión vertical, en radianes.
pub fovy_rad: f32,
/// Plano cercano (`> 0`).
pub znear: f32,
/// Plano lejano.
pub zfar: f32,
}
impl Default for Camera3d {
fn default() -> Self {
Self {
eye: Vec3::new(2.5, 2.0, 3.5),
target: Vec3::ZERO,
up: Vec3::Y,
fovy_rad: 60_f32.to_radians(),
znear: 0.1,
// Generoso: cubre mundos voxel de cientos de unidades. Importa desde
// que el pase de voxels escribe profundidad (`Scene3d`): un hit más
// allá de `zfar` se clamparía a 1.0 y fallaría el depth test. Float32
// de depth mantiene precisión de sobra en este rango para oclusión.
zfar: 5000.0,
}
}
}
impl Camera3d {
/// Cámara orbitando `target` a `dist`, con `yaw`/`pitch` en radianes.
/// `yaw` gira alrededor del eje Y; `pitch` sube/baja (clamp suave para no
/// cruzar los polos y degenerar el `up`).
pub fn orbit(target: Vec3, yaw: f32, pitch: f32, dist: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let offset = Vec3::new(cp * sy, sp, cp * cy) * dist;
Self {
eye: target + offset,
target,
..Self::default()
}
}
/// Cámara **libre / primera persona**: parada en `eye`, mirando según
/// `yaw` (giro alrededor de Y) y `pitch` (cabeceo, clamped para no cruzar el
/// cenit). Complementa a [`orbit`](Self::orbit): `orbit` mira un punto desde
/// afuera (vista de paisaje), `fly` te pone *adentro* del mundo (vuelo / FPS).
/// `yaw=0` mira hacia `+Z`.
pub fn fly(eye: Vec3, yaw: f32, pitch: f32) -> Self {
let lim = std::f32::consts::FRAC_PI_2 - 0.01;
let pitch = pitch.clamp(-lim, lim);
let (sy, cy) = yaw.sin_cos();
let (sp, cp) = pitch.sin_cos();
let dir = Vec3::new(cp * sy, sp, cp * cy);
Self {
eye,
target: eye + dir,
..Self::default()
}
}
/// Matriz `proj * view` lista para `mvp * vec4(pos, 1.0)` en el shader.
/// `aspect` = ancho/alto del viewport en pixels.
pub fn view_proj(&self, aspect: f32) -> Mat4 {
let view = Mat4::look_at_rh(self.eye, self.target, self.up);
// `perspective_rh` (no `_gl`): profundidad 0..1, la que espera wgpu.
let proj = Mat4::perspective_rh(self.fovy_rad, aspect.max(1e-4), self.znear, self.zfar);
proj * view
}
}
+139
View File
@@ -0,0 +1,139 @@
//! `CameraTrack` — interpolación de cámara por **keyframes** en el tiempo, el
//! ingrediente "cine" del motor: en vez de una `Camera3d` fija o atada a input,
//! una secuencia de poses `(t, eye, target, fov)` que se interpolan suave para
//! producir un **movimiento de cámara guionado** (travelling, grúa, dolly,
//! corte). Determinista por construcción → ideal para *filmar* frame a frame.
//!
//! Es genérico del motor 3D (no sabe de voxels ni de juegos): cualquier app que
//! quiera una cámara animada lo usa. La *dirección* de actores/eventos vive en
//! la capa de contenido (la app), no acá.
use glam::Vec3;
use crate::camera::Camera3d;
/// Una pose de cámara anclada a un instante `t` (segundos). Entre keys
/// consecutivas, [`CameraTrack::sample`] interpola `eye`/`target`/`fovy_rad`.
#[derive(Debug, Clone, Copy)]
pub struct CamKey {
/// Instante de la pose, en segundos desde el inicio.
pub t: f32,
/// Posición del ojo.
pub eye: Vec3,
/// Punto al que mira.
pub target: Vec3,
/// Campo de visión vertical (radianes) en esta pose.
pub fovy_rad: f32,
}
impl CamKey {
/// Atajo: una pose mirando de `eye` a `target` con FOV en **grados**.
pub fn look(t: f32, eye: Vec3, target: Vec3, fov_deg: f32) -> Self {
Self { t, eye, target, fovy_rad: fov_deg.to_radians() }
}
}
/// Secuencia de [`CamKey`] ordenada en el tiempo. `sample(t)` devuelve la
/// `Camera3d` interpolada; fuera de rango hace *clamp* a la primera/última pose.
#[derive(Debug, Clone, Default)]
pub struct CameraTrack {
keys: Vec<CamKey>,
}
impl CameraTrack {
/// Crea el track a partir de las keys (se ordenan por `t`). Un track vacío
/// o de una sola key es válido (devuelve siempre esa pose).
pub fn new(mut keys: Vec<CamKey>) -> Self {
keys.sort_by(|a, b| a.t.total_cmp(&b.t));
Self { keys }
}
/// Duración total (el `t` de la última key), o `0.0` si está vacío.
pub fn duration(&self) -> f32 {
self.keys.last().map(|k| k.t).unwrap_or(0.0)
}
/// La cámara interpolada en el instante `t` (segundos). Entre dos keys usa
/// **smoothstep** (acelera/desacelera suave, sin tirones) sobre la fracción
/// del segmento; antes de la primera / después de la última, clampa.
pub fn sample(&self, t: f32) -> Camera3d {
match self.keys.as_slice() {
[] => Camera3d::default(),
[only] => cam_of(only),
keys => {
// Clamp a los extremos.
if t <= keys[0].t {
return cam_of(&keys[0]);
}
if t >= keys[keys.len() - 1].t {
return cam_of(&keys[keys.len() - 1]);
}
// Segmento que contiene a `t`: última key con `t_key <= t`
// (existe y no es la última, por el clamp de arriba).
let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(keys.len() - 2);
let (a, b) = (&keys[i], &keys[i + 1]);
let span = (b.t - a.t).max(1e-6);
let f = smoothstep((t - a.t) / span);
Camera3d {
eye: a.eye.lerp(b.eye, f),
target: a.target.lerp(b.target, f),
fovy_rad: a.fovy_rad + (b.fovy_rad - a.fovy_rad) * f,
..Camera3d::default()
}
}
}
}
}
/// Construye una `Camera3d` (con `up`/planos por defecto) desde una key.
fn cam_of(k: &CamKey) -> Camera3d {
Camera3d {
eye: k.eye,
target: k.target,
fovy_rad: k.fovy_rad,
..Camera3d::default()
}
}
/// Suavizado Hermite clásico `3t²−2t³` en `[0,1]` (deriva nula en los extremos).
fn smoothstep(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
#[cfg(test)]
mod tests {
use super::*;
fn track() -> CameraTrack {
CameraTrack::new(vec![
CamKey::look(0.0, Vec3::new(0.0, 0.0, 0.0), Vec3::Z, 60.0),
CamKey::look(2.0, Vec3::new(10.0, 0.0, 0.0), Vec3::Z, 40.0),
])
}
#[test]
fn clamp_en_los_extremos() {
let tr = track();
assert_eq!(tr.sample(-1.0).eye, Vec3::ZERO);
assert_eq!(tr.sample(5.0).eye.x, 10.0);
assert_eq!(tr.duration(), 2.0);
}
#[test]
fn interpola_la_mitad_con_smoothstep() {
let tr = track();
// En la mitad temporal, smoothstep(0.5)=0.5 → punto medio exacto.
let c = tr.sample(1.0);
assert!((c.eye.x - 5.0).abs() < 1e-4, "x={}", c.eye.x);
assert!((c.fovy_rad - 50_f32.to_radians()).abs() < 1e-4);
}
#[test]
fn smoothstep_acelera_suave() {
let tr = track();
// A 1/4 del tiempo, smoothstep(0.25)=0.15625 < 0.25 (arranca lento).
let c = tr.sample(0.5);
assert!(c.eye.x < 2.5, "debería ir más lento al principio: x={}", c.eye.x);
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Dimensiones / mundos paralelos (M5) — `MOTOR-VOXEL.md` §3.8.
//!
//! Una **dimensión = un mundo voxel independiente** con su propio grid, su sol,
//! su cielo (color de fondo) y sus entidades. "Viajar" = cambiar qué dimensión
//! renderiza la cámara (un portal = un `switch`). No agrega complejidad de motor
//! (cada dimensión reusa el `VoxelRenderer` sparse tal cual): es contenido.
//!
//! El [`Multiverse`] mantiene N dimensiones y la activa; cada una materializa su
//! `VoxelRenderer` (su brick pool) perezosamente la primera vez que se la pinta,
//! y queda "tibia" en memoria para que el switch sea instantáneo.
use crate::camera::Camera3d;
use crate::voxel::VoxelGrid;
use crate::voxel_renderer::{Atmosphere, Entity3d, VoxelRenderer};
/// Formato de la textura intermedia de Llimphi (target de `gpu_paint_with`).
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Un mundo voxel independiente.
pub struct Dimension {
pub name: String,
pub grid: VoxelGrid,
/// Color de fondo (cielo) sugerido para la pasada vello base.
pub sky: [u8; 3],
/// Dirección hacia el sol de esta dimensión.
pub sun_dir: [f32; 3],
/// Atmósfera (cielo + niebla) de esta dimensión. Default = niebla off, así
/// una dimensión sin configurar se comporta como en M5 (miss → discard).
pub atmosphere: Atmosphere,
/// Entidades (agentes) de esta dimensión; se copian al renderer por frame.
pub entities: Vec<Entity3d>,
renderer: Option<VoxelRenderer>,
}
impl Dimension {
/// Dimensión nueva con cielo/sol por defecto y sin entidades.
pub fn new(name: impl Into<String>, grid: VoxelGrid) -> Self {
Self {
name: name.into(),
grid,
sky: [18, 22, 32],
sun_dir: [0.5, 1.0, 0.35],
atmosphere: Atmosphere::default(),
entities: Vec::new(),
renderer: None,
}
}
pub fn with_sky(mut self, sky: [u8; 3]) -> Self {
self.sky = sky;
self
}
pub fn with_sun(mut self, sun_dir: [f32; 3]) -> Self {
self.sun_dir = sun_dir;
self
}
/// Activa cielo + niebla propios para esta dimensión (el `render` los aplica
/// al renderer). Con `fog_density > 0`, el motor pinta su propio cielo en los
/// misses (ya no se ve el fondo vello).
pub fn with_atmosphere(mut self, atmosphere: Atmosphere) -> Self {
self.atmosphere = atmosphere;
self
}
pub fn with_entities(mut self, entities: Vec<Entity3d>) -> Self {
self.entities = entities;
self
}
}
/// Conjunto de dimensiones con una activa. La cámara siempre ve la activa.
pub struct Multiverse {
dims: Vec<Dimension>,
active: usize,
format: wgpu::TextureFormat,
}
impl Multiverse {
pub fn new(dims: Vec<Dimension>) -> Self {
Self {
dims,
active: 0,
format: FMT,
}
}
/// Cambia el formato de color del target (default `Rgba8Unorm`, la
/// intermedia de Llimphi). Sólo afecta a renderers aún no materializados.
pub fn with_format(mut self, format: wgpu::TextureFormat) -> Self {
self.format = format;
self
}
pub fn count(&self) -> usize {
self.dims.len()
}
pub fn active(&self) -> usize {
self.active
}
pub fn active_name(&self) -> &str {
&self.dims[self.active].name
}
pub fn names(&self) -> Vec<String> {
self.dims.iter().map(|d| d.name.clone()).collect()
}
pub fn skies(&self) -> Vec<[u8; 3]> {
self.dims.iter().map(|d| d.sky).collect()
}
/// Viaja a la dimensión `i` (no-op si fuera de rango).
pub fn switch(&mut self, i: usize) {
if i < self.dims.len() {
self.active = i;
}
}
pub fn next(&mut self) {
self.active = (self.active + 1) % self.dims.len();
}
pub fn prev(&mut self) {
self.active = (self.active + self.dims.len() - 1) % self.dims.len();
}
pub fn active_dim(&self) -> &Dimension {
&self.dims[self.active]
}
pub fn active_dim_mut(&mut self) -> &mut Dimension {
&mut self.dims[self.active]
}
/// Ray-marchea la dimensión activa sobre `target`. Materializa su brick pool
/// la primera vez. Firma compatible con la closure de `gpu_paint_with`.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
viewport: (u32, u32),
camera: &Camera3d,
) {
let fmt = self.format;
let d = &mut self.dims[self.active];
let r = d
.renderer
.get_or_insert_with(|| VoxelRenderer::new(device, queue, fmt, &d.grid));
r.sun_dir = d.sun_dir;
r.atmosphere = d.atmosphere;
r.entities = d.entities.clone();
r.render(device, queue, encoder, target, viewport, camera);
}
/// Acceso al renderer ya materializado de la dimensión activa (para `sync`
/// incremental de mutaciones, stats, etc.). `None` si aún no se pintó.
pub fn active_renderer_mut(&mut self) -> Option<&mut VoxelRenderer> {
self.dims[self.active].renderer.as_mut()
}
}
+306
View File
@@ -0,0 +1,306 @@
//! `Hud` — un pase **screen-space** mínimo: dibuja rectángulos de color plano
//! (con alpha) directamente en NDC, *después* del pase 3D, sobre el mismo
//! target. Es la pieza que faltaba para un **HUD / mira (crosshair)** en primera
//! persona: el contenido vello del árbol Llimphi queda **debajo** del canvas GPU
//! full-screen, así que cualquier overlay que deba ir *encima* del ray-march
//! tiene que pintarse en GPU en la misma closure `gpu_paint_with`, y eso es
//! justo lo que hace [`Hud::render`].
//!
//! Deliberadamente tonto: sin texturas, sin bind groups, sin depth. Geometría
//! en CPU → un vertex buffer dinámico → un draw. Suficiente para miras, barras,
//! marcos y **texto** ([`HudQuad::text`], fuente bitmap 5×7 = un quad por píxel
//! encendido, sin salir del pipeline de quads).
/// Un rectángulo de HUD en **pixels** (origen arriba-izquierda, como la
/// pantalla), color RGBA lineal `0..1`.
#[derive(Debug, Clone, Copy)]
pub struct HudQuad {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
pub color: [f32; 4],
}
impl HudQuad {
/// Una **mira (crosshair)** centrada en un viewport `(w, h)`: dos barras
/// (horizontal + vertical) de brazo `arm` y grosor `th` pixels.
pub fn crosshair(viewport: (u32, u32), arm: f32, th: f32, color: [f32; 4]) -> [HudQuad; 2] {
let cx = viewport.0 as f32 * 0.5;
let cy = viewport.1 as f32 * 0.5;
[
HudQuad { x: cx - arm, y: cy - th * 0.5, w: arm * 2.0, h: th, color },
HudQuad { x: cx - th * 0.5, y: cy - arm, w: th, h: arm * 2.0, color },
]
}
/// Emite los quads de una cadena con la **fuente bitmap 5×7** embebida
/// ([`glyph`]): origen arriba-izquierda en `(x, y)` pixels, cada píxel de
/// glifo mide `px` pixels de lado y los caracteres avanzan `6·px` (5 de ancho
/// + 1 de espacio). Sólo ASCII; las minúsculas se dibujan en mayúscula y los
/// caracteres desconocidos quedan en blanco. Se mantiene dentro del pipeline
/// tonto del HUD (un quad por píxel encendido, sin texturas).
pub fn text(s: &str, x: f32, y: f32, px: f32, color: [f32; 4]) -> Vec<HudQuad> {
let mut out = Vec::new();
let mut cx = x;
for ch in s.chars() {
if ch != ' ' {
let g = glyph(ch);
for (r, row) in g.iter().enumerate() {
for c in 0..5u32 {
if row & (1 << (4 - c)) != 0 {
out.push(HudQuad {
x: cx + c as f32 * px,
y: y + r as f32 * px,
w: px,
h: px,
color,
});
}
}
}
}
cx += 6.0 * px;
}
out
}
/// Ancho en pixels que ocuparía `s` con [`text`](Self::text) a tamaño `px`
/// (útil para dimensionar un panel de fondo antes de dibujar el texto).
pub fn text_width(s: &str, px: f32) -> f32 {
s.chars().count() as f32 * 6.0 * px
}
}
/// Mapa de un carácter a su bitmap **5×7**: 7 filas, cada `u8` con los 5 bits
/// bajos = columnas de izquierda (bit 4) a derecha (bit 0). Cubre `0-9`, `A-Z`
/// y puntuación común; lo desconocido devuelve un glifo en blanco. Las filas se
/// escriben en binario para que la forma sea legible en el código.
fn glyph(c: char) -> [u8; 7] {
match c.to_ascii_uppercase() {
'0' => [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1' => [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2' => [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
'3' => [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
'4' => [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5' => [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6' => [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8' => [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9' => [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
'A' => [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'B' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
'C' => [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
'D' => [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
'E' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'F' => [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
'G' => [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
'H' => [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'I' => [0b01110, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'J' => [0b00111, 0b00010, 0b00010, 0b00010, 0b10010, 0b10010, 0b01100],
'K' => [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
'L' => [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
'M' => [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
'N' => [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
'O' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'P' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'Q' => [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101],
'R' => [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
'S' => [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
'T' => [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
'U' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'V' => [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
'W' => [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
'X' => [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
'Y' => [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
'Z' => [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
':' => [0b00000, 0b00100, 0b00000, 0b00000, 0b00100, 0b00000, 0b00000],
'.' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100],
',' => [0b00000, 0b00000, 0b00000, 0b00000, 0b00100, 0b00100, 0b01000],
'-' => [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
'+' => [0b00000, 0b00100, 0b00100, 0b11111, 0b00100, 0b00100, 0b00000],
'/' => [0b00001, 0b00010, 0b00010, 0b00100, 0b01000, 0b01000, 0b10000],
'(' => [0b00110, 0b01000, 0b01000, 0b01000, 0b01000, 0b01000, 0b00110],
')' => [0b01100, 0b00010, 0b00010, 0b00010, 0b00010, 0b00010, 0b01100],
'%' => [0b11001, 0b11001, 0b00010, 0b00100, 0b01000, 0b10011, 0b10011],
_ => [0; 7],
}
}
/// Tamaño de un vértice del HUD: `pos: vec2<f32>` + `color: vec4<f32>`.
const VSIZE: usize = 2 * 4 + 4 * 4;
/// Renderer de overlay screen-space. Cachea pipeline + un vertex buffer
/// dinámico que crece según haga falta.
pub struct Hud {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
cap: u64,
}
impl Hud {
/// Crea el HUD para el `color_format` del target (el de la intermedia del
/// frame). No toca depth: dibuja siempre encima.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-hud-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-hud-pl"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-hud-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: VSIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: 8,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
// Sin depth: el HUD va siempre encima del 3D.
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let cap = (64 * 6 * VSIZE) as u64; // ~64 quads sin recrear
let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, vbuf, cap }
}
/// Dibuja `quads` sobre `target` (color `LoadOp::Load`, sin depth). Firma
/// compatible con la closure `gpu_paint_with`: llamar *después* del pase 3D.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
quads: &[HudQuad],
) {
if w == 0 || h == 0 || quads.is_empty() {
return;
}
// Geometría en CPU: 2 triángulos (6 vértices) por quad, en NDC. El eje Y
// de pantalla va hacia abajo; NDC hacia arriba → `1 - 2·y/h`.
let (fw, fh) = (w as f32, h as f32);
let mut bytes = Vec::with_capacity(quads.len() * 6 * VSIZE);
let mut vert = |x_px: f32, y_px: f32, c: [f32; 4]| {
let ndc_x = x_px / fw * 2.0 - 1.0;
let ndc_y = 1.0 - y_px / fh * 2.0;
bytes.extend_from_slice(&ndc_x.to_ne_bytes());
bytes.extend_from_slice(&ndc_y.to_ne_bytes());
for ch in c {
bytes.extend_from_slice(&ch.to_ne_bytes());
}
};
for q in quads {
let (x0, y0, x1, y1) = (q.x, q.y, q.x + q.w, q.y + q.h);
vert(x0, y0, q.color);
vert(x1, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y0, q.color);
vert(x1, y1, q.color);
vert(x0, y1, q.color);
}
// Crecer el buffer si hiciera falta (raro: la mira son 2 quads).
if bytes.len() as u64 > self.cap {
self.cap = (bytes.len() as u64).next_power_of_two();
self.vbuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-hud-vbuf"),
size: self.cap,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
queue.write_buffer(&self.vbuf, 0, &bytes);
let count = (quads.len() * 6) as u32;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-hud-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_vertex_buffer(0, self.vbuf.slice(..bytes.len() as u64));
pass.draw(0..count, 0..1);
}
}
const WGSL: &str = r#"
struct VIn {
@location(0) pos: vec2<f32>,
@location(1) color: vec4<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = vec4<f32>(in.pos, 0.0, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return in.color;
}
"#;
+54
View File
@@ -0,0 +1,54 @@
//! # llimphi-3d — pase 3D base de Llimphi (M0 del motor 3D)
//!
//! Lo mínimo para tener **3D real dentro de un `View` de Llimphi**: una
//! [`Camera3d`] (matrices view/proj con `glam`), un depth buffer propio y un
//! [`Renderer3d`] que dibuja geometría indexada con test de profundidad sobre
//! la textura intermedia del frame.
//!
//! ## Cómo encaja con el bucle Elm + vello + wgpu
//!
//! Llimphi ya rasteriza la UI con vello sobre una textura intermedia y expone
//! [`View::gpu_paint_with`] para inyectar una pasada GPU directa *después* de
//! vello (con `LoadOp::Load`, preservando la UI). [`Renderer3d::render`] tiene
//! **exactamente** la firma que esa closure necesita
//! (`device, queue, encoder, target_view, (w, h), &camera`), así que un nodo 3D
//! es:
//!
//! ```ignore
//! let r3d = Arc::new(Mutex::new(Renderer3d::new(&device, fmt)));
//! View::empty().gpu_paint_with(move |dev, q, enc, view, rect, vp| {
//! r3d.lock().unwrap().render(dev, q, enc, view, vp, &camera);
//! })
//! ```
//!
//! No es un segundo motor: corre sobre el **mismo wgpu** que ya usa Llimphi,
//! que a su vez traduce a Vulkan/Metal/DX12/GL/WebGPU. Ver
//! `01_yachay/dominium/MOTOR-VOXEL.md` §11 para la ruta completa (M0..M4,
//! ray-march de voxels sparse en los hitos siguientes).
//!
//! [`View::gpu_paint_with`]: https://docs/llimphi-compositor
pub use glam;
pub use wgpu;
mod camera;
mod cinema;
mod dimensions;
mod hud;
mod mesh;
mod renderer;
mod scene;
mod voxel;
mod voxel_renderer;
pub use camera::Camera3d;
pub use cinema::{CamKey, CameraTrack};
pub use dimensions::{Dimension, Multiverse};
pub use hud::{Hud, HudQuad};
pub use mesh::{cube, push_cube, Vertex3d, CUBE_INDICES};
pub use renderer::Renderer3d;
pub use scene::Scene3d;
pub use voxel::{DirtyBox, VoxelGrid};
pub use voxel_renderer::{
Atmosphere, Entity3d, PointLight, VoxelRenderer, VOXEL_BRICK, VOXEL_MAX_LIGHTS,
};
+85
View File
@@ -0,0 +1,85 @@
//! Geometría de mallas: el vértice 3D ([`Vertex3d`]), un cubo de prueba
//! ([`cube`]) y un compositor de cajas transformadas ([`push_cube`]) para armar
//! mallas multi-caja en CPU — p.ej. un **muñeco articulado** (cabeza/torso/
//! miembros como cajas rotadas en sus articulaciones).
//!
//! Sigue el idiom de `llimphi-raster::gpu` (subir a GPU vía `to_ne_bytes`, sin
//! `bytemuck`) para no agregar una dependencia nueva al workspace.
use glam::{Mat4, Vec3};
/// Vértice 3D: posición en mundo + color RGB lineal.
#[derive(Debug, Clone, Copy)]
pub struct Vertex3d {
pub pos: [f32; 3],
pub color: [f32; 3],
}
impl Vertex3d {
/// Tamaño en bytes de un vértice empaquetado (`6 × f32`).
pub const SIZE: usize = 6 * 4;
/// Vuelca este vértice al buffer en orden `pos.xyz, color.rgb` (native
/// endian, como hace `GpuBatch`).
pub fn write_to(&self, out: &mut Vec<u8>) {
for v in self.pos {
out.extend_from_slice(&v.to_ne_bytes());
}
for v in self.color {
out.extend_from_slice(&v.to_ne_bytes());
}
}
}
/// Las 8 esquinas del cubo unitario centrado en el origen (lado 1, `-0.5..0.5`).
const CUBE_CORNERS: [[f32; 3]; 8] = [
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, 0.5, -0.5],
[-0.5, 0.5, -0.5],
[-0.5, -0.5, 0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, 0.5],
[-0.5, 0.5, 0.5],
];
/// Los 36 índices (12 triángulos) del cubo, winding CCW visto desde afuera.
#[rustfmt::skip]
pub const CUBE_INDICES: [u16; 36] = [
0, 2, 1, 0, 3, 2, // -Z (atrás)
4, 5, 6, 4, 6, 7, // +Z (frente)
0, 4, 7, 0, 7, 3, // -X (izquierda)
1, 2, 6, 1, 6, 5, // +X (derecha)
0, 1, 5, 0, 5, 4, // -Y (abajo)
3, 7, 6, 3, 6, 2, // +Y (arriba)
];
/// Cubo unitario centrado en el origen (lado 1, de `-0.5` a `0.5`). 8 vértices
/// coloreados por su posición (`color = pos + 0.5`) → un degradé que deja ver
/// las tres caras visibles distintas. 36 índices (12 triángulos), winding CCW.
pub fn cube() -> (Vec<Vertex3d>, Vec<u16>) {
let verts = CUBE_CORNERS
.iter()
.map(|&[x, y, z]| Vertex3d {
pos: [x, y, z],
color: [x + 0.5, y + 0.5, z + 0.5],
})
.collect();
(verts, CUBE_INDICES.to_vec())
}
/// Apila un cubo transformado por `m` (mapea el cubo unitario `[-0.5,0.5]³` a su
/// caja en mundo) con color plano `color`, en `verts`/`indices`. Es el ladrillo
/// para componer mallas multi-caja en CPU: cada llamada agrega 8 vértices + 36
/// índices con la base reubicada. Para un miembro articulado, `m` suele ser
/// `T(articulación) · R(ángulo) · T(0,-largo/2,0) · S(tamaño)`.
pub fn push_cube(verts: &mut Vec<Vertex3d>, indices: &mut Vec<u16>, m: Mat4, color: [f32; 3]) {
let base = verts.len() as u16;
for c in CUBE_CORNERS {
let p = m.transform_point3(Vec3::from_array(c));
verts.push(Vertex3d { pos: p.to_array(), color });
}
for i in CUBE_INDICES {
indices.push(base + i);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! `Renderer3d` — pipeline wgpu mínimo que dibuja geometría 3D indexada con
//! test de profundidad sobre la textura intermedia del frame de Llimphi.
//!
//! La firma de [`Renderer3d::render`] es la que pide la closure de
//! `View::gpu_paint_with` (`device, queue, encoder, target_view, (w, h)`), más
//! la cámara — así un nodo 3D entra en el árbol `View<Msg>` sin tocar el
//! runtime. Mantiene su **propio depth buffer** (recreado al cambiar de
//! tamaño); el color se compone con `LoadOp::Load` para preservar la UI vello
//! que ya está debajo.
use glam::Mat4;
use crate::camera::Camera3d;
use crate::mesh::{cube, Vertex3d};
use crate::scene::{ensure_depth, DepthBuffer, DEPTH_FORMAT};
/// Renderer de **mallas** indexadas (por defecto un cubo) visto desde una
/// [`Camera3d`]. Cachea pipeline, buffers de geometría, uniform y (para el
/// camino standalone) un depth propio. En [`Scene3d`](crate::Scene3d) comparte
/// el depth con el pase de voxels para ocluirse mutuamente.
///
/// `model` ubica la malla en el mundo (default identidad): `mvp = view_proj ·
/// model`, así una misma malla se instancia/posiciona sin reconstruir buffers.
pub struct Renderer3d {
pipeline: wgpu::RenderPipeline,
vbuf: wgpu::Buffer,
ibuf: wgpu::Buffer,
index_count: u32,
ubuf: wgpu::Buffer,
bind_group: wgpu::BindGroup,
model: Mat4,
depth: Option<DepthBuffer>,
}
impl Renderer3d {
/// Crea el renderer para un `color_format` dado (el de la textura
/// intermedia del frame — `Rgba8Unorm` en headless, el de la surface en
/// vivo). Arranca con el cubo de prueba cargado.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let (verts, indices) = cube();
Self::with_mesh(device, color_format, &verts, &indices)
}
/// Igual que [`Self::new`] pero con una malla arbitraria.
pub fn with_mesh(
device: &wgpu::Device,
color_format: wgpu::TextureFormat,
verts: &[Vertex3d],
indices: &[u16],
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-3d-shader"),
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-3d-bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-3d-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-3d-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: Vertex3d::SIZE as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x3,
offset: 12,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
// M0 sin cull: el depth test ya resuelve la oclusión y nos
// ahorra bugs de winding al sumar mallas. El cull entra en M1+.
cull_mode: None,
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
// Opaco: el cubo reemplaza el fondo vello donde lo cubre.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
// Geometría → buffers (idiom `to_ne_bytes`, sin bytemuck).
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
let vbuf = create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
let ibuf = create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-3d-ubuf"),
size: 64, // una mat4x4<f32>
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-3d-bg"),
layout: &bind_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: ubuf.as_entire_binding(),
}],
});
Self {
pipeline,
vbuf,
ibuf,
index_count: indices.len() as u32,
ubuf,
bind_group,
model: Mat4::IDENTITY,
depth: None,
}
}
/// Ubica la malla en el mundo (`mvp = view_proj · model`). Default identidad.
pub fn set_model(&mut self, model: Mat4) {
self.model = model;
}
/// Reemplaza la geometría (recrea los buffers de vértices/índices). Pensado
/// para mallas que cambian cada frame — p.ej. un **muñeco articulado** cuya
/// pose se rehornea en CPU (limbos rotados) y se vuelve a subir. Las mallas
/// son chicas (decenas-cientos de vértices), así que recrear los buffers por
/// frame es despreciable; el pipeline/uniform/bind-group se conservan.
pub fn set_geometry(&mut self, device: &wgpu::Device, verts: &[Vertex3d], indices: &[u16]) {
let mut vbytes = Vec::with_capacity(verts.len() * Vertex3d::SIZE);
for v in verts {
v.write_to(&mut vbytes);
}
self.vbuf =
create_buffer_init(device, "llimphi-3d-vbuf", wgpu::BufferUsages::VERTEX, &vbytes);
let mut ibytes = Vec::with_capacity(indices.len() * 2);
for &i in indices {
ibytes.extend_from_slice(&i.to_ne_bytes());
}
self.ibuf =
create_buffer_init(device, "llimphi-3d-ibuf", wgpu::BufferUsages::INDEX, &ibytes);
self.index_count = indices.len() as u32;
}
/// Sube el uniform del frame (`mvp = view_proj · model`). Lo llama
/// [`Self::render`] y [`Scene3d`](crate::Scene3d). `aspect` = w/h.
pub fn upload(&self, queue: &wgpu::Queue, aspect: f32, camera: &Camera3d) {
let mvp = camera.view_proj(aspect) * self.model;
// glam es column-major; el shader WGSL espera column-major → upload tal cual.
let mut ubytes = Vec::with_capacity(64);
for v in mvp.to_cols_array() {
ubytes.extend_from_slice(&v.to_ne_bytes());
}
queue.write_buffer(&self.ubuf, 0, &ubytes);
}
/// Dibuja la malla indexada en un pase **ya abierto** (color + depth). Lo usa
/// [`Scene3d`](crate::Scene3d) para compartir el pase con los voxels.
/// Requiere `upload` previo en el mismo frame.
pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, self.vbuf.slice(..));
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..self.index_count, 0, 0..1);
}
/// Dibuja la malla sola sobre `target` (camino standalone, depth propio).
/// Firma compatible con `View::gpu_paint_with`; color preservado
/// (`LoadOp::Load`), depth propio limpiado cada frame.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
camera: &Camera3d,
) {
if w == 0 || h == 0 {
return;
}
self.upload(queue, w as f32 / h as f32, camera);
ensure_depth(&mut self.depth, device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
self.draw(&mut pass);
}
}
/// Crea un buffer ya inicializado con `data` (sin `wgpu::util::DeviceExt`, para
/// no arrastrar la feature `util`): `mapped_at_creation` + copia + `unmap`.
fn create_buffer_init(
device: &wgpu::Device,
label: &str,
usage: wgpu::BufferUsages,
data: &[u8],
) -> wgpu::Buffer {
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size: data.len() as u64,
usage,
mapped_at_creation: true,
});
buf.slice(..).get_mapped_range_mut().copy_from_slice(data);
buf.unmap();
buf
}
const WGSL: &str = r#"
struct Uniforms { mvp: mat4x4<f32> };
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>,
};
struct VOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn vs(in: VIn) -> VOut {
var out: VOut;
out.clip = u.mvp * vec4<f32>(in.pos, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs(in: VOut) -> @location(0) vec4<f32> {
return vec4<f32>(in.color, 1.0);
}
"#;
+186
View File
@@ -0,0 +1,186 @@
//! `Scene3d` — orquestador de una escena 3D **general**: compone, en un único
//! pase con **depth buffer compartido**, el render volumétrico de voxels
//! ([`VoxelRenderer`](crate::VoxelRenderer)) y mallas de triángulos
//! ([`Renderer3d`](crate::Renderer3d)). Es el keystone que vuelve a `llimphi-3d`
//! un motor 3D general y no "sólo voxels": voxels y mallas se **ocluyen
//! correctamente entre sí** porque ambos escriben/testean el mismo depth.
//!
//! La firma de [`Scene3d::render`] es compatible con la closure de
//! `View::gpu_paint_with` (más los renderers a componer): el `Scene3d` posee el
//! depth y abre el pase; cada renderer aporta su `upload` (uniforms) + `draw`
//! (en el pase ya abierto).
use crate::camera::Camera3d;
use crate::renderer::Renderer3d;
use crate::voxel_renderer::VoxelRenderer;
/// Formato del depth buffer de toda la escena 3D (debe coincidir entre el
/// pipeline de voxels, el de mallas y la textura de depth).
pub(crate) const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
/// Depth attachment cacheado, recreado cuando cambia el tamaño del viewport.
pub(crate) struct DepthBuffer {
pub view: wgpu::TextureView,
w: u32,
h: u32,
}
/// Asegura que `slot` tenga un depth buffer de `w×h` (lo recrea si cambió).
pub(crate) fn ensure_depth(
slot: &mut Option<DepthBuffer>,
device: &wgpu::Device,
w: u32,
h: u32,
) {
if matches!(slot, Some(d) if d.w == w && d.h == h) {
return;
}
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-3d-depth"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
*slot = Some(DepthBuffer { view, w, h });
}
/// Escena 3D que comparte un depth buffer entre el pase de voxels y el de
/// mallas. Sólo posee el depth; los renderers los aporta el llamador por
/// referencia en cada frame (así la app conserva la propiedad y los muta).
#[derive(Default)]
pub struct Scene3d {
depth: Option<DepthBuffer>,
}
impl Scene3d {
pub fn new() -> Self {
Self::default()
}
/// Compone la escena sobre `target` (textura intermedia del frame). Primero
/// ray-marchea los voxels (escriben color + profundidad), luego dibuja las
/// mallas (testean contra esa profundidad) — todo en un pase con el depth
/// compartido, limpiado a lejano (`1.0`) al abrirlo. El color se preserva
/// (`LoadOp::Load`) para no pisar la UI vello de abajo.
///
/// Firma compatible con `View::gpu_paint_with` más los renderers a componer.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
// El caso por defecto: la escena ocupa todo el target.
self.render_in(
device,
queue,
encoder,
target,
(w, h),
(0.0, 0.0, w as f32, h as f32),
camera,
voxel,
meshes,
);
}
/// Como [`render`](Self::render) pero **confina** la escena a la sub-región
/// `rect = (x, y, w, h)` (en px del target, esquina sup-izq), vía
/// `set_viewport` + `set_scissor_rect`. Es lo que permite montar el 3D en un
/// **panel** de una UI (un canvas que no ocupa toda la ventana) sin pisar el
/// chrome alrededor: la pasada de ray-march/mallas pinta sólo dentro del rect,
/// con el aspect del rect (no el de la ventana). `target`/`viewport` siguen
/// siendo el frame completo (load-preserve del chrome ya rasterizado).
#[allow(clippy::too_many_arguments)]
pub fn render_in(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
(w, h): (u32, u32),
rect: (f32, f32, f32, f32),
camera: &Camera3d,
voxel: Option<&VoxelRenderer>,
meshes: &[&Renderer3d],
) {
if w == 0 || h == 0 {
return;
}
let (rx, ry, rw, rh) = rect;
if rw < 1.0 || rh < 1.0 {
return;
}
// El aspect es el del rect (el viewport mapea NDC a esa sub-región).
let aspect = rw / rh;
// Subir uniforms antes de abrir el pase (queue.write_buffer se ordena
// antes del submit).
if let Some(v) = voxel {
v.upload(queue, aspect, camera);
}
for m in meshes {
m.upload(queue, aspect, camera);
}
ensure_depth(&mut self.depth, device, w, h);
let depth_view = &self.depth.as_ref().unwrap().view;
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-3d-scene-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
// Viewport (mapeo NDC→rect) + scissor (recorte físico al rect, clampeado a
// los límites del attachment).
pass.set_viewport(rx, ry, rw, rh, 0.0, 1.0);
let sx = rx.max(0.0);
let sy = ry.max(0.0);
let sw = (rw.min(w as f32 - sx)).max(0.0) as u32;
let sh = (rh.min(h as f32 - sy)).max(0.0) as u32;
if sw == 0 || sh == 0 {
return;
}
pass.set_scissor_rect(sx as u32, sy as u32, sw, sh);
if let Some(v) = voxel {
v.draw(&mut pass);
}
for m in meshes {
m.draw(&mut pass);
}
}
}
+296
View File
@@ -0,0 +1,296 @@
//! `VoxelGrid` — grid de voxels denso y acotado (CPU side). Cada voxel es
//! RGBA8: `rgb` = color, `a` = ocupación (`0` vacío, `>0` sólido). Se sube a
//! una textura 3D de GPU que el shader ray-march recorre por DDA.
//!
//! M1 es **denso** a propósito (lo más simple que funciona). El salto a sparse
//! (SVO/brickmap, saltar el aire) es M2 — ver `MOTOR-VOXEL.md` §11.2.
//!
//! M3 agrega **dirty tracking**: cada `set`/`clear` expande una caja AABB de la
//! región cambiada. `VoxelRenderer::sync` sube sólo esa sub-caja (fina + bricks
//! gruesos afectados) — la actualización incremental que reemplaza al re-mesh.
/// Caja AABB de voxels cambiados desde el último `take_dirty`: `[xmin, ymin,
/// zmin, xmax, ymax, zmax]` inclusiva.
pub type DirtyBox = [u32; 6];
/// Grid denso de voxels RGBA8. Índice lineal `x + y*dx + z*dx*dy` (x contiguo),
/// que es justo el layout que espera `queue.write_texture` (filas en x, luego y,
/// luego capas en z).
#[derive(Clone)]
pub struct VoxelGrid {
dim: [u32; 3],
data: Vec<[u8; 4]>,
/// AABB de voxels mutados desde el último `take_dirty`. `None` = sin cambios.
dirty: Option<DirtyBox>,
}
impl VoxelGrid {
/// Grid vacío de `dim = [dx, dy, dz]` voxels.
pub fn new(dim: [u32; 3]) -> Self {
let n = (dim[0] * dim[1] * dim[2]) as usize;
Self {
dim,
data: vec![[0, 0, 0, 0]; n],
dirty: None,
}
}
/// Dimensiones `[dx, dy, dz]`.
pub fn dim(&self) -> [u32; 3] {
self.dim
}
#[inline]
fn idx(&self, x: u32, y: u32, z: u32) -> usize {
(x + y * self.dim[0] + z * self.dim[0] * self.dim[1]) as usize
}
#[inline]
fn mark_dirty(&mut self, x: u32, y: u32, z: u32) {
match &mut self.dirty {
None => self.dirty = Some([x, y, z, x, y, z]),
Some(d) => {
d[0] = d[0].min(x);
d[1] = d[1].min(y);
d[2] = d[2].min(z);
d[3] = d[3].max(x);
d[4] = d[4].max(y);
d[5] = d[5].max(z);
}
}
}
/// Toma y limpia la caja de cambios pendientes. `VoxelRenderer::sync` la usa
/// para subir sólo lo mutado. `None` si no hubo cambios desde la última toma.
pub fn take_dirty(&mut self) -> Option<DirtyBox> {
self.dirty.take()
}
/// Descarta los cambios pendientes sin subirlos (tras un upload completo, el
/// estado inicial ya está en GPU).
pub fn reset_dirty(&mut self) {
self.dirty = None;
}
/// Marca un voxel sólido con color `rgb` (alpha = 255). Fuera de rango: no-op.
pub fn set(&mut self, x: u32, y: u32, z: u32, rgb: [u8; 3]) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [rgb[0], rgb[1], rgb[2], 255];
self.mark_dirty(x, y, z);
}
}
/// Vacía **todos** los voxels y marca el grid entero como dirty (la próxima
/// `VoxelRenderer::sync` re-sube todo). Para regenerar el contenido de una
/// ventana de *streaming* in-place sin reconstruir el renderer.
pub fn clear_all(&mut self) {
for px in &mut self.data {
*px = [0, 0, 0, 0];
}
self.dirty = Some([0, 0, 0, self.dim[0] - 1, self.dim[1] - 1, self.dim[2] - 1]);
}
/// Vacía un voxel.
pub fn clear(&mut self, x: u32, y: u32, z: u32) {
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let i = self.idx(x, y, z);
self.data[i] = [0, 0, 0, 0];
self.mark_dirty(x, y, z);
}
}
#[inline]
fn solid(&self, x: u32, y: u32, z: u32) -> bool {
self.data[self.idx(x, y, z)][3] > 0
}
/// `true` si el voxel `(x,y,z)` es sólido. Fuera de rango → `false` (el
/// "afuera" del mundo es aire). Lo usa el raycast de `llimphi-voxel` para
/// picking/edición (mirar → bloque).
#[inline]
pub fn is_solid(&self, x: i32, y: i32, z: i32) -> bool {
if x < 0 || y < 0 || z < 0 {
return false;
}
let (x, y, z) = (x as u32, y as u32, z as u32);
x < self.dim[0] && y < self.dim[1] && z < self.dim[2] && self.solid(x, y, z)
}
/// Color RGBA del voxel `(x,y,z)`, o `None` fuera de rango. `a = 0` = aire.
pub fn get(&self, x: u32, y: u32, z: u32) -> Option<[u8; 4]> {
(x < self.dim[0] && y < self.dim[1] && z < self.dim[2]).then(|| self.data[self.idx(x, y, z)])
}
/// Altura del voxel sólido más alto en la columna `(x, z)` (escaneando de
/// arriba hacia abajo), o `None` si la columna está vacía. Útil para posar
/// una cámara/entidad sobre el terreno sin meterla dentro de la roca.
pub fn height_at(&self, x: u32, z: u32) -> Option<u32> {
if x >= self.dim[0] || z >= self.dim[2] {
return None;
}
(0..self.dim[1]).rev().find(|&y| self.solid(x, y, z))
}
/// Mapa de ocupación grueso por *bricks* de `brick³` voxels (M2): un texel
/// por brick, `255` si el brick contiene algún voxel sólido, `0` si está
/// todo vacío. El shader marcha primero esta grilla gruesa y se salta los
/// bricks vacíos enteros en un paso (empty-space skipping). Devuelve
/// `(dim_grueso, bytes R8)` con índice `cx + cy*cdx + cz*cdx*cdy`.
pub fn coarse_occupancy(&self, brick: u32) -> ([u32; 3], Vec<u8>) {
let b = brick.max(1);
let cdim = [
self.dim[0].div_ceil(b),
self.dim[1].div_ceil(b),
self.dim[2].div_ceil(b),
];
let mut out = vec![0u8; (cdim[0] * cdim[1] * cdim[2]) as usize];
for z in 0..self.dim[2] {
for y in 0..self.dim[1] {
for x in 0..self.dim[0] {
if self.solid(x, y, z) {
let (cx, cy, cz) = (x / b, y / b, z / b);
out[(cx + cy * cdim[0] + cz * cdim[0] * cdim[1]) as usize] = 255;
}
}
}
}
(cdim, out)
}
/// `255` si el brick `(cx,cy,cz)` (tamaño `b`) tiene algún voxel sólido,
/// `0` si está todo vacío. Lo usa el brick pool para decidir si un brick
/// necesita slot.
pub fn brick_occupied(&self, b: u32, cx: u32, cy: u32, cz: u32) -> u8 {
let (x0, y0, z0) = (cx * b, cy * b, cz * b);
for z in z0..(z0 + b).min(self.dim[2]) {
for y in y0..(y0 + b).min(self.dim[1]) {
for x in x0..(x0 + b).min(self.dim[0]) {
if self.solid(x, y, z) {
return 255;
}
}
}
}
0
}
/// Extrae los voxels de un brick `(cx,cy,cz)` de lado `brick` como RGBA
/// plano (`brick³` voxels, x contiguo), padeando con vacío los voxels fuera
/// del grid (bricks de borde cuando `dim` no es múltiplo de `brick`). Es la
/// unidad de subida al *pool* sparse (un slot del atlas = un brick).
pub fn extract_brick(&self, brick: u32, cx: u32, cy: u32, cz: u32) -> Vec<u8> {
let b = brick;
let mut out = vec![0u8; (b * b * b * 4) as usize];
for lz in 0..b {
for ly in 0..b {
for lx in 0..b {
let (x, y, z) = (cx * b + lx, cy * b + ly, cz * b + lz);
if x < self.dim[0] && y < self.dim[1] && z < self.dim[2] {
let px = self.data[self.idx(x, y, z)];
let o = ((lx + ly * b + lz * b * b) * 4) as usize;
out[o..o + 4].copy_from_slice(&px);
}
}
}
}
out
}
/// Extrae una sub-caja RGBA contigua `[origin, origin+ext)` para subirla con
/// `queue.write_texture` (M3: upload incremental de la región fina mutada).
pub fn extract_fine(&self, origin: [u32; 3], ext: [u32; 3]) -> Vec<u8> {
let mut out = Vec::with_capacity((ext[0] * ext[1] * ext[2] * 4) as usize);
for z in origin[2]..origin[2] + ext[2] {
for y in origin[1]..origin[1] + ext[1] {
let row = self.idx(origin[0], y, z);
for i in 0..ext[0] as usize {
out.extend_from_slice(&self.data[row + i]);
}
}
}
out
}
/// Recalcula la ocupación gruesa de la caja de bricks `[cmin, cmin+cext)` y
/// la devuelve contigua (R8) para subir sólo esos bricks (M3).
pub fn coarse_region(&self, brick: u32, cmin: [u32; 3], cext: [u32; 3]) -> Vec<u8> {
let b = brick.max(1);
let mut out = Vec::with_capacity((cext[0] * cext[1] * cext[2]) as usize);
for cz in cmin[2]..cmin[2] + cext[2] {
for cy in cmin[1]..cmin[1] + cext[1] {
for cx in cmin[0]..cmin[0] + cext[0] {
out.push(self.brick_occupied(b, cx, cy, cz));
}
}
}
out
}
/// Bytes RGBA planos listos para `queue.write_texture`.
pub fn bytes(&self) -> &[u8] {
// `[u8;4]` es contiguo: reinterpretamos el Vec como bytes planos.
// SAFETY: `[u8;4]` no tiene padding; len*4 bytes válidos.
unsafe {
std::slice::from_raw_parts(self.data.as_ptr() as *const u8, self.data.len() * 4)
}
}
/// Escena de prueba para M1: un piso de 2 capas + una esfera coloreada por
/// posición flotando en el centro. Pone a prueba el DDA (atraviesa aire,
/// pega en piso y en esfera) y el sombreado por normal de cara.
pub fn demo_scene(dim: [u32; 3]) -> Self {
let mut g = Self::new(dim);
let [dx, dy, dz] = dim;
// Piso: 2 capas grises abajo, con un leve damero para leer la perspectiva.
for z in 0..dz {
for x in 0..dx {
let chk = ((x / 4 + z / 4) % 2) == 0;
let base = if chk { 70 } else { 95 };
for y in 0..2 {
g.set(x, y, z, [base, base + 8, base + 16]);
}
}
}
// Esfera centrada, color por posición normalizada.
let cx = dx as f32 / 2.0;
let cy = dy as f32 * 0.55;
let cz = dz as f32 / 2.0;
let r = (dx.min(dy).min(dz) as f32) * 0.3;
for z in 0..dz {
for y in 0..dy {
for x in 0..dx {
let (fx, fy, fz) = (x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
let d = ((fx - cx).powi(2) + (fy - cy).powi(2) + (fz - cz).powi(2)).sqrt();
if d <= r {
let rr = (fx / dx as f32 * 255.0) as u8;
let gg = (fy / dy as f32 * 255.0) as u8;
let bb = (fz / dz as f32 * 255.0) as u8;
g.set(x, y, z, [rr, gg, bb]);
}
}
}
}
// Pilares: dan rincones para el AO y proyectan/reciben sombras.
let pillars: [(u32, u32, u32, [u8; 3]); 3] = [
(dx / 5, dz / 4, dy * 7 / 10, [200, 120, 90]),
(dx * 4 / 5, dz / 3, dy / 2, [110, 170, 120]),
(dx / 3, dz * 4 / 5, dy * 3 / 5, [120, 130, 210]),
];
for (px, pz, ph, col) in pillars {
for y in 2..(2 + ph).min(dy) {
for dxx in 0..3u32 {
for dzz in 0..3u32 {
g.set(px + dxx, y, pz + dzz, col);
}
}
}
}
// Estado inicial: el upload completo lo cubre, no es "mutación".
g.reset_dirty();
g
}
}
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -5,12 +5,31 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-compositor — el núcleo declarativo de Llimphi sin winit: el árbol `View<Msg>`, el mount sobre taffy, el paint a `vello::Scene` y el hit-test. No depende de llimphi-hal ni de una surface concreta, así que la misma composición sirve sobre winit (llimphi-ui) o, a futuro, sobre el framebuffer del kernel wawa. `wgpu` entra sólo por la firma de `GpuPaintFn` (tipos, no windowing)."
[dependencies]
llimphi-layout = { path = "../llimphi-layout" }
llimphi-text = { path = "../llimphi-text" }
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
vello = { workspace = true }
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
# wgpu NO depende de winit — el compositor sigue libre de windowing.
wgpu = { workspace = true }
[dev-dependencies]
# Volcado headless del paint a PNG (llvmpipe) para VER sombra/gradiente/borde
# sin levantar ventana. Sólo capas inferiores — no llimphi-ui (sin ciclo).
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-theme = { path = "../llimphi-theme" }
png = { workspace = true }
pollster = { workspace = true }
# Widgets REALES para el showreel: el `View<Msg>` que devuelven es el mismo
# tipo (`llimphi_compositor::View`, re-exportado por llimphi-ui), así que
# montan con el mount/paint/measure de este mismo crate. Dev-only → sin ciclo
# en el grafo normal (llimphi-ui depende de este crate, no al revés).
llimphi-widget-switch = { path = "../widgets/switch" }
llimphi-widget-slider = { path = "../widgets/slider" }
llimphi-widget-progress = { path = "../widgets/progress" }
llimphi-widget-button = { path = "../widgets/button" }
llimphi-widget-segmented = { path = "../widgets/segmented" }
+263
View File
@@ -0,0 +1,263 @@
//! Filmstrip headless de **animaciones implícitas**, tres filas:
//!
//! - **Fila 1** — `View::animated`: el mismo nodo cuyo `fill` cambia de rojo a
//! azul, reconciliado a 6 instantes crecientes — crossfade rojo→púrpura→azul.
//! - **Fila 2** — `View::animated_enter`: el fade-in de ENTRADA de un nodo, de
//! opacidad 0 a opaco, a los mismos 6 progresos.
//! - **Fila 3** — `View::animated_exit`: el fade-out de SALIDA de un nodo
//! (capturado mientras vivía, reproducido como fantasma), de opaco a 0.
//!
//! Prueba el camino completo View.animated[_enter|_exit] → AnimRegistry →
//! paint/paint_range/replay_ghosts → píxeles.
//!
//! `cargo run -p llimphi-compositor --example anim_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{mount, paint, paint_range, AnimRegistry, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
use vello::kurbo::Affine;
const W: u32 = 1180;
const H: u32 = 580;
/// Y de la fila de crossfade, de fade-in de entrada y de fade-out de salida.
const ROW_FADE_Y: f64 = 40.0;
const ROW_ENTER_Y: f64 = 220.0;
const ROW_EXIT_Y: f64 = 400.0;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const FRAMES: usize = 6;
const DUR: Duration = Duration::from_millis(500);
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Una tarjeta animada (key=1) con `fill`, transladada (vía `transform`) a su
/// columna `i` y con el `fill` que la `view` "quiere" este frame.
fn card_shell(col: usize, row_y: f64, label: &str, fg: Color) -> View<()> {
View::<()>::new(Style {
size: Size { width: length(170.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
flex_direction: FlexDirection::Column,
..Default::default()
})
.transform(Affine::translate((20.0 + col as f64 * 190.0, row_y)))
.radius(18.0)
.children(vec![View::<()>::new(Style {
size: Size { width: length(150.0), height: length(20.0) },
..Default::default()
})
.text_aligned(label.to_string(), 13.0, fg, Alignment::Center)])
}
fn card(fill: Color, col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_FADE_Y, label, fg).fill(fill).animated(1, DUR)
}
/// Tarjeta con animación de ENTRADA: su primera aparición sube de opacidad 0
/// a opaco. La key se varía por columna (key=10+col) para que cada registro la
/// trate como una entrada nueva e independiente.
fn card_enter(col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_ENTER_Y, label, fg)
.fill(rgb(60, 90, 220))
.animated_enter(10 + col as u64, DUR)
}
/// Tarjeta con animación de SALIDA: cuando desaparece, el runtime reproduce su
/// subescena capturada con opacidad decreciente. Verde para distinguir la fila.
fn card_exit(col: usize, label: &str, fg: Color) -> View<()> {
card_shell(col, ROW_EXIT_Y, label, fg)
.fill(rgb(40, 160, 90))
.animated_exit(20 + col as u64, DUR)
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "anim.png".to_string());
let red = rgb(220, 60, 60);
let blue = rgb(60, 90, 220);
let white = rgb(245, 245, 250);
// Un registro por columna: cada uno se asienta en rojo y arranca el tween
// a azul en t0, pero se OBSERVA a un instante distinto (i * paso). Así el
// filmstrip muestra la misma transición a 6 progresos.
let t0 = Instant::now();
let step = DUR / (FRAMES as u32 - 1);
let mut cards = Vec::new();
for i in 0..FRAMES {
let mut reg = AnimRegistry::new();
// Frame de asentamiento (rojo) en t0.
{
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(red, i, "", white));
reg.reconcile(&mut m, t0);
}
// Frame de detección del cambio a azul (arranca el reloj en t0).
{
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(blue, i, "", white));
reg.reconcile(&mut m, t0);
}
// Frame de observación: el nodo `card` se reconcilia a t0 + i*paso y su
// `fill` queda con el valor interpolado. Lo dejamos para pintar.
let now = t0 + step * i as u32;
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
let mut layout = LayoutTree::new();
let mut m = mount(&mut layout, card(blue, i, &format!("{pct}%"), white));
let computed = layout.compute(m.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut m, now);
cards.push((m, computed));
// Fila de entrada: la PRIMERA aparición ARRANCA el tween (en t0), así
// que el frame de observación a `now` ve el progreso correcto. Si se
// reconciliara una sola vez en `now`, el tween arrancaría y se
// observaría en el mismo instante → siempre t=0 (invisible).
let mut reg_enter = AnimRegistry::new();
{
let mut layout = LayoutTree::new();
let mut me = mount(&mut layout, card_enter(i, "", white));
reg_enter.reconcile(&mut me, t0);
}
let mut layout = LayoutTree::new();
let mut me = mount(&mut layout, card_enter(i, &format!("{pct}%"), white));
let computed = layout.compute(me.root, (W as f32, H as f32)).expect("layout");
reg_enter.reconcile(&mut me, now);
cards.push((me, computed));
}
// Fila de SALIDA: cada columna corre el ciclo real captura→fantasma→replay.
// (1) frame VIVO en t0: se reconcilia y se captura su subescena con
// `paint_range`. (2) frame AUSENTE en t0: la key desaparece → se promueve a
// fantasma (start=t0). (3) `replay_ghosts` a t0+paso·i lo pinta con la
// opacidad decreciente sobre `ghost_scene`, que luego se compone.
let mut ts_exit = Typesetter::new();
let mut ghost_scene = vello::Scene::new();
for i in 0..FRAMES {
let mut reg = AnimRegistry::new();
// (1) Vivo: reconcilia y captura su subárbol (root = idx 0).
{
let mut layout = LayoutTree::new();
let mut mv = mount(&mut layout, card_exit(i, "", white));
let computed = layout.compute(mv.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut mv, t0);
let n = mv.nodes.len();
let mut sub = vello::Scene::new();
paint_range(&mut sub, &mv, &computed, &mut ts_exit, None, None, 0, n, Affine::IDENTITY);
reg.store_live_exit(20 + i as u64, sub, DUR, llimphi_compositor::ease_out_cubic);
}
// (2) Ausente: la key se va → fantasma con start=t0.
{
let mut layout = LayoutTree::new();
let mut empty = mount(&mut layout, card_shell(i, ROW_EXIT_Y, "", white));
layout.compute(empty.root, (W as f32, H as f32)).expect("layout");
reg.reconcile(&mut empty, t0);
}
// (3) Observación: el fantasma se reproduce al progreso `now`.
let now = t0 + step * i as u32;
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
reg.replay_ghosts(&mut ghost_scene, now, W as f32, H as f32);
// Rótulo del progreso sobre la tarjeta (fuera del fantasma, siempre nítido).
let mut layout = LayoutTree::new();
let mut lbl = mount(&mut layout, card_shell(i, ROW_EXIT_Y, &format!("{pct}%"), rgb(40, 50, 60)));
let lc = layout.compute(lbl.root, (W as f32, H as f32)).expect("layout");
// Sólo el texto (sin fill): reusa la tarjeta vacía como portador del label.
lbl.nodes[0].fill = None;
cards.push((lbl, lc));
}
// Pinta las columnas (cada una su árbol ya reconciliado) en una escena, y
// por debajo de los rótulos compone los fantasmas de la fila de salida.
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
scene.append(&ghost_scene, None);
for (m, computed) in &cards {
paint(&mut scene, m, computed, &mut ts, None, None);
}
// Volcado a PNG.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-anim"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let bg = rgb(244, 245, 248);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"anim_demo: escrito {out} ({W}x{H}) — fila 1: crossfade rojo→azul · \
fila 2: fade-in de entrada · fila 3: fade-out de salida · {FRAMES} pasos"
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,279 @@
//! Filmstrip headless de **animateContentSize** (Bloque 15 de
//! PARIDAD-FLUTTER): un card con `View::animated_size(key, dur)`
//! arranca con tamaño 80×40 y, tras el primer frame, se reasigna a
//! 320×120. Renderizamos cinco frames simulando `Instant::now()` a
//! 0/60/120/180/240 ms — los del medio muestran el tween en curso, el
//! último ya está asentado.
//!
//! Verifica que el camino `reconcile_size_anim` parcha `style.size`
//! ANTES del mount/compute, así el layout cascade ve el tamaño
//! interpolado y los siblings reflowean (acá el padre es un row con
//! `gap`; el segundo hijo se va corriendo según crece el primero).
//!
//! `cargo run -p llimphi-compositor --example animated_size_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{
measure_text_node, mount, paint, reconcile_size_anim, SizeAnimRegistry, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const FRAME_W: u32 = 360;
const FRAME_H: u32 = 200;
const NUM_FRAMES: u32 = 5;
const W: u32 = FRAME_W * NUM_FRAMES;
const H: u32 = FRAME_H;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const KEY: u64 = 1;
const DUR_MS: u64 = 200;
const FRAME_STEP_MS: u64 = 60;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Card animable cuya `target_size` se elige según el frame (frame 0 =
/// 80×40, resto = 320×120). El gap del row y el segundo hijo (un fixed
/// 60×40) garantizan que el sibling reflowee al crecer el card.
fn build_view(target_size: (f32, f32), accent: Color, fg: Color, panel: Color) -> View<()> {
let card = View::<()>::new(Style {
size: Size {
width: length(target_size.0),
height: length(target_size.1),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(accent)
.radius(12.0)
.text_aligned("animated", 14.0, panel, Alignment::Center)
.animated_size(KEY, Duration::from_millis(DUR_MS));
let companion = View::<()>::new(Style {
size: Size {
width: length(60.0_f32),
height: length(40.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(panel)
.radius(8.0)
.border(1.0, rgb(180, 184, 196))
.text_aligned("sib", 11.0, fg, Alignment::Center);
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size {
width: length(12.0_f32),
height: length(0.0_f32),
},
padding: Rect {
left: length(16.0_f32),
right: length(16.0_f32),
top: length(16.0_f32),
bottom: length(16.0_f32),
},
..Default::default()
})
.fill(rgb(245, 247, 250))
.children(vec![card, companion])
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "animated_size.png".to_string());
let theme = llimphi_theme::Theme::light();
let accent = theme.accent;
let fg = Color::from_rgba8(30, 34, 44, 255);
let panel = theme.bg_panel;
let mut reg = SizeAnimRegistry::new();
let t0 = Instant::now();
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-animated-size"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view_tex = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
// Componemos UNA scene grande con los 5 frames lado-a-lado. Cada
// frame es un sub-tree de `View` posicionado con offset horizontal
// vía translate del paint — más simple: para cada sub-scene
// renderizamos a un buffer y lo blitteamos? Lo más directo: armamos
// un root flex Row de 5 frames con un divider mínimo.
let mut frames: Vec<View<()>> = Vec::with_capacity(NUM_FRAMES as usize);
let mut ts = Typesetter::new();
for i in 0..NUM_FRAMES {
// Target size: frame 0 = 80×40 (asentado); resto = 320×120 (target nuevo).
let target_size = if i == 0 { (80.0, 40.0) } else { (320.0, 120.0) };
let mut frame_view = build_view(target_size, accent, fg, panel);
let when = t0 + Duration::from_millis(i as u64 * FRAME_STEP_MS);
// Reconcilá el size en el árbol del frame. Después del frame 0
// el registry conoce target=80×40. En el frame 1 el target nuevo
// arranca el tween; los frames 2-4 lo continúan.
let animating = reconcile_size_anim(&mut frame_view, &mut reg, when);
// Pintamos cada frame en una columna fija dentro de un row root.
// El alto fijo + width fijo hace que el rect del frame esté
// delimitado; el contenido del frame ocupa todo el alto.
let frame_box = View::<()>::new(Style {
size: Size {
width: length(FRAME_W as f32),
height: length(FRAME_H as f32),
},
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(rgb(228, 232, 240))
.children(vec![
View::<()>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
..Default::default()
})
.text_aligned(
format!(
"t = {} ms{}",
i as u64 * FRAME_STEP_MS,
if animating { " (animando)" } else { "" }
),
11.0,
fg,
Alignment::Center,
),
View::<()>::new(Style {
size: Size {
width: percent(1.0_f32),
height: length((FRAME_H - 20) as f32),
},
..Default::default()
})
.children(vec![frame_view]),
]);
frames.push(frame_box);
}
let root = View::<()>::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: length(W as f32),
height: length(H as f32),
},
..Default::default()
})
.children(frames);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view_tex, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"animated_size_demo: escrito {out} ({W}x{H}) — 5 frames del card que \
crece de 80x40 a 320x120 en 200 ms. El sibling (cuadrado 'sib') se \
corre hacia la derecha por el gap del row a medida que el card crece.",
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,248 @@
//! Filmstrip headless del **backdrop blur** (Bloque 11 de PARIDAD-FLUTTER):
//! sobre un fondo con franjas de colores fuertes, una fila de cuatro paneles
//! `.backdrop_blur(σ)` con `σ ∈ {0, 4, 8, 16}` — el primero es la referencia
//! sin blur, el resto muestra el Gauss separable cada vez más fuerte.
//!
//! Prueba el camino `View::backdrop_blur` → `collect_backdrop_blurs` →
//! `BlurCompositor::blur` (post-pasada wgpu sobre la intermediate). Render
//! headless: vello pinta a una textura, el compositor de blur la modifica
//! in-place sobre los rects de cada panel, y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example backdrop_blur_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{collect_backdrop_blurs, mount, paint, View};
use llimphi_hal::{wgpu, BlurCompositor, Hal};
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::Typesetter;
const W: u32 = 1200;
const H: u32 = 360;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const SIGMAS: [f32; 4] = [0.0, 4.0, 8.0, 16.0];
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "backdrop_blur.png".to_string());
// Fondo: cuatro franjas verticales saturadas — el blur tiene que mezclar
// los bordes entre franjas, así el efecto se ve aun sin texto/detalle.
let franjas: Vec<View<()>> = [
rgb(231, 76, 60),
rgb(241, 196, 15),
rgb(46, 204, 113),
rgb(52, 152, 219),
]
.iter()
.map(|c| {
View::<()>::new(Style {
size: Size {
width: percent(0.25),
height: percent(1.0),
},
..Default::default()
})
.fill(*c)
})
.collect();
let fondo = View::<()>::new(Style {
size: Size {
width: percent(1.0),
height: percent(1.0),
},
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: auto(),
bottom: auto(),
},
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(franjas);
// Fila de paneles "vidrio": cada uno apunta a un σ distinto. Todos
// `Position::Absolute` con inset calculado para superponerse al fondo.
// El panel es un rect translúcido sin contenido propio (el blur post-
// pasada borronea TODO lo que está dentro del rect, así que un texto
// *dentro* del panel saldría borroso — limitación documentada del v1).
let panel_w = 240.0_f32;
let panel_h = 220.0_f32;
let gap = 24.0_f32;
let fila_w = SIGMAS.len() as f32 * panel_w + (SIGMAS.len() as f32 - 1.0) * gap;
let inicio_x = (W as f32 - fila_w) * 0.5;
let panel_y = (H as f32 - panel_h) * 0.5;
let mut hijos: Vec<View<()>> = vec![fondo];
for (i, &sigma) in SIGMAS.iter().enumerate() {
let x = inicio_x + i as f32 * (panel_w + gap);
let panel = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(panel_y),
right: auto(),
bottom: auto(),
},
size: Size {
width: length(panel_w),
height: length(panel_h),
},
..Default::default()
})
.fill(rgba(255, 255, 255, 96))
.radius(20.0)
.border(1.5, rgba(255, 255, 255, 180))
.backdrop_blur(sigma);
hijos.push(panel);
}
let root = View::<()>::new(Style {
size: Size {
width: length(W as f32),
height: length(H as f32),
},
..Default::default()
})
.children(hijos);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
// Render + post-pase de blur con BlurCompositor — el mismo camino que
// toma `llimphi-ui` durante un redraw real.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut blur = BlurCompositor::new(&hal.device);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-blur"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
.expect("render_to_view");
let blurs = collect_backdrop_blurs(&mounted, &computed);
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("blur-demo-encoder"),
});
for b in &blurs {
blur.blur(
&hal.device,
&hal.queue,
&mut encoder,
&view,
(W, H),
b.rect,
b.sigma,
);
}
hal.queue.submit(std::iter::once(encoder.finish()));
write_png(&hal, &target, &out);
eprintln!(
"backdrop_blur_demo: escrito {out} ({W}x{H}) — {} paneles σ={:?}; \
σ=0 queda nítido (el compositor no-op'ea); los demás muestran el \
Gauss separable sobre las franjas. {} blur node(s) detectado(s).",
SIGMAS.len(),
SIGMAS,
blurs.len(),
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+281
View File
@@ -0,0 +1,281 @@
//! Filmstrip headless de la **familia `filter`** (Fases 7.12327.1235): sobre un
//! fondo a franjas, una fila de tiles iguales, cada uno con un `filter` distinto
//! — referencia, `blur`, `grayscale`, `invert`, `sepia` y `drop-shadow`.
//!
//! Ejercita el camino completo `View::filter` → `collect_filters` → post-pasada
//! GPU (`BlurCompositor` para `blur`, `ColorFilterCompositor` para las matrices
//! de color), más el `drop-shadow` que se pinta inline en vello (no es
//! post-pasada). Es el mismo orden que toma `llimphi-ui` en un redraw real.
//! Render headless: vello pinta a una textura, los compositores la modifican
//! in-place sobre el rect de cada tile, y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example filter_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{collect_filters, mount, paint, FilterOp, Shadow, View};
use llimphi_hal::{wgpu, BlurCompositor, ColorFilterCompositor, Hal};
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::Typesetter;
const W: u32 = 1320;
const H: u32 = 320;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
// Matriz identidad 4×5 (referencia, sin efecto).
const IDENTITY: [f32; 20] = [
1., 0., 0., 0., 0., //
0., 1., 0., 0., 0., //
0., 0., 1., 0., 0., //
0., 0., 0., 1., 0.,
];
// grayscale(1): luminancia Rec.709 en las tres filas.
const GRAYSCALE: [f32; 20] = [
0.2126, 0.7152, 0.0722, 0., 0., //
0.2126, 0.7152, 0.0722, 0., 0., //
0.2126, 0.7152, 0.0722, 0., 0., //
0., 0., 0., 1., 0.,
];
// invert(1): out = 1 - in.
const INVERT: [f32; 20] = [
-1., 0., 0., 0., 1., //
0., -1., 0., 0., 1., //
0., 0., -1., 0., 1., //
0., 0., 0., 1., 0.,
];
// sepia(1): matriz fija de la spec.
const SEPIA: [f32; 20] = [
0.393, 0.769, 0.189, 0., 0., //
0.349, 0.686, 0.168, 0., 0., //
0.272, 0.534, 0.131, 0., 0., //
0., 0., 0., 1., 0.,
];
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "filter.png".to_string());
// Etiqueta + FilterOp de cada tile. El primero es la referencia sin filtro.
let tiles: Vec<(&str, Option<FilterOp>)> = vec![
("ref", None),
("blur", Some(FilterOp::Blur(6.0))),
("grayscale", Some(FilterOp::ColorMatrix(GRAYSCALE))),
("invert", Some(FilterOp::ColorMatrix(INVERT))),
("sepia", Some(FilterOp::ColorMatrix(SEPIA))),
(
"drop-shadow",
Some(FilterOp::DropShadow(Shadow {
color: Color::from_rgba8(0, 0, 0, 160),
blur: 12.0,
dx: 8.0,
dy: 10.0,
spread: 0.0,
})),
),
];
let _ = IDENTITY; // referencia documentada arriba.
// Fondo a franjas para que el blur tenga bordes que mezclar.
let franjas: Vec<View<()>> = [
rgb(231, 76, 60),
rgb(241, 196, 15),
rgb(46, 204, 113),
rgb(52, 152, 219),
rgb(155, 89, 182),
]
.iter()
.map(|c| {
View::<()>::new(Style {
size: Size { width: percent(0.2), height: percent(1.0) },
..Default::default()
})
.fill(*c)
})
.collect();
let fondo = View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
position: Position::Absolute,
inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(franjas);
let tile_w = 180.0_f32;
let tile_h = 180.0_f32;
let gap = 24.0_f32;
let n = tiles.len() as f32;
let fila_w = n * tile_w + (n - 1.0) * gap;
let inicio_x = (W as f32 - fila_w) * 0.5;
let tile_y = (H as f32 - tile_h) * 0.5;
let mut hijos: Vec<View<()>> = vec![fondo];
for (i, (_, op)) in tiles.iter().enumerate() {
let x = inicio_x + i as f32 * (tile_w + gap);
// Cada tile: un rect blanco con un bloque interno multicolor, para que
// las matrices de color tengan algo que transformar.
let interno = View::<()>::new(Style {
size: Size { width: percent(0.7), height: percent(0.7) },
margin: Rect {
left: percent(0.15),
top: percent(0.15),
right: auto(),
bottom: auto(),
},
..Default::default()
})
.fill(rgb(255, 140, 0))
.radius(14.0);
let mut tile = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(x),
top: length(tile_y),
right: auto(),
bottom: auto(),
},
size: Size { width: length(tile_w), height: length(tile_h) },
..Default::default()
})
.fill(rgb(245, 245, 245))
.radius(18.0)
.children(vec![interno]);
if let Some(op) = op {
tile = tile.filter(vec![op.clone()]);
}
hijos.push(tile);
}
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
..Default::default()
})
.children(hijos);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout
.compute(mounted.root, (W as f32, H as f32))
.expect("layout");
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
// `paint` ya pinta los drop-shadow inline (no son post-pasada).
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut blur = BlurCompositor::new(&hal.device);
let mut color = ColorFilterCompositor::new(&hal.device);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-filter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(&hal, &scene, &view, W, H, Color::BLACK)
.expect("render_to_view");
// Post-pasadas de filtro: el mismo camino que `llimphi-ui::redraw`.
let passes = collect_filters(&mounted, &computed);
let mut encoder = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("filter-demo") });
for p in &passes {
match &p.op {
FilterOp::Blur(sigma) => {
blur.blur(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *sigma);
}
FilterOp::ColorMatrix(m) => {
color.apply(&hal.device, &hal.queue, &mut encoder, &view, (W, H), p.rect, *m);
}
FilterOp::DropShadow(_) => {} // ya pintado por vello en `paint`.
}
}
hal.queue.submit(std::iter::once(encoder.finish()));
write_png(&hal, &target, &out);
eprintln!(
"filter_demo: escrito {out} ({W}x{H}) — tiles {:?}; {} post-pasada(s) de \
filtro (blur+color), drop-shadow pintado inline. ref queda sin tocar.",
tiles.iter().map(|(l, _)| *l).collect::<Vec<_>>(),
passes.len(),
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,264 @@
//! Demo headless de los dos primitivos GPU nuevos de Llimphi:
//!
//! - **Primitivo A** — disco/círculo relleno con AA por SDF en el shader
//! (`GpuBatch::add_disc` / `add_ring` en `llimphi-raster`). Esta demo
//! pinta por GPU directo una grilla de *rects instanciados* + una grilla
//! de *discos AA* sobre la misma pasada `GpuBatch::flush`.
//! - **Primitivo B** — over-layer: una escena vello que se rasteriza
//! DESPUÉS del pase GPU y se compone con alpha encima (un disco vello
//! grande + el rótulo "OVER" que deben quedar SOBRE los rects/discos
//! GPU). Replica exactamente el orden que el eventloop de `llimphi-ui`
//! aplica para `View::paint_over`: `[vello base] → [GPU] → [vello over]`.
//!
//! No abre ventana: compone sobre una textura intermedia `Rgba8Unorm`
//! (misma mecánica que el frame real) y vuelca el resultado a PNG.
//!
//! `cargo run -p llimphi-compositor --example gpu_primitivos_demo --release -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_hal::{wgpu, Hal, OverlayCompositor};
use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
use llimphi_raster::peniko::{Color, Fill};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{draw_block, TextBlock, Typesetter};
use vello::kurbo::{Affine, Circle};
const W: u32 = 720;
const H: u32 = 480;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "gpu_primitivos_demo.png".to_string());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let pipelines = GpuPipelines::new(&hal.device, FMT);
let overlay = OverlayCompositor::new(&hal.device);
// ── Textura intermedia (donde se compone todo) ──────────────────────
let inter = make_tex(
&hal.device,
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
);
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// ── (1) vello base: fondo + rótulo "base vello" ─────────────────────
// render_to_view limpia con base_color y escribe todos los píxeles.
let mut base = vello::Scene::new();
let mut ts = Typesetter::new();
draw_label(
&mut base,
&mut ts,
16.0,
24.0,
"base vello (fondo)",
18.0,
Color::from_rgba8(120, 130, 150, 255),
);
renderer
.render_to_view(
&hal,
&base,
&inter_view,
W,
H,
Color::from_rgba8(16, 20, 30, 255),
)
.expect("render base");
// ── (2) pase GPU directo: grilla de rects + grilla de discos AA ─────
// Un solo GpuBatch → un flush con LoadOp::Load (preserva el fondo vello).
let mut batch = GpuBatch::new(&pipelines);
// Grilla de rects instanciados (mitad izquierda).
for j in 0..6 {
for i in 0..6 {
let x = 40.0 + i as f32 * 46.0;
let y = 70.0 + j as f32 * 46.0;
let c = Color::from_rgba8(
60 + (i * 30) as u8,
90 + (j * 24) as u8,
200,
255,
);
batch.add_rect(x, y, 36.0, 36.0, c);
}
}
// Grilla de discos AA (mitad derecha) — radios variables para ver el
// suavizado del borde a distintas escalas.
for j in 0..6 {
for i in 0..6 {
let cx = 400.0 + i as f32 * 46.0;
let cy = 88.0 + j as f32 * 46.0;
let r = 8.0 + (i + j) as f32 * 1.4;
let c = Color::from_rgba8(
240,
120 + (i * 18) as u8,
60 + (j * 24) as u8,
255,
);
batch.add_disc(cx, cy, r, c);
}
}
// Un anillo grande para ejercitar add_ring (borde interno + externo AA).
batch.add_ring(180.0, 380.0, 46.0, 10.0, Color::from_rgba8(120, 240, 200, 255));
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("gpu-prim-pass"),
});
batch.flush(
&hal.device,
&hal.queue,
&mut enc,
&inter_view,
(W as f32, H as f32),
wgpu::LoadOp::Load,
);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
// ── (3) over-layer vello: disco grande + rótulo "OVER" ──────────────
// Se rasteriza en una scratch transparente y se compone con alpha
// sobre la intermedia DESPUÉS del pase GPU → queda ENCIMA de los
// rects/discos GPU. Espejo exacto del camino de redraw.rs.
let scratch = make_tex(
&hal.device,
wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
);
let scratch_view = scratch.create_view(&wgpu::TextureViewDescriptor::default());
let mut over = vello::Scene::new();
// Disco vello grande y semitransparente que se monta SOBRE la grilla
// GPU (su centro cae sobre rects y discos a la vez).
over.fill(
Fill::NonZero,
Affine::IDENTITY,
Color::from_rgba8(255, 60, 120, 200),
None,
&Circle::new((300.0, 230.0), 70.0),
);
draw_label(
&mut over,
&mut ts,
232.0,
222.0,
"OVER",
30.0,
Color::from_rgba8(255, 255, 255, 255),
);
renderer
.render_to_view(&hal, &over, &scratch_view, W, H, Color::TRANSPARENT)
.expect("render over");
let mut enc2 = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("over-composite"),
});
overlay.composite(&hal.device, &mut enc2, &inter_view, &scratch_view);
hal.queue.submit(std::iter::once(enc2.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
// ── (4) readback → PNG ──────────────────────────────────────────────
write_png(&hal, &inter, &out);
eprintln!("gpu_primitivos_demo: escrito {out} ({W}x{H})");
}
fn make_tex(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("gpu-prim-tex"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage,
view_formats: &[],
})
}
fn draw_label(
scene: &mut vello::Scene,
ts: &mut Typesetter,
x: f32,
y: f32,
text: &str,
size: f32,
color: Color,
) {
// Reusa el typesetter: layout de una línea y blit de glyphs a la escena.
let block = TextBlock::simple(text, size, color, (x as f64, y as f64));
draw_block(scene, ts, &block);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,288 @@
//! Filmstrip headless de **`ImageFit`** (Bloque 12 de PARIDAD-FLUTTER):
//! una misma imagen 4:3 sintética se compone en cinco rects 1:1 con
//! `ImageFit::{Contain, Cover, Fill, None}` y, al final, un círculo
//! redondeado al máximo con `ImageFit::Cover` para verificar que el
//! clip respeta `radius` / `corner_radii` (caso avatar).
//!
//! Prueba el camino `View::image` + `View::image_fit` → `paint` (pasada
//! de `node.image_fit` y `node_rrect` para el clip). Render headless:
//! vello pinta a una textura y volcamos a PNG.
//!
//! `cargo run -p llimphi-compositor --example image_fit_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::sync::Arc;
use llimphi_compositor::{measure_text_node, mount, paint, ImageFit, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{
Blob, Color, ImageAlphaType, ImageBrush as Image, ImageData, ImageFormat,
};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const W: u32 = 1500;
const H: u32 = 380;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Imagen sintética 4:3 (`240×180`): cuadrícula de 4×3 con colores
/// distintos por celda + cruz central blanca. Permite "ver" la
/// diferencia entre los fits sin embeber un archivo (`Contain` deja
/// banda, `Cover` recorta, `Fill` deforma la cruz, `None` clippea las
/// celdas externas).
fn make_image() -> Image {
const IW: u32 = 240;
const IH: u32 = 180;
const COLS: u32 = 4;
const ROWS: u32 = 3;
let palette: [[u8; 3]; 12] = [
[231, 76, 60], [241, 196, 15], [46, 204, 113], [52, 152, 219],
[155, 89, 182], [26, 188, 156], [230, 126, 34], [149, 165, 166],
[192, 57, 43], [243, 156, 18], [22, 160, 133], [41, 128, 185],
];
let mut px: Vec<u8> = Vec::with_capacity((IW * IH * 4) as usize);
let cw = IW / COLS;
let ch = IH / ROWS;
for y in 0..IH {
for x in 0..IW {
let col = (x / cw).min(COLS - 1);
let row = (y / ch).min(ROWS - 1);
let idx = (row * COLS + col) as usize;
// Cruz central blanca, ~8 px de grosor — la deformación de
// `Fill` se hace evidente cuando los brazos cambian de razón.
let mid_x = (x as i32 - IW as i32 / 2).abs() <= 4;
let mid_y = (y as i32 - IH as i32 / 2).abs() <= 4;
let [r, g, b] = if mid_x || mid_y {
[255, 255, 255]
} else {
palette[idx]
};
px.extend_from_slice(&[r, g, b, 255]);
}
}
Image::new(ImageData {
data: Blob::new(Arc::new(px)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: IW,
height: IH,
})
}
/// Una "ficha" con la imagen arriba (cuadrada de 200×200) + un rótulo
/// abajo con el nombre del fit. Cuerpo blanco con borde sutil.
fn ficha(img: &Image, fit: ImageFit, label: &str, panel: Color, fg: Color) -> View<()> {
let visor = View::<()>::new(Style {
size: Size { width: length(200.0_f32), height: length(200.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(30, 34, 44, 255)) // fondo gris para que `Contain` deje banda visible
.radius(8.0)
.border(1.0, Color::from_rgba8(60, 66, 80, 255))
.image(img.clone())
.image_fit(fit);
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(14.0)
.border(1.0, Color::from_rgba8(220, 224, 232, 255))
.children(vec![
visor,
View::<()>::new(Style {
size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(label.to_string(), 14.0, fg, Alignment::Center),
])
}
/// Ficha avatar: imagen rectangular 4:3 metida en un cuadrado con
/// radio máximo (= círculo) y `Cover`. Verifica que el clip respeta el
/// `node_rrect` (corona el caso que rompía antes del Bloque 12).
fn avatar(img: &Image, panel: Color, fg: Color) -> View<()> {
let crc = View::<()>::new(Style {
size: Size { width: length(200.0_f32), height: length(200.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(30, 34, 44, 255))
.radius(100.0) // círculo completo
.border(2.0, Color::from_rgba8(60, 66, 80, 255))
.image(img.clone())
.image_fit(ImageFit::Cover);
View::<()>::new(Style {
size: Size { width: length(220.0_f32), height: length(260.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.0_f32),
top: length(10.0_f32),
bottom: length(10.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(14.0)
.border(1.0, Color::from_rgba8(220, 224, 232, 255))
.children(vec![
crc,
View::<()>::new(Style {
size: Size { width: percent(0.95_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Cover + radio".to_string(), 14.0, fg, Alignment::Center),
])
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "image_fit.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let fg = Color::from_rgba8(30, 34, 44, 255);
let img = make_image();
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(20.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![
ficha(&img, ImageFit::Contain, "Contain", panel, fg),
ficha(&img, ImageFit::Cover, "Cover", panel, fg),
ficha(&img, ImageFit::Fill, "Fill", panel, fg),
ficha(&img, ImageFit::None, "None", panel, fg),
avatar(&img, panel, fg),
]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-image-fit"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer
.render_to_view(&hal, &scene, &view, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"image_fit_demo: escrito {out} ({W}x{H}) — 5 fichas: Contain (deja \
banda en el eje extra) · Cover (recorta el sobrante) · Fill (deforma \
la cruz) · None (1:1 centrada, recorta lo que no entra) · Cover sobre \
un cuadrado con radius=100 (avatar circular)."
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,243 @@
//! Demo headless del **LayoutBuilder** (Bloque 9 de PARIDAD-FLUTTER): el MISMO
//! árbol declarativo, renderizado a dos anchos de viewport. Un panel central
//! usa `View::layout_builder`: si su slot es **angosto** apila las tarjetas en
//! **1 columna**; si es **ancho**, en **2 columnas**. La decisión depende del
//! tamaño del slot (no de la ventana), resuelto en dos pasadas — exactamente lo
//! que el runtime hace por frame.
//!
//! Emula el camino del runtime (`resolve_layout_builders`) con las funciones
//! puras del compositor: `has_layout_builder` → mount pasada 1 → compute →
//! `collect_builder_constraints` → `expand_layout_builders` → mount/paint.
//!
//! Vuelca dos PNGs (`<base>-angosto.png` y `<base>-ancho.png`).
//! `cargo run -p llimphi-compositor --example layout_builder_demo -- [base]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{
collect_builder_constraints, expand_layout_builders, has_layout_builder, mount, paint,
Constraints, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const H: u32 = 360;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Bajo este ancho de slot, el panel apila en 1 columna; por encima, 2.
const BREAKPOINT: f32 = 360.0;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Una tarjeta de muestra.
fn card(label: &str) -> View<()> {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(64.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(rgb(60, 72, 100))
.radius(10.0)
.children(vec![View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(20.0) },
..Default::default()
})
.text_aligned(label.to_string(), 14.0, rgb(235, 238, 245), Alignment::Center)])
}
/// El subárbol que el builder produce según sus constraints: 1 columna si
/// angosto, 2 si ancho. Cada columna es un flex column con tarjetas.
fn responsive_panel(c: Constraints) -> View<()> {
let dos_columnas = c.max_width >= BREAKPOINT;
let etiqueta = if dos_columnas {
format!("slot {:.0}px = 2 columnas", c.max_width)
} else {
format!("slot {:.0}px = 1 columna", c.max_width)
};
let header = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(28.0) },
..Default::default()
})
.text_aligned(etiqueta, 13.0, rgb(150, 200, 160), Alignment::Center);
let col = |labels: &[&str]| {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(10.0) },
..Default::default()
})
.children(labels.iter().map(|l| card(l)).collect())
};
let cuerpo = if dos_columnas {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(12.0), height: length(0.0) },
..Default::default()
})
.children(vec![col(&["Uno", "Tres"]), col(&["Dos", "Cuatro"])])
} else {
col(&["Uno", "Dos", "Tres", "Cuatro"])
};
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(8.0) },
..Default::default()
})
.children(vec![header, cuerpo])
}
/// Árbol raíz: una sidebar fija + un panel central que es el `layout_builder`.
/// El ancho del slot del panel = viewport sidebar paddings, así cambia con
/// el viewport sin que el árbol "sepa" el tamaño al construirse.
fn root() -> View<()> {
let sidebar = View::<()>::new(Style {
size: Size { width: length(160.0), height: percent(1.0) },
..Default::default()
})
.fill(rgb(34, 40, 54))
.radius(12.0)
.children(vec![View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(20.0) },
..Default::default()
})
.text_aligned("sidebar", 13.0, rgb(140, 150, 170), Alignment::Center)]);
let panel = View::<()>::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0), height: percent(1.0) },
..Default::default()
})
.layout_builder(responsive_panel);
View::<()>::new(Style {
size: Size { width: percent(1.0), height: percent(1.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(16.0), height: length(0.0) },
padding: Rect {
left: LengthPercentage::length(16.0),
right: LengthPercentage::length(16.0),
top: LengthPercentage::length(16.0),
bottom: LengthPercentage::length(16.0),
},
..Default::default()
})
.fill(rgb(24, 28, 38))
.children(vec![sidebar, panel])
}
/// Resuelve los builders (dos pasadas) y vuelca el árbol a un PNG a ese ancho.
fn render_a(ancho: u32, ts: &mut Typesetter, hal: &Hal, renderer: &mut Renderer, path: &str) {
let viewport = (ancho as f32, H as f32);
// Pasada 1: montar (builders como hojas) + computar.
let v1 = root();
assert!(has_layout_builder(&v1), "el demo debe tener un layout_builder");
let mut l1 = LayoutTree::new();
let m1 = mount(&mut l1, v1);
let c1 = l1.compute(m1.root, viewport).expect("layout p1");
let cons = collect_builder_constraints(&m1, &c1);
// Pasada 2: árbol fresco + expand con las constraints reales.
let resolved = expand_layout_builders(root(), &cons);
let mut l2 = LayoutTree::new();
let m2 = mount(&mut l2, resolved);
let c2 = l2.compute(m2.root, viewport).expect("layout p2");
let mut scene = vello::Scene::new();
paint(&mut scene, &m2, &c2, ts, None, None);
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-lb"),
size: wgpu::Extent3d { width: ancho, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &scene, &view, ancho, H, rgb(244, 245, 248))
.expect("render_to_view");
write_png(hal, &target, ancho, path);
eprintln!("layout_builder_demo: escrito {path} ({ancho}x{H}) — slot panel {:.0}px", cons[0].max_width);
}
fn main() {
let base = std::env::args().nth(1).unwrap_or_else(|| "lb".to_string());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut ts = Typesetter::new();
// Angosto: viewport 460 → slot ~268px (<360) → 1 columna.
render_a(460, &mut ts, &hal, &mut renderer, &format!("{base}-angosto.png"));
// Ancho: viewport 760 → slot ~568px (≥360) → 2 columnas.
render_a(760, &mut ts, &hal, &mut renderer, &format!("{base}-ancho.png"));
}
fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, path: &str) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: w, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,697 @@
//! Pantallazo headless del motor — **una UI real y densa** compuesta sólo con
//! primitivas del compositor (View → layout → vello → wgpu → PNG), pensada
//! para la tarjeta pública "Un motor gráfico soberano".
//!
//! Muestra en una sola pasada: tema oscuro de `llimphi-theme`, top bar con
//! tabs, sidebar con filas seleccionadas, un editor de código con resaltado
//! sintáctico vía `TextSpan`s sobre fuente mono, un párrafo de texto rico
//! (pesos, cursiva, subrayado, mono inline), tarjetas de métricas con
//! gradientes y sombras, un mini gráfico de barras hecho con puros rects,
//! chips/botones, y un toast flotante con esquinas asimétricas.
//!
//! `cargo run -p llimphi-compositor --example pantallazo_motor --release -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{auto, length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Position, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter};
use vello::kurbo::Point;
const W: u32 = 1280;
const H: u32 = 800;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
/// Caja vacía con tamaño fijo — separadores, swatches, barras.
fn rect(w: f32, h: f32) -> View<()> {
View::<()>::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() })
}
/// Nodo de texto de una línea con alto fijo (mismo patrón que los demos vecinos).
fn txt(w: taffy::Dimension, h: f32, s: &str, size: f32, c: Color) -> View<()> {
View::<()>::new(Style { size: Size { width: w, height: length(h) }, ..Default::default() })
.text_aligned(s.to_string(), size, c, Alignment::Start)
}
/// Spans sintácticos: pinta TODAS las ocurrencias de cada `needle` con su estilo.
fn spans_all(text: &str, reglas: &[(&str, TextSpanStyle)]) -> Vec<TextSpan> {
let mut out = Vec::new();
for (needle, style) in reglas {
for (i, _) in text.match_indices(needle) {
out.push(TextSpan::new(i, i + needle.len(), style.clone()));
}
}
out
}
fn color_span(c: Color) -> TextSpanStyle {
TextSpanStyle { color: Some(c), ..Default::default() }
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "pantallazo_motor.png".to_string());
// Default dark (el look histórico de esta tarjeta); `LLIMPHI_THEME=<nombre>`
// fuerza otro preset por nombre canónico (p. ej. `Tawa`) para evidenciar la
// paleta firma sin alterar el default del pipeline público.
let theme = std::env::var("LLIMPHI_THEME")
.ok()
.and_then(|n| llimphi_theme::Theme::by_name(&n))
.unwrap_or_else(llimphi_theme::Theme::dark);
// Paleta de sintaxis (sobre el panel oscuro del theme).
let kw = rgb(198, 120, 221); // keywords — violeta
let ty = rgb(229, 192, 123); // tipos — ámbar
let fnc = rgb(97, 175, 239); // funciones — azul
let strv = rgb(152, 195, 121); // strings — verde
let cmt = rgb(92, 104, 124); // comentarios — gris azulado
let lit = rgb(209, 154, 102); // literales numéricos — naranja
let code_fg = rgb(171, 178, 191);
// ───────────────────────────── top bar ─────────────────────────────
let tab = |name: &str, activo: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(30.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(8.0);
let fg = if activo { theme.fg_text } else { theme.fg_muted };
let v = if activo { base.fill(theme.bg_selected) } else { base };
v.children(vec![txt(auto(), 18.0, name, 13.0, fg)])
};
let brand_dot = rect(10.0, 10.0)
.radius(5.0)
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([theme.accent, rgb(80, 200, 200)].as_slice()),
);
let buscador = View::<()>::new(Style {
size: Size { width: length(230.0), height: length(28.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(12.0), right: length(12.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_input)
.radius(14.0)
.border(1.0, theme.border)
.children(vec![txt(auto(), 16.0, "buscar en el haz… ⌘K", 12.0, theme.fg_placeholder)]);
let topbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(46.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(10.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.border(1.0, theme.border)
.children(vec![
brand_dot,
txt(auto(), 18.0, "llimphi", 15.0, theme.fg_text).bold(),
rect(14.0, 1.0),
tab("pluma", true),
tab("khipu", false),
tab("cosmos", false),
tab("shuma", false),
// empuja el buscador a la derecha
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
buscador,
]);
// ───────────────────────────── sidebar ─────────────────────────────
let fila = |nombre: &str, badge: Option<&str>, sel: bool, dot: Color| {
let base = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
padding: Rect { left: length(10.0), right: length(10.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(7.0);
let v = if sel { base.fill(theme.bg_selected) } else { base };
let izq = View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![
rect(8.0, 8.0).radius(2.5).fill(dot),
txt(length(135.0), 17.0, nombre, 13.0, if sel { theme.fg_text } else { theme.fg_muted }),
]);
let mut hijos = vec![izq];
if let Some(b) = badge {
hijos.push(
View::<()>::new(Style {
size: Size { width: auto(), height: length(17.0) },
align_items: Some(AlignItems::Center),
padding: Rect { left: length(7.0), right: length(7.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_button)
.radius(8.5)
.children(vec![txt(auto(), 13.0, b, 10.5, theme.fg_muted)]),
);
}
v.children(hijos)
};
let seccion = |t: &str| txt(percent(1.0), 16.0, t, 11.0, theme.fg_placeholder).bold();
let sidebar = View::<()>::new(Style {
size: Size { width: length(236.0), height: percent(1.0) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(4.0) },
padding: Rect { left: length(12.0), right: length(12.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.border(1.0, theme.border)
.children(vec![
seccion("HAZ DE CUERPOS"),
fila("ensayo · español", None, true, theme.accent),
fila("ensayo · english", Some("stale"), false, rgb(209, 154, 102)),
fila("ensayo · runasimi", None, false, rgb(80, 200, 200)),
fila("resumen ejecutivo", None, false, rgb(152, 195, 121)),
rect(1.0, 10.0),
seccion("MÓDULOS"),
fila("nodegraph.rs", Some("12"), false, fnc),
fila("text_editor.rs", Some("3"), false, fnc),
fila("typesetter.rs", None, false, fnc),
fila("raster/scene.rs", None, false, fnc),
rect(1.0, 10.0),
seccion("DAEMONS"),
fila("verbo · e5-small", Some("384d"), false, rgb(152, 195, 121)),
fila("chasqui · DHT", Some("9"), false, rgb(152, 195, 121)),
]);
// ─────────────────────── editor de código (centro) ───────────────────────
// (líneas unidas a mano — la continuación `\` de Rust se comería la indentación)
let codigo = [
"// bucle Elm del motor: input → update → view → layout → raster",
"pub fn frame(&mut self, msg: Msg) -> Scene {",
" self.app.update(msg);",
" let view = self.app.view();",
" let tree = mount(&mut self.layout, view);",
" let computed = self.layout.compute(tree.root, self.size);",
" let mut scene = Scene::new();",
" paint(&mut scene, &tree, &computed, &mut self.ts);",
" scene // vello la rasteriza en GPU vía wgpu",
"}",
"",
"let sombra = Shadow::soft(90, 24.0).offset(0.0, 12.0);",
"let card = View::new(estilo)",
" .fill_gradient(grad) // gradiente en [0,1]²",
" .radius_corners(18.0, 18.0, 4.0, 4.0)",
" .shadow(sombra);",
]
.join("\n");
let codigo = codigo.as_str();
let reglas = [
("// bucle Elm del motor: input → update → view → layout → raster", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("// vello la rasteriza en GPU vía wgpu", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("// gradiente en [0,1]²", TextSpanStyle { color: Some(cmt), italic: Some(true), ..Default::default() }),
("pub fn ", color_span(kw)),
("let ", color_span(kw)),
("&mut ", color_span(kw)),
("self", color_span(rgb(224, 108, 117))),
("Msg", color_span(ty)),
("Scene", color_span(ty)),
("Shadow", color_span(ty)),
("View", color_span(ty)),
("frame", TextSpanStyle { color: Some(fnc), weight: Some(700.0), ..Default::default() }),
("update", color_span(fnc)),
("view()", color_span(fnc)),
("mount", color_span(fnc)),
("compute", color_span(fnc)),
("new", color_span(fnc)),
("paint", color_span(fnc)),
("soft", color_span(fnc)),
("offset", color_span(fnc)),
("fill_gradient", color_span(fnc)),
("radius_corners", color_span(fnc)),
("shadow(", color_span(fnc)),
("90, 24.0", color_span(lit)),
("0.0, 12.0", color_span(lit)),
("18.0, 18.0, 4.0, 4.0", color_span(lit)),
];
let code_spans = spans_all(codigo, &reglas);
let code_text = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(360.0) },
..Default::default()
})
.text_spans(codigo, 13.5, code_fg, code_spans, Alignment::Start)
.mono()
.line_height(1.55);
// header del editor: tres puntos + nombre de archivo + chip de lenguaje
let punto = |c: Color| rect(11.0, 11.0).radius(5.5).fill(c);
let editor_header = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(36.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.radius_corners(12.0, 12.0, 0.0, 0.0)
.children(vec![
punto(rgb(224, 108, 117)),
punto(rgb(229, 192, 123)),
punto(rgb(152, 195, 121)),
rect(6.0, 1.0),
txt(auto(), 17.0, "eventloop.rs", 13.0, theme.fg_text).mono(),
txt(length(96.0), 16.0, "— llimphi-ui", 12.0, theme.fg_placeholder),
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
txt(auto(), 16.0, "rust", 11.0, theme.accent).mono(),
]);
let editor = View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.shadow(Shadow::soft(110, 26.0).offset(0.0, 12.0))
.children(vec![
editor_header,
View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
padding: Rect { left: length(16.0), right: length(16.0), top: length(12.0), bottom: length(8.0) },
..Default::default()
})
.children(vec![code_text]),
]);
// ─────────────────── párrafo rico (debajo del editor) ───────────────────
let parrafo = "Un solo nodo de texto, varios lentes por rango de bytes: \
NEGRITA para el énfasis, cursiva para la voz, un enlace.qu subrayado, \
texto tachado para lo descartado, y Typesetter en mono inline — todo \
medido y pintado por el mismo layout_spans, sin HTML ni DOM.";
let find = |n: &str| {
let i = parrafo.find(n).expect("needle");
(i, i + n.len())
};
let (b0, b1) = find("NEGRITA");
let (i0, i1) = find("cursiva");
let (l0, l1) = find("enlace.qu");
let (t0, t1) = find("tachado");
let (m0, m1) = find("Typesetter");
let rich_spans = vec![
TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), color: Some(theme.fg_text), ..Default::default() }),
TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), color: Some(theme.fg_text), ..Default::default() }),
TextSpan::new(l0, l1, TextSpanStyle { color: Some(theme.accent), underline: Some(true), ..Default::default() }),
TextSpan::new(t0, t1, TextSpanStyle { color: Some(theme.fg_destructive), strikethrough: Some(true), ..Default::default() }),
TextSpan::new(m0, m1, TextSpanStyle { font_family: Some(llimphi_text::MONOSPACE.to_string()), color: Some(rgb(80, 200, 200)), ..Default::default() }),
];
let rich_card = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(150.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(8.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
txt(percent(1.0), 20.0, "Texto rico — spans nativos", 14.5, theme.fg_text).bold(),
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(78.0) },
..Default::default()
})
.text_spans(parrafo, 13.5, theme.fg_muted, rich_spans, Alignment::Start)
.line_height(1.45),
]);
let centro = View::<()>::new(Style {
size: Size { width: auto(), height: percent(1.0) },
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(14.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.children(vec![editor, rich_card]);
// ───────────────────────── columna derecha ─────────────────────────
// 1) Tarjeta de métricas con gradiente + sombra (el look "hero card").
let grad_hero = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([rgb(64, 92, 180), rgb(34, 46, 96)].as_slice());
let metrica = |valor: &str, label: &str| {
View::<()>::new(Style {
size: Size { width: length(80.0), height: auto() },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(2.0) },
..Default::default()
})
.children(vec![
txt(length(80.0), 26.0, valor, 21.0, rgb(240, 244, 252)).bold(),
txt(length(80.0), 15.0, label, 11.0, rgba(214, 222, 240, 190)),
])
};
let hero = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(120.0) },
flex_direction: FlexDirection::Column,
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0), height: length(10.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(12.0), bottom: length(12.0) },
..Default::default()
})
.fill_gradient(grad_hero)
.radius(14.0)
.border(1.0, rgba(150, 175, 240, 120))
.shadow(Shadow::soft(120, 28.0).offset(0.0, 14.0))
.children(vec![
txt(percent(1.0), 16.0, "RENDER · ÚLTIMO FRAME", 11.0, rgba(214, 222, 240, 200)).bold(),
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
gap: Size { width: length(16.0), height: length(0.0) },
..Default::default()
})
.children(vec![metrica("1.8 ms", "scene → GPU"), metrica("2 411", "nodos"), metrica("60 fps", "vsync")]),
]);
// 2) Mini gráfico de barras: puros rects con gradiente, alineados al piso.
let alturas = [34.0_f32, 52.0, 41.0, 66.0, 58.0, 78.0, 49.0, 88.0, 71.0, 60.0, 94.0, 80.0];
let barras: Vec<View<()>> = alturas
.iter()
.enumerate()
.map(|(i, &h)| {
let g = if i == 10 {
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(120, 220, 200), rgb(60, 150, 140)].as_slice())
} else {
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(110, 140, 220), rgb(58, 78, 128)].as_slice())
};
rect(15.0, h).radius_corners(4.0, 4.0, 0.0, 0.0).fill_gradient(g)
})
.collect();
let chart = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(168.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(10.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(18.0) },
flex_direction: FlexDirection::Row,
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(vec![
txt(length(200.0), 18.0, "Throughput del raster", 13.0, theme.fg_text).bold(),
View::<()>::new(Style {
size: Size { width: length(70.0), height: length(16.0) },
..Default::default()
})
.text_aligned("12 frames".to_string(), 11.0, theme.fg_placeholder, Alignment::End),
]),
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(94.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::FlexEnd),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(barras),
]);
// 3) Botones / chips — el acento del theme en acción.
let boton = |label: &str, primario: bool| {
let base = View::<()>::new(Style {
size: Size { width: auto(), height: length(34.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
padding: Rect { left: length(18.0), right: length(18.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.radius(17.0);
if primario {
base.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([rgb(124, 154, 232), rgb(92, 120, 198)].as_slice()),
)
.shadow(Shadow::soft(90, 16.0).offset(0.0, 6.0))
.children(vec![txt(auto(), 18.0, label, 13.0, rgb(244, 247, 255)).bold()])
} else {
base.fill(theme.bg_button)
.border(1.0, theme.border)
.children(vec![txt(auto(), 18.0, label, 13.0, theme.fg_text)])
}
};
let acciones = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(34.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(10.0), height: length(0.0) },
..Default::default()
})
.children(vec![boton("Regenerar", true), boton("Difundir", false), boton("Alinear", false)]);
// 4) Tarjeta de hebras (estado del haz multilienzo) — filas con dot de estado.
let hebra = |de: &str, a: &str, estado: &str, c: Color| {
View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
..Default::default()
})
.children(vec![
View::<()>::new(Style {
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(8.0), height: length(0.0) },
..Default::default()
})
.children(vec![
rect(7.0, 7.0).radius(3.5).fill(c),
txt(length(170.0), 16.0, &format!("{de}{a}"), 12.0, theme.fg_text),
]),
View::<()>::new(Style {
size: Size { width: length(80.0), height: length(15.0) },
..Default::default()
})
.text_aligned(estado.to_string(), 11.0, c, Alignment::End),
])
};
let hebras = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(148.0) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(6.0) },
padding: Rect { left: length(18.0), right: length(18.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.fill(theme.bg_panel)
.radius(12.0)
.border(1.0, theme.border)
.children(vec![
txt(percent(1.0), 18.0, "Hebras del haz", 13.0, theme.fg_text).bold(),
hebra("español", "english", "stale", rgb(229, 192, 123)),
hebra("español", "runasimi", "al día", rgb(152, 195, 121)),
hebra("español", "resumen", "al día", rgb(152, 195, 121)),
hebra("english", "tono formal", "derivando…", theme.accent),
]);
let derecha = View::<()>::new(Style {
size: Size { width: length(330.0), height: percent(1.0) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(14.0) },
padding: Rect { left: length(0.0), right: length(14.0), top: length(14.0), bottom: length(14.0) },
..Default::default()
})
.children(vec![hero, chart, acciones, hebras]);
// ───────────────────────── status bar ─────────────────────────
let status_item = |w: f32, s: &str, c: Color| txt(length(w), 16.0, s, 11.5, c);
let statusbar = View::<()>::new(Style {
size: Size { width: percent(1.0), height: length(30.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(18.0), height: length(0.0) },
padding: Rect { left: length(16.0), right: length(16.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(theme.bg_panel_alt)
.border(1.0, theme.border)
.children(vec![
status_item(70.0, "git · main", theme.accent),
status_item(250.0, "wgpu 27 · vello 0.7 · taffy · parley", theme.fg_muted),
status_item(80.0, "BLAKE3 ok", rgb(152, 195, 121)),
View::<()>::new(Style { flex_grow: 1.0, ..Default::default() }),
status_item(45.0, "UTF-8", theme.fg_placeholder),
status_item(85.0, "Ln 7, Col 23", theme.fg_placeholder),
status_item(50.0, "100 Hz", theme.fg_placeholder),
]);
// ─────────────── toast flotante (absoluto, esquinas asimétricas) ───────────────
let toast = View::<()>::new(Style {
position: Position::Absolute,
inset: Rect { left: auto(), top: auto(), right: length(360.0), bottom: length(48.0) },
size: Size { width: length(290.0), height: length(64.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(12.0), height: length(0.0) },
padding: Rect { left: length(14.0), right: length(14.0), top: length(0.0), bottom: length(0.0) },
..Default::default()
})
.fill(rgb(28, 34, 48))
.radius_corners(18.0, 18.0, 18.0, 4.0)
.border(1.0, rgba(110, 140, 220, 160))
.shadow(Shadow::soft(150, 30.0).offset(0.0, 14.0))
.children(vec![
rect(34.0, 34.0)
.radius(10.0)
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0))
.with_stops([rgb(120, 220, 200), rgb(60, 140, 170)].as_slice()),
),
View::<()>::new(Style {
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(2.0) },
..Default::default()
})
.children(vec![
txt(auto(), 17.0, "Cuerpo regenerado", 13.0, theme.fg_text).bold(),
txt(auto(), 15.0, "english · 42 átomos realineados", 11.5, theme.fg_muted),
]),
]);
// ───────────────────────── árbol raíz ─────────────────────────
let fila_central = View::<()>::new(Style {
size: Size { width: percent(1.0), height: auto() },
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![sidebar, centro, derecha]);
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(theme.bg_app)
.children(vec![topbar, fila_central, statusbar, toast]);
// view → layout → scene → render headless → PNG (misma secuencia que el eventloop).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("pantallazo-motor"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("pantallazo_motor: escrito {out} ({W}x{H})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,273 @@
//! Volcado headless de las primitivas nuevas del compositor (Tier 1+2 del
//! roadmap PARIDAD-FLUTTER): **sombra · gradiente · borde · peso de fuente ·
//! radio por esquina**. Monta un árbol `View` con tarjetas que ejercitan cada
//! una (y su combinación), lo pinta a una `vello::Scene` y lee la textura a
//! PNG. Sirve para VERLAS sin ventana.
//!
//! `cargo run -p llimphi-compositor --example primitivas_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, Shadow, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
use vello::kurbo::Point;
const W: u32 = 1476;
const H: u32 = 340;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Una tarjeta con título + descripción, dimensionada igual para todas.
fn card(build: impl FnOnce(View<()>) -> View<()>, title: &str, fg: Color) -> View<()> {
let base = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(8.0_f32) },
..Default::default()
})
.radius(16.0);
build(base).children(vec![View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(22.0_f32) },
..Default::default()
})
.text_aligned(title.to_string(), 16.0, fg, Alignment::Center)])
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "primitivas.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let dark = Color::from_rgba8(30, 34, 44, 255);
let white = Color::from_rgba8(248, 248, 250, 255);
// 1) Sombra: fill plano + elevación suave.
let sombra = card(
|v| v.fill(panel).shadow(Shadow::soft(70, 22.0).offset(0.0, 10.0)),
"Sombra",
dark,
);
// 2) Gradiente: relleno vertical claro→oscuro (espacio unidad [0,1]²).
let grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0)).with_stops(
[Color::from_rgba8(96, 130, 220, 255), Color::from_rgba8(40, 60, 140, 255)].as_slice(),
);
let gradiente = card(|v| v.fill_gradient(grad.clone()), "Gradiente", white);
// 3) Borde: hairline sobre fill plano (reemplaza el truco del rect-padre).
let borde = card(
|v| v.fill(panel).border(1.5, theme.accent),
"Borde",
dark,
);
// 4) Combo: gradiente + borde + sombra — el look de un botón/card moderno.
let combo_grad = Gradient::new_linear(Point::new(0.0, 0.0), Point::new(1.0, 1.0)).with_stops(
[Color::from_rgba8(80, 200, 140, 255), Color::from_rgba8(30, 140, 110, 255)].as_slice(),
);
let combo = card(
|v| {
v.fill_gradient(combo_grad)
.border(1.5, Color::from_rgba8(180, 240, 210, 255))
.shadow(Shadow::soft(90, 24.0).offset(0.0, 12.0))
},
"Combo",
white,
);
// 5) Peso de fuente: la misma palabra en 400 (normal) y 700 (bold), para
// contrastar el grosor del trazo en una sola tarjeta.
let line = |txt: &str, bold: bool| {
let v = View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(30.0_f32) },
..Default::default()
})
.text_aligned(txt.to_string(), 24.0, dark, Alignment::Center);
if bold { v.bold() } else { v }
};
let peso = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(4.0_f32) },
..Default::default()
})
.radius(16.0)
.fill(panel)
.border(1.0, theme.accent)
.children(vec![
line("Regular 400", false),
line("Bold 700", true),
View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Peso", 14.0, dark, Alignment::Center),
]);
// 6) Radio por esquina: esquinas asimétricas (arriba muy redondeadas,
// abajo casi rectas) — el look de una pestaña / bocadillo de chat. El
// borde sigue las cuatro esquinas.
let esquinas = card(
|v| {
v.fill_gradient(grad.clone())
.border(1.5, white)
.radius_corners(34.0, 34.0, 4.0, 4.0)
},
"Esquinas",
white,
);
// 7) Overflow / ellipsis: texto largo clampado a 1 y a 2 líneas, terminando
// en `…`. El ancho de la caja fuerza la envoltura; el clamp recorta.
let long = "Texto largo que no entra en el ancho de esta tarjeta angosta y debe recortarse";
let clamp = |txt: &str, n: usize, h: f32| {
View::<()>::new(Style {
size: Size { width: percent(0.86_f32), height: length(h) },
..Default::default()
})
.text_aligned(txt.to_string(), 13.0, dark, Alignment::Start)
.ellipsis(n)
};
let elipsis = View::<()>::new(Style {
size: Size { width: length(180.0_f32), height: length(150.0_f32) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(0.0_f32), height: length(10.0_f32) },
..Default::default()
})
.radius(16.0)
.fill(panel)
.border(1.0, theme.accent)
.children(vec![
clamp(long, 1, 18.0),
clamp(long, 2, 36.0),
View::<()>::new(Style {
size: Size { width: percent(0.9_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned("Ellipsis 1·2", 14.0, dark, Alignment::Center),
]);
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(28.0_f32), height: length(0.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(24.0_f32),
bottom: length(24.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(vec![sombra, gradiente, borde, combo, peso, esquinas, elipsis]);
// view → layout → scene (misma secuencia que el eventloop).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-primitivas"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("primitivas_demo: escrito {out} ({W}x{H})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,262 @@
//! Volcado headless de **RichText spans** (Bloque 13 de PARIDAD-FLUTTER:
//! cierra Tier 2 final): un mismo nodo de texto con defaults a nivel
//! bloque (tamaño 16 px, color gris oscuro, weight 400, sin italic) más
//! un arreglo de `TextSpan` que sobreescriben por rango de bytes
//! `weight=700` (bold), `italic=true`, `color`, `underline=true`,
//! `size_px=22` (heading inline), `font_family=mono` (`<code>`-like) y
//! `strikethrough=true`. Verifica que la **medida** y el **pintado**
//! consumen el mismo `layout_spans` (taffy reserva el alto del span más
//! alto en su línea).
//!
//! `cargo run -p llimphi-compositor --example rich_text_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_compositor::{measure_text_node, mount, paint, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, TextSpan, TextSpanStyle, Typesetter, MONOSPACE};
const W: u32 = 980;
const H: u32 = 380;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
/// Helper para localizar un substring exacto en `text` y devolver el
/// `[start, end)` en bytes — así los spans del demo son legibles ("apply
/// bold to 'NEGRITA'") sin offsets a mano.
fn range_of(text: &str, needle: &str) -> (usize, usize) {
let start = text.find(needle).unwrap_or_else(|| panic!("'{needle}' not found"));
(start, start + needle.len())
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "rich_text.png".to_string());
let theme = llimphi_theme::Theme::light();
let panel = theme.bg_panel;
let dark = rgb(30, 34, 44);
let accent = rgb(52, 152, 219);
let danger = rgb(231, 76, 60);
let muted = rgb(128, 132, 144);
// Párrafo con seis tipos de override + texto plano alrededor para que
// se note el contraste de cada lente.
let parrafo = "Esto es un párrafo que mezcla NEGRITA, cursiva, un \
link.com, un cambio de TAMAÑO inline, una palabra \
tachada y código de muestra inline.";
let (b0, b1) = range_of(parrafo, "NEGRITA");
let (i0, i1) = range_of(parrafo, "cursiva");
let (l0, l1) = range_of(parrafo, "link.com");
let (sz0, sz1) = range_of(parrafo, "TAMAÑO");
let (st0, st1) = range_of(parrafo, "tachada");
let (m0, m1) = range_of(parrafo, "código de muestra");
let spans = vec![
TextSpan::new(b0, b1, TextSpanStyle { weight: Some(700.0), ..Default::default() }),
TextSpan::new(i0, i1, TextSpanStyle { italic: Some(true), ..Default::default() }),
TextSpan::new(
l0,
l1,
TextSpanStyle {
color: Some(accent),
underline: Some(true),
..Default::default()
},
),
TextSpan::new(
sz0,
sz1,
TextSpanStyle {
size_px: Some(24.0),
weight: Some(700.0),
color: Some(rgb(46, 204, 113)),
..Default::default()
},
),
TextSpan::new(
st0,
st1,
TextSpanStyle {
color: Some(danger),
strikethrough: Some(true),
..Default::default()
},
),
TextSpan::new(
m0,
m1,
TextSpanStyle {
font_family: Some(MONOSPACE.to_string()),
color: Some(rgb(155, 89, 182)),
..Default::default()
},
),
];
let texto_rico = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(160.0_f32) },
..Default::default()
})
.text_spans(parrafo, 16.0, dark, spans, Alignment::Start);
// Subtítulo + descripción + el párrafo rico, todo dentro de una card
// (apilada con flex_direction Column).
let titulo = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
..Default::default()
})
.text_aligned(
"RichText spans (Bloque 13 — cierra Tier 2)",
18.0,
dark,
Alignment::Start,
)
.bold();
let descripcion = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
..Default::default()
})
.text_aligned(
"Un solo nodo, seis tipos de override aplicados por rango de bytes:",
13.0,
muted,
Alignment::Start,
);
let card = View::<()>::new(Style {
size: Size { width: length(W as f32 - 80.0), height: length(H as f32 - 60.0) },
flex_direction: FlexDirection::Column,
align_items: Some(AlignItems::FlexStart),
justify_content: Some(JustifyContent::FlexStart),
gap: Size { width: length(0.0_f32), height: length(12.0_f32) },
padding: Rect {
left: length(24.0_f32),
right: length(24.0_f32),
top: length(20.0_f32),
bottom: length(20.0_f32),
},
..Default::default()
})
.fill(panel)
.radius(16.0)
.border(1.0, rgb(220, 224, 232))
.children(vec![titulo, descripcion, texto_rico]);
let root = View::<()>::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(theme.bg_app)
.children(vec![card]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-rich-text"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer
.render_to_view(&hal, &scene, &view, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"rich_text_demo: escrito {out} ({W}x{H}) — un nodo de texto con \
seis spans aplicados por rango de bytes: bold, italic, link \
(color + underline), heading inline (size 24 + bold + verde), \
strikethrough rojo, y un fragmento en mono morado."
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+182
View File
@@ -0,0 +1,182 @@
//! Filmstrip headless del **ripple/InkWell** (Bloque 8 de PARIDAD-FLUTTER):
//! una fila de botones, cada uno con una salpicadura Material disparada en el
//! mismo punto (arriba-izquierda) pero **observada a un progreso creciente** —
//! de la onda recién nacida (izquierda) a casi extinta (derecha). Muestra el
//! círculo expandiéndose desde el tap, recortado al contorno redondeado del
//! botón, y atenuándose con el fade.
//!
//! Prueba el camino `View::ripple` → `RippleRegistry::trigger`/`paint` →
//! `node_rrect` (clip) → píxeles, sin runtime ni winit. El press real lo
//! sintetiza el runtime (`llimphi-ui`); acá lo emulamos llamando `trigger`.
//!
//! `cargo run -p llimphi-compositor --example ripple_demo -- [out.png]`
use std::fs::File;
use std::io::BufWriter;
use std::time::{Duration, Instant};
use llimphi_compositor::{mount, paint, RippleRegistry, View};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy::prelude::{length, FlexDirection, Size, Style};
use llimphi_layout::taffy::{AlignItems, JustifyContent, LengthPercentage, Rect};
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_text::{Alignment, Typesetter};
const W: u32 = 1180;
const H: u32 = 240;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const FRAMES: usize = 6;
const DUR: Duration = Duration::from_millis(500);
/// Punto del tap relativo al rect de cada botón (arriba-izquierda) — la onda
/// crece desde ahí hacia el rincón opuesto, bien visible en el filmstrip.
const TAP: (f32, f32) = (38.0, 36.0);
fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::from_rgba8(r, g, b, 255)
}
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| "ripple.png".to_string());
let fg = rgb(235, 238, 245);
let surface = rgb(44, 52, 70);
let ink = Color::from_rgba8(255, 255, 255, 90); // onda blanca semitransparente
// Una fila de FRAMES botones con ripple (key = columna). Layout real con
// gap/padding → cada botón tiene su propio rect computado (sin transform,
// que el paint del ripple no contempla en v1).
let botones: Vec<View<()>> = (0..FRAMES)
.map(|i| {
let pct = (i as f32 / (FRAMES as f32 - 1.0) * 100.0).round() as i32;
View::<()>::new(Style {
size: Size { width: length(150.0), height: length(140.0) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(surface)
.radius(20.0)
.ripple(i as u64, ink)
.children(vec![View::<()>::new(Style {
size: Size { width: length(130.0), height: length(20.0) },
..Default::default()
})
.text_aligned(format!("{pct}%"), 14.0, fg, Alignment::Center)])
})
.collect();
let root = View::<()>::new(Style {
size: Size { width: length(W as f32), height: length(H as f32) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
gap: Size { width: length(20.0), height: length(0.0) },
padding: Rect {
left: LengthPercentage::length(20.0),
right: LengthPercentage::length(20.0),
top: LengthPercentage::length(0.0),
bottom: LengthPercentage::length(0.0),
},
..Default::default()
})
.children(botones);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
// Pintá los botones, luego superponé una salpicadura por columna observada
// a un progreso creciente (cada registro disparó en t0, se observa a
// t0 + paso·i). Todas escriben en la misma escena.
let mut ts = Typesetter::new();
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let t0 = Instant::now();
let step = DUR / (FRAMES as u32 - 1);
for i in 0..FRAMES {
let mut reg = RippleRegistry::new();
reg.trigger(i as u64, TAP.0, TAP.1, ink, DUR, t0);
let now = t0 + step * i as u32;
reg.paint(&mut scene, &mounted, &computed, now);
}
// Volcado a PNG.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dump-ripple"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let bg = rgb(244, 245, 248);
renderer.render_to_view(&hal, &scene, &view, W, H, bg).expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!(
"ripple_demo: escrito {out} ({W}x{H}) — {FRAMES} botones, la misma onda \
de {}ms observada a 0→100% (crece desde el tap y se desvanece)",
DUR.as_millis()
);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+741
View File
@@ -0,0 +1,741 @@
//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
//!
//! El render es **headless y determinista** (sin reloj, sin runtime, sin
//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
//! wgpu → PNG. El cold-open (trazo bezier draw-on) y el wordmark de cierre
//! son `paint_with` sobre un nodo full-screen, superpuestos sobre los widgets.
//!
//! ```text
//! cargo run -p llimphi-compositor --example showreel --release -- \
//! [out_dir] [n_frames] [W] [H]
//! ```
//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
use std::fs::{create_dir_all, File};
use std::io::BufWriter;
use llimphi_compositor::{
measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{
auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
};
use llimphi_layout::taffy::Rect;
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{self, Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
use llimphi_theme::motion;
use vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
use llimphi_widget_slider::{slider_view, SliderPalette};
use llimphi_widget_switch::{switch_view, SwitchPalette};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
// ───────────────────────── utilidades ─────────────────────────
/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
fn with_alpha(c: Color, a: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, a.clamp(0.0, 1.0)])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
fn seg(t: f32, lo: f32, hi: f32) -> f32 {
((t - lo) / (hi - lo)).clamp(0.0, 1.0)
}
// ───────────────────────── tema / paleta ─────────────────────────
#[derive(Clone)]
struct Skin {
theme: llimphi_theme::Theme,
accent: Color,
panel: Color,
panel_hi: Color,
border: Color,
border_accent: Color,
fg: Color,
fg_muted: Color,
bg: Color,
}
// ───────────────────────── geometría de las tarjetas ─────────────────────────
#[derive(Clone, Copy)]
struct CardRect {
x: f64,
y: f64,
w: f64,
h: f64,
}
impl CardRect {
fn lerp(self, b: CardRect, t: f64) -> CardRect {
CardRect {
x: lerp(self.x, b.x, t),
y: lerp(self.y, b.y, t),
w: lerp(self.w, b.w, t),
h: lerp(self.h, b.h, t),
}
}
}
const N_CARDS: usize = 6;
/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let card_w = 360.0;
let card_h = 196.0;
let gap = 40.0;
let cols = 3.0;
let rows = 2.0;
let total_w = cols * card_w + (cols - 1.0) * gap;
let total_h = rows * card_h + (rows - 1.0) * gap;
let x0 = (cw - total_w) / 2.0;
let y0 = (ch - total_h) / 2.0;
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
let col = (i % 3) as f64;
let row = (i / 3) as f64;
c.x = x0 + col * (card_w + gap);
c.y = y0 + row * (card_h + gap);
}
out
}
/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let gap = 22.0;
let n = N_CARDS as f64;
let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
let x0 = 90.0;
let cy = ch / 2.0;
// alturas tipo "ecualizador" — silueta dinámica al reacomodar.
let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
c.x = x0 + i as f64 * (card_w + gap);
c.h = hs[i];
c.y = cy - c.h / 2.0;
c.w = card_w;
}
out
}
// ───────────────────────── contenido de cada card ─────────────────────────
/// Header de card: chip de acento + título.
fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
let chip = View::new(Style {
size: Size { width: length(28.0), height: length(8.0) },
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0)
.fill(if accented { s.accent } else { s.fg_muted });
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(10.0), height: length(0.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![
chip,
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
.bold(),
])
}
/// Línea de "valor" grande (estado legible) bajo el control.
fn value_line(text: &str, color: Color, size: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(text.to_string(), size, color, Alignment::Start)
.bold()
}
/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
match i {
// ── 0: Switch (off → on) ──────────────────────────────────────
0 => {
// El thumb se desliza en una rampa centrada del beat.
let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
let on = prog > 0.5;
let pal = SwitchPalette::from_theme(&s.theme);
let sw_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(14.0), height: length(0.0) },
..Default::default()
})
.children(vec![
switch_view(prog, (), &pal),
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(
"Sincronizar".to_string(),
13.0,
s.fg,
Alignment::Start,
),
]);
vec![
card_header("switch", s, true),
spacer(8.0),
sw_row,
spacer(10.0),
value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
]
}
// ── 1: Slider (20% → 75%) ─────────────────────────────────────
1 => {
let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
let mut pal = SliderPalette::from_theme(&s.theme);
pal.track_width = 168.0;
pal.label_width = 0.0;
pal.value_width = 50.0;
pal.track_thickness = 8.0;
pal.row_height = 26.0;
let sld = slider_view::<(), _>(
"",
v,
0.0,
1.0,
&pal,
|_phase: DragPhase, _dv: f32| None,
);
vec![
card_header("slider", s, false),
spacer(10.0),
sld,
spacer(12.0),
value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
]
}
// ── 2: Linear progress (avanza) ───────────────────────────────
2 => {
let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
let bar = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(12.0) },
position: Position::Relative,
..Default::default()
})
.fill(s.theme.bg_button)
.radius(6.0)
.children(vec![linear_progress_view(
v,
s.theme.bg_button,
s.accent,
12.0,
)]);
vec![
card_header("progress", s, true),
spacer(14.0),
bar,
spacer(14.0),
value_line(&format!("{:>3.0}% · compilando", v * 100.0), s.fg_muted, 13.0),
]
}
// ── 3: Segmented control (cambia de pestaña activa) ───────────
3 => {
// 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
let phase = seg(p, 0.1, 0.95);
let active = ((phase * 3.0).floor() as usize).min(2);
let labels = ["Día", "Semana", "Mes"];
let pal = SegmentedPalette::from_theme(&s.theme);
let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
vec![
card_header("segmented", s, false),
spacer(14.0),
seg_ctrl,
spacer(14.0),
value_line(labels[active], s.accent, 22.0),
]
}
// ── 4: Botones (primario teal + ghost) ────────────────────────
4 => {
// Paleta primaria: fondo teal, texto sobre fondo.
let mut prim = ButtonPalette::from_theme(&s.theme);
prim.bg = s.accent;
prim.bg_hover = s.accent;
prim.fg = s.bg; // texto oscuro sobre teal
prim.radius = 8.0;
let mut ghost = ButtonPalette::from_theme(&s.theme);
ghost.bg = s.theme.bg_button;
ghost.fg = s.fg;
ghost.radius = 8.0;
let row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(38.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(12.0), height: length(0.0) },
..Default::default()
})
.children(vec![
View::new(Style {
size: Size { width: length(132.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Regenerar", &prim, ())]),
View::new(Style {
size: Size { width: length(110.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Difundir", &ghost, ())]),
]);
vec![
card_header("button", s, true),
spacer(14.0),
row,
spacer(14.0),
value_line("primario · ghost", s.fg_muted, 13.0),
]
}
// ── 5: Radial progress (anillo que se llena) ──────────────────
_ => {
let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
let ring = View::new(Style {
size: Size { width: length(96.0), height: length(96.0) },
position: Position::Relative,
..Default::default()
})
.children(vec![radial_progress_view(
v,
s.theme.bg_button,
s.accent,
0.14,
)]);
let ring_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(96.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![ring]);
vec![
card_header("radial", s, false),
spacer(6.0),
ring_row,
]
}
}
}
/// Espaciador vertical de alto fijo.
fn spacer(h: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
..Default::default()
})
}
/// Una card como contenedor absoluto, hospedando widgets reales.
fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
let accented = i == 0 || i == 2 || i == 4;
let border_col = if accented { s.border_accent } else { s.border };
// Pop de entrada: escala desde el centro de la card.
let cx = rect.x + rect.w / 2.0;
let cy = rect.y + rect.h / 2.0;
let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(rect.x as f32),
top: length(rect.y as f32),
right: auto(),
bottom: auto(),
},
size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(0.0) },
padding: Rect {
left: length(22.0),
right: length(22.0),
top: length(20.0),
bottom: length(18.0),
},
..Default::default()
})
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([s.panel_hi, s.panel].as_slice()),
)
.radius(18.0)
.border(if accented { 1.4 } else { 1.0 }, border_col)
.shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
.transform(xf)
.alpha(alpha)
.children(card_body(i, p, s))
}
// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
/// Curva bezier "firma" del cold-open.
fn signature_path(cw: f64, ch: f64) -> BezPath {
let cx = cw / 2.0;
let cy = ch / 2.0;
let mut p = BezPath::new();
p.move_to((cx - 360.0, cy + 40.0));
p.curve_to(
(cx - 150.0, cy - 220.0),
(cx + 150.0, cy + 220.0),
(cx + 360.0, cy - 40.0),
);
p
}
/// Recorta un `BezPath` cúbico a su fracción inicial `prog`. Devuelve la
/// cabeza del trazo para anclar el punto teal.
fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
use vello::kurbo::ParamCurve;
let prog = prog.clamp(0.0, 1.0);
let mut cubic = None;
let mut start = Point::ZERO;
for el in full.elements() {
match el {
vello::kurbo::PathEl::MoveTo(p) => start = *p,
vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
}
_ => {}
}
}
let mut out = BezPath::new();
let mut head = start;
if let Some(cb) = cubic {
out.move_to(cb.p0);
let steps = 96;
for i in 1..=steps {
let u = (i as f64 / steps as f64) * prog;
let pt = cb.eval(u);
out.line_to(pt);
head = pt;
}
}
(out, head)
}
/// Dibuja los overlays vector (cold-open + wordmark + punto firma) sobre un
/// nodo full-screen, en función de `t`. Los widgets ya están pintados debajo.
fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
// ── COLD OPEN (0–12%) ──────────────────────────────────────────
let b1 = seg(t, 0.0, 0.12);
let line_vis = 1.0 - seg(t, 0.12, 0.20);
if line_vis > 0.001 {
let path = signature_path(cw, ch);
let draw_on = motion::ease_out_cubic(seg(t, 0.02, 0.13)) as f64;
let (trimmed, head) = trim_path(&path, draw_on);
let line_col = with_alpha(s.accent, 0.9 * line_vis);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
let pop = motion::ease_out_back(b1);
let r = (4.0 + 7.0 * pop as f64).max(0.0);
let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.18 * dot_a),
None,
&Circle::new(head, r * 3.2),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, dot_a),
None,
&Circle::new(head, r),
);
}
// ── WORDMARK (82100%) ─────────────────────────────────────────
let word_in = seg(t, 0.84, 0.95);
let word_a = motion::ease_out_cubic(word_in);
if word_a > 0.001 {
let size = 132.0_f32;
let layout = ts.layout(
"Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
);
let m = measurement(&layout);
let rise = lerp(24.0, 0.0, word_a as f64);
let ox = (cw - m.width as f64) / 2.0;
let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
if sub_a > 0.001 {
let ssz = 26.0_f32;
let sub = ts.layout(
"a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
false, false, 0.0, 0.0,
);
let sm = measurement(&sub);
let dot_r = 6.0;
let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
let sx = (cw - block_w) / 2.0;
let sy = oy + m.height as f64 + 18.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, sub_a),
None,
&Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
);
let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
draw_layout_brush_xf(
scene,
&sub,
&sbrush,
Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
);
}
}
// ── punto teal de firma (esquina inf-der), ancla de marca ───────
let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
if corner_a > 0.001 {
let cx = cw - 54.0;
let cy = ch - 54.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.16 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 18.0),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.9 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 6.0),
);
}
}
// ───────────────────────── la escena por frame ─────────────────────────
/// Construye el árbol `View` completo del frame `t`: las cards con widgets
/// reales (con su estado derivado de t) + un nodo overlay full-screen que
/// pinta cold-open / wordmark encima.
fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
let grid = layout_grid(cw, ch);
let row = layout_row(cw, ch);
// Progreso del "estado" de los widgets (toggle/slider/progress/…).
let widget_p = seg(t, 0.16, 0.58);
// Morph grid → fila (5880%).
let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
// Fade-out de las cards antes del wordmark.
let cards_fade = 1.0 - seg(t, 0.80, 0.86);
let mut children: Vec<View<()>> = Vec::new();
if cards_fade > 0.001 {
for i in 0..N_CARDS {
// Stagger de entrada: cada card arranca con retraso incremental.
let delay = i as f32 * 0.035;
let enter = motion::ease_out_back(seg(t, 0.12 + delay, 0.12 + delay + 0.16));
if enter <= 0.001 {
continue;
}
let rect = grid[i].lerp(row[i], morph);
let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
children.push(card_view(i, rect, alpha, scale, widget_p, s));
}
}
// Nodo overlay full-screen para el vector (cold-open + wordmark).
let overlay = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with({
let s = s.clone();
move |scene, ts, _rect: PaintRect| {
draw_overlays(scene, ts, t, cw, ch, &s);
}
});
children.push(overlay);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
position: Position::Relative,
..Default::default()
})
.fill(s.bg)
.children(children)
}
fn main() {
let mut args = std::env::args().skip(1);
let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
create_dir_all(&out_dir).expect("mkdir out_dir");
let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
let skin = Skin {
accent,
panel: theme.bg_panel,
panel_hi: theme.bg_button,
border: theme.border,
border_accent: with_alpha(accent, 0.55),
fg: theme.fg_text,
fg_muted: theme.fg_muted,
bg: theme.bg_app,
theme,
};
let [br, bg, bb, _] = skin.bg.components;
let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
// GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("showreel"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut ts = Typesetter::new();
let cw = w as f64;
let ch = h as f64;
for i in 0..n {
let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
let root = build_view(t, cw, ch, &skin);
// view → layout (con medición de texto real) → scene — idéntico al eventloop.
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view, w, h, base)
.expect("render_to_view");
let path = format!("{out_dir}/frame_{i:04}.png");
write_png(&hal, &target, &path, w, h);
if i % 30 == 0 || i == n - 1 {
eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
}
}
eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for r in 0..h as usize {
let sidx = r * padded;
pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
//! **Hero / shared-element transitions** — un mismo nodo lógico (key estable)
//! que aparece en posiciones distintas entre frames "vuela" del rect anterior
//! al actual en vez de saltar. Es el Hero de Flutter auténtico.
//!
//! Modelo:
//! - El caller marca un nodo con [`View::hero(key, duration)`](crate::View::hero).
//! `key` enlaza "el mismo nodo lógico" entre dos `view()` distintos (entre
//! rutas, paneles, layouts) — dos nodos con la misma `key` en frames distintos
//! son la misma identidad para el runtime.
//! - El runtime mantiene una instancia de [`HeroRegistry`] entre frames y llama
//! [`HeroRegistry::reconcile`] DESPUÉS de `compute` y ANTES de `paint`. Por
//! cada nodo hero:
//! - Lee su rect absoluto del [`ComputedLayout`].
//! - Si en el frame anterior la misma `key` vivió en un rect distinto,
//! arranca un tween: durante `duration`, escribe en `node.transform` una
//! afín que "lleva visualmente" el nodo del rect actual al rect anterior y
//! converge a `IDENTITY`. El nodo se ve VOLAR del rect anterior al actual.
//! - Mientras el tween esté vivo, devuelve `true` y el runtime pide otro
//! frame (ticker autodetenido).
//! - Al asentarse, deja `node.transform = None`: cero costo de transform
//! residual en frames posteriores.
//!
//! No depende de [`crate::AnimRegistry`] — el wiring es independiente; sólo
//! reusa el campo `transform` del [`MountedNode`](crate::MountedNode), que el
//! `paint` ya respeta como cualquier otro afín.
//!
//! ## Reglas de uso
//!
//! - `key` debe ser estable y **única** entre los nodos hero presentes en un
//! mismo frame. Dos hero con la misma key en el mismo árbol generan
//! ambigüedad; el runtime se queda con la última que recorra.
//! - El rect "anterior" es el del frame anterior — no funciona como
//! shared-element entre dos *vistas montadas a la vez* (eso requeriría dos
//! rect simultáneos por key). Funciona entre transiciones de rutas.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use llimphi_layout::{ComputedLayout, Rect};
use vello::kurbo::Affine;
/// Declara un nodo como **hero**: la `key` enlaza la identidad entre frames; si
/// el rect cambia, el runtime anima la transición.
#[derive(Clone, Copy, Debug)]
pub struct Hero {
pub key: u64,
pub duration: Duration,
/// Easing aplicado a `t ∈ [0,1]`. Por defecto, los setters de [`View`]
/// usan un ease-out cúbico (igual que las animaciones implícitas).
pub easing: fn(f32) -> f32,
}
/// Registro de heroes, vivo entre frames. Guarda el último rect por `key` para
/// detectar el delta y un tween activo si está animando.
#[derive(Default)]
pub struct HeroRegistry {
/// Último rect donde se pintó un nodo con esta `key`. Se actualiza en cada
/// `reconcile`. Es contra esto que detectamos el cambio que dispara el
/// tween.
last: HashMap<u64, Rect>,
/// Tweens en curso. Cada uno conoce su `from_rect`, el reloj y el easing.
/// Una key con tween activo NO arranca uno nuevo si vuelve a moverse —
/// reusamos el `from_rect` original para que la trayectoria sea continua
/// (si el target cambia a mitad, vuela hacia el nuevo destino, no cambia
/// el origen).
tweens: HashMap<u64, Tween>,
}
struct Tween {
from_rect: Rect,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl HeroRegistry {
pub fn new() -> Self {
Self::default()
}
/// Reconcilia heroes con el árbol montado. Para cada nodo con [`Hero`]:
/// - Si el rect cambió respecto del frame anterior, arranca tween.
/// - Si hay tween activo y vivo, escribe `node.transform` con la afín
/// interpolada (cur → from).
/// - Cuando el tween termina, lo limpia y deja `node.transform = None`.
///
/// Llamar DESPUÉS de `compute` y ANTES de `paint`. Devuelve `true` si
/// algún tween sigue en curso → el runtime pide otro frame.
pub fn reconcile<Msg>(
&mut self,
mounted: &mut crate::Mounted<Msg>,
computed: &ComputedLayout,
now: Instant,
) -> bool {
let mut animating = false;
let mut seen: Vec<u64> = Vec::new();
for node in &mut mounted.nodes {
let Some(hero) = node.hero else { continue };
let Some(cur) = computed.get(node.id) else { continue };
seen.push(hero.key);
// ¿Cambió el rect respecto del último frame? Arrancar tween (si no
// hay uno activo; si lo hay, no re-resetamos el origen).
if let Some(last) = self.last.get(&hero.key).copied() {
if last != cur && !self.tweens.contains_key(&hero.key) {
self.tweens.insert(
hero.key,
Tween {
from_rect: last,
start: now,
duration: hero.duration,
easing: hero.easing,
},
);
}
}
// Aplicar tween si está vivo. Calcula la afín que mapea `cur` al
// `from_rect` y la interpola hacia identidad a medida que `t` crece.
if let Some(tw) = self.tweens.get(&hero.key) {
let elapsed = now.saturating_duration_since(tw.start).as_secs_f32();
let raw = (elapsed / tw.duration.as_secs_f32().max(1e-6)).clamp(0.0, 1.0);
if raw >= 1.0 {
// Aterrizó: dejamos el nodo sin transform y limpiamos.
node.transform = None;
self.tweens.remove(&hero.key);
} else {
let t = (tw.easing)(raw);
let back = back_transform(cur, tw.from_rect);
let xf = lerp_affine(back, Affine::IDENTITY, t);
node.transform = Some(xf);
animating = true;
}
}
self.last.insert(hero.key, cur);
}
// Las keys que no aparecieron este frame se descartan (un hero que se
// va deja de recordarse; si vuelve, su rect "anterior" será el nuevo
// primero — no anima desde el último que tuvo hace varios frames).
if self.last.len() != seen.len() {
self.last.retain(|k, _| seen.contains(k));
self.tweens.retain(|k, _| seen.contains(k));
}
animating
}
}
/// Afín local que, aplicada con [`View::transform`]'s convención (alrededor
/// del centro del rect actual), mapea visualmente cada punto del `cur_rect`
/// al punto correspondiente del `from_rect`. Es la base de un "fly":
/// el nodo se pinta en `cur` pero con esta xf VOLVIÓ a `from` —
/// interpolando hacia identidad, "vuela" de `from` a `cur`.
fn back_transform(cur: Rect, from: Rect) -> Affine {
// El compositor aplica xf como `T(centro_cur) · xf_local · T(-centro_cur)`,
// así que xf_local debe ser `scale + translate` que mapea:
// esquina superior izquierda de cur → esquina superior izquierda de from.
//
// Si scale = (from.w/cur.w, from.h/cur.h) y t = (cx_from - cx_cur,
// cy_from - cy_cur), entonces `T(t) · S` cumple esa propiedad (despejo en
// los comentarios del módulo).
let sx = (from.w as f64) / (cur.w as f64).max(1e-6);
let sy = (from.h as f64) / (cur.h as f64).max(1e-6);
let cx_cur = (cur.x + cur.w * 0.5) as f64;
let cy_cur = (cur.y + cur.h * 0.5) as f64;
let cx_from = (from.x + from.w * 0.5) as f64;
let cy_from = (from.y + from.h * 0.5) as f64;
Affine::translate((cx_from - cx_cur, cy_from - cy_cur)) * Affine::scale_non_uniform(sx, sy)
}
/// Lerp componente-a-componente de las 6 coefs del afín. Idéntica al helper
/// privado de [`crate::anim`] — vive separada para mantener el módulo `hero`
/// auto-contenido (sin acoplar a Anim).
fn lerp_affine(a: Affine, b: Affine, t: f32) -> Affine {
let p = a.as_coeffs();
let q = b.as_coeffs();
let ft = t as f64;
Affine::new([
p[0] + (q[0] - p[0]) * ft,
p[1] + (q[1] - p[1]) * ft,
p[2] + (q[2] - p[2]) * ft,
p[3] + (q[3] - p[3]) * ft,
p[4] + (q[4] - p[4]) * ft,
p[5] + (q[5] - p[5]) * ft,
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, View};
use llimphi_layout::{LayoutTree, Style};
use llimphi_layout::taffy::prelude::length;
use llimphi_layout::taffy::Size;
/// Monta un único nodo hero con su Style + key=1 + dur=200ms. Devuelve
/// `Mounted` y el `ComputedLayout` ya resuelto contra un viewport de
/// 1000×1000 — los rects salen del propio Style.
fn one(x: f32, y: f32, w: f32, h: f32) -> (crate::Mounted<()>, ComputedLayout) {
let v = View::<()>::new(Style {
size: Size { width: length(w), height: length(h) },
inset: llimphi_layout::taffy::Rect {
left: length(x),
top: length(y),
right: llimphi_layout::taffy::prelude::auto(),
bottom: llimphi_layout::taffy::prelude::auto(),
},
position: llimphi_layout::taffy::Position::Absolute,
..Default::default()
})
.hero(1, Duration::from_millis(200));
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, v);
let computed = layout
.compute(mounted.root, (1000.0_f32, 1000.0_f32))
.expect("layout");
(mounted, computed)
}
#[test]
fn primera_aparicion_no_anima() {
let mut reg = HeroRegistry::new();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
let animating = reg.reconcile(&mut m, &c, Instant::now());
assert!(!animating, "primera aparición no debe animar");
assert!(m.nodes[0].transform.is_none(), "sin xf en primer frame");
}
#[test]
fn cambio_de_rect_arranca_tween_y_aplica_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
// Frame 1: rect (10, 10, 50, 50).
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
// Frame 2: el nodo ahora vive en (200, 200, 100, 100) → arranca tween.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(50));
assert!(animating, "cambio de rect → tween");
let xf = m.nodes[0].transform.expect("xf");
// A 50ms en una anim de 200ms, raw ≈ 0.25; con ease-out cúbico t > 0.25.
// La afín NO debe ser identidad (algún coef se ve).
let c = xf.as_coeffs();
assert!(c[0] != 1.0 || c[3] != 1.0 || c[4] != 0.0 || c[5] != 0.0,
"xf no debe ser identidad a mitad del tween: {:?}", c);
}
#[test]
fn al_terminar_limpia_la_xf() {
let mut reg = HeroRegistry::new();
let t0 = Instant::now();
let (mut m, c) = one(10.0, 10.0, 50.0, 50.0);
reg.reconcile(&mut m, &c, t0);
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
reg.reconcile(&mut m, &c, t0 + Duration::from_millis(10));
// Pasada la duración: el tween se descarta y deja el nodo sin xf.
let (mut m, c) = one(200.0, 200.0, 100.0, 100.0);
let animating = reg.reconcile(&mut m, &c, t0 + Duration::from_millis(500));
assert!(!animating);
assert!(m.nodes[0].transform.is_none());
}
#[test]
fn back_transform_es_identidad_si_los_rects_coinciden() {
let r = Rect { x: 50.0, y: 50.0, w: 100.0, h: 100.0 };
let xf = back_transform(r, r);
let c = xf.as_coeffs();
assert!((c[0] - 1.0).abs() < 1e-9);
assert!((c[3] - 1.0).abs() < 1e-9);
assert!(c[4].abs() < 1e-9);
assert!(c[5].abs() < 1e-9);
}
}
+233
View File
@@ -0,0 +1,233 @@
//! **LayoutBuilder** — el 4º seam de PARIDAD-FLUTTER: construir un subárbol
//! sensible al **tamaño del slot** del nodo (no de la ventana — para eso
//! alcanza `on_resize` + el Model). Flutter `LayoutBuilder`.
//!
//! El modelo de Llimphi corre `view → mount → compute → paint`: el `View` se
//! arma ANTES de conocer el layout, así que "construir distinto según el espacio
//! disponible" exige diferir. La solución, sin tocar `mount`/`paint`, es una
//! **resolución en dos pasadas** orquestada por el runtime:
//!
//! 1. Montar el árbol tal cual ([`crate::View::layout_builder`] queda como
//! **hoja** — no tiene `children` estáticos) y computar el layout. Ahora cada
//! builder tiene su rect resuelto por su `Style`/contexto flex.
//! 2. [`collect_builder_constraints`] lee esos rects (en pre-orden), se pide un
//! `view()` fresco y [`expand_layout_builders`] invoca cada closure con sus
//! [`crate::Constraints`] para producir el subárbol real. Ese árbol expandido
//! se monta y pinta normalmente.
//!
//! [`has_layout_builder`] hace que todo esto sea **coste cero** cuando ningún
//! nodo usa el builder (el caso de la abrumadora mayoría de frames): es un
//! simple walk que corta el camino de dos pasadas.
//!
//! **Correspondencia de orden.** `collect_builder_constraints` recorre
//! `Mounted::nodes` (pre-orden, padre antes que hijos — el orden en que `mount`
//! los pushea) filtrando `is_layout_builder`; `expand_layout_builders` recorre
//! el `View` fresco en el MISMO pre-orden asignando un índice por builder. Como
//! ambos árboles salen del mismo `view(model)` determinista, el i-ésimo builder
//! de uno corresponde al i-ésimo del otro — por eso alcanza con un `Vec`
//! ordenado, sin keys.
//!
//! **Límite v1**: sin anidamiento. Un builder cuyo subárbol producido contiene
//! otro `layout_builder` no resuelve el interno (no existía en la pasada 1):
//! queda como hoja. El anidamiento requeriría iterar la resolución; se difiere.
use crate::{Constraints, ComputedLayout, Mounted, View};
/// `true` si `view` o algún descendiente declara un [`crate::View::layout_builder`].
/// El runtime lo usa para decidir si vale la pena la resolución en dos pasadas;
/// cuando es `false` (lo normal) el camino diferido se evita por completo.
pub fn has_layout_builder<Msg>(view: &View<Msg>) -> bool {
view.layout_builder.is_some() || view.children.iter().any(has_layout_builder)
}
/// Lee las [`Constraints`] (tamaño del slot) de cada nodo `is_layout_builder`
/// del árbol montado, en pre-orden. El runtime las pasa a
/// [`expand_layout_builders`]. Un nodo sin rect computado (fuera del layout)
/// cae a `0×0`.
pub fn collect_builder_constraints<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
) -> Vec<Constraints> {
mounted
.nodes
.iter()
.filter(|n| n.is_layout_builder)
.map(|n| {
computed
.get(n.id)
.map(|r| Constraints { max_width: r.w, max_height: r.h })
.unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 })
})
.collect()
}
/// Expande los `layout_builder` de `view` (pre-orden) usando `cons` — una
/// [`Constraints`] por builder, en el orden que produjo
/// [`collect_builder_constraints`]. Cada builder se reemplaza por un nodo
/// contenedor (su mismo `Style`) cuyo único hijo es lo que devolvió la closure
/// invocada con sus constraints. Builders sin constraint correspondiente (más
/// builders que `cons`, p. ej. uno anidado recién producido) caen a `0×0` y se
/// resuelven igual, pero su tamaño será nulo (límite v1: sin anidamiento).
/// Consume `view`.
pub fn expand_layout_builders<Msg>(view: View<Msg>, cons: &[Constraints]) -> View<Msg> {
let mut idx = 0;
expand_rec(view, cons, &mut idx)
}
fn expand_rec<Msg>(mut view: View<Msg>, cons: &[Constraints], idx: &mut usize) -> View<Msg> {
if let Some(builder) = view.layout_builder.take() {
let c = cons
.get(*idx)
.copied()
.unwrap_or(Constraints { max_width: 0.0, max_height: 0.0 });
*idx += 1;
// El builder posee los hijos: descartamos cualquier `children` estático
// y ponemos lo que produjo la closure. NO recursamos en el resultado
// (v1 sin anidamiento — un builder interno queda como hoja al montarse).
let child = builder(c);
view.children = vec![child];
view
} else {
let children = std::mem::take(&mut view.children);
view.children = children
.into_iter()
.map(|c| expand_rec(c, cons, idx))
.collect();
view
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, Constraints};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::{LayoutTree, Style};
/// Árbol sin builders → `has_layout_builder` falso y expand es no-op.
#[test]
fn sin_builder_es_noop() {
let v = View::<()>::new(Style::default())
.children(vec![View::<()>::new(Style::default())]);
assert!(!has_layout_builder(&v));
let v = expand_layout_builders(v, &[]);
assert_eq!(v.children.len(), 1);
}
#[test]
fn detecta_builder_anidado_en_hijos() {
let v = View::<()>::new(Style::default()).children(vec![
View::<()>::new(Style::default()),
View::<()>::new(Style::default()).layout_builder(|_c| View::<()>::new(Style::default())),
]);
assert!(has_layout_builder(&v));
}
/// El builder recibe las constraints y produce su subárbol; el nodo deja de
/// ser builder y queda como contenedor con el hijo producido.
#[test]
fn expand_invoca_closure_con_constraints() {
// Dos columnas a percent(0.5) del root 400px → cada slot = 200px. La de
// la izquierda es un builder que mete 1 hijo si es angosta (<300) o 2 si
// es ancha. A 200px mete 1.
let build_col = |c: Constraints| {
let n = if c.max_width < 300.0 { 1 } else { 2 };
View::<()>::new(Style::default())
.children((0..n).map(|_| View::<()>::new(Style::default())).collect())
};
let root = View::<()>::new(Style {
size: Size { width: length(400.0), height: length(100.0) },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
})
.layout_builder(build_col),
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
}),
]);
// Pasada 1: montar (builder como hoja) y computar.
let mut l1 = LayoutTree::new();
let m1 = mount(&mut l1, root);
let c1 = l1.compute(m1.root, (400.0, 100.0)).expect("layout");
let cons = collect_builder_constraints(&m1, &c1);
assert_eq!(cons.len(), 1, "un solo builder");
assert!((cons[0].max_width - 200.0).abs() < 1.0, "slot 200px: {:?}", cons[0]);
// Pasada 2: árbol fresco (mismo Style) + expand.
let root2 = View::<()>::new(Style {
size: Size { width: length(400.0), height: length(100.0) },
flex_direction: FlexDirection::Row,
..Default::default()
})
.children(vec![
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
})
.layout_builder(build_col),
View::<()>::new(Style {
size: Size { width: percent(0.5), height: percent(1.0) },
..Default::default()
}),
]);
let expanded = expand_layout_builders(root2, &cons);
// El nodo builder (hijo 0 del root) ya no es builder y tiene 1 hijo
// producido (slot 200 < 300 → angosto → 1 columna).
let col_izq = &expanded.children[0];
assert!(col_izq.layout_builder.is_none(), "ya expandido");
assert_eq!(col_izq.children.len(), 1, "200px angosto → 1 hijo");
}
/// Con un slot ancho el mismo builder produce 2 hijos — verifica que la
/// rama de decisión depende de las constraints reales.
#[test]
fn slot_ancho_produce_mas_hijos() {
let build_col = |c: Constraints| {
let n = if c.max_width < 300.0 { 1 } else { 2 };
View::<()>::new(Style::default())
.children((0..n).map(|_| View::<()>::new(Style::default())).collect())
};
// Constraint inyectada directo: 500px → ancho. El builder devuelve UN
// contenedor (hijo único del nodo) con 2 columnas adentro.
let v = View::<()>::new(Style::default()).layout_builder(build_col);
let expanded = expand_layout_builders(v, &[Constraints { max_width: 500.0, max_height: 100.0 }]);
assert_eq!(expanded.children.len(), 1, "el builder produce 1 contenedor");
assert_eq!(expanded.children[0].children.len(), 2, "ancho → 2 columnas");
}
/// Pre-orden: dos builders hermanos reciben sus constraints en orden.
#[test]
fn dos_builders_reciben_constraints_en_preorden() {
let mk = |w: f32| {
move |_c: Constraints| {
View::<()>::new(Style {
size: Size { width: length(w), height: length(10.0) },
..Default::default()
})
}
};
let root = View::<()>::new(Style::default()).children(vec![
View::<()>::new(Style::default()).layout_builder(mk(1.0)),
View::<()>::new(Style::default()).layout_builder(mk(2.0)),
]);
let cons = vec![
Constraints { max_width: 111.0, max_height: 0.0 },
Constraints { max_width: 222.0, max_height: 0.0 },
];
let expanded = expand_layout_builders(root, &cons);
// Ambos expandidos, en orden (verificamos vía el ancho del hijo producido
// que NO depende de la constraint acá — sólo confirmamos que se invocaron
// los dos y que ninguno quedó como builder).
assert!(expanded.children[0].layout_builder.is_none());
assert!(expanded.children[1].layout_builder.is_none());
assert_eq!(expanded.children[0].children.len(), 1);
assert_eq!(expanded.children[1].children.len(), 1);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
//! **Ripple / InkWell** — el feedback de tap de Material: un círculo que se
//! expande desde el punto donde el dedo/cursor presionó, clipeado al contorno
//! del nodo, desvaneciéndose mientras crece. Es puro feedback visual: no vive
//! en el `Model` de la app (igual que las animaciones implícitas de
//! [`crate::AnimRegistry`]) sino en un registro retenido por el runtime entre
//! frames.
//!
//! **Flujo.** Un `View` se marca ripple-capaz con [`crate::View::ripple`]
//! (key estable + color). Cuando un press izquierdo cae sobre ese nodo, el
//! runtime hace [`crate::hit_test_ripple`], calcula el punto local del tap y
//! llama [`RippleRegistry::trigger`] — que guarda una "salpicadura" con su
//! reloj. En cada redraw, DESPUÉS del paint del contenido, el runtime llama
//! [`RippleRegistry::paint`], que por cada salpicadura viva resuelve el rect
//! actual del nodo (puede haber cambiado de tamaño), dibuja el círculo
//! expansivo recortado al rrect del nodo y devuelve `true` si alguna sigue
//! viva → el runtime pide otro frame (ticker autodetenido, sin `spawn_periodic`).
//!
//! **Aditivo.** El ripple NO toca el camino click/drag: se dispara en el press
//! por su propio hit-test, conviva o no el nodo con `on_click`. Un botón normal
//! (`on_click` + `.ripple(...)`) recibe ambos.
//!
//! **Limitación v1.** Como la captura de subescenas del fade-out
//! ([`crate::AnimRegistry`]), el paint usa el rect en coordenadas absolutas del
//! layout e ignora los `transform` de ancestros — alcanza para botones/cards
//! (rara vez transformados). La salpicadura es one-shot (expande + se desvanece
//! en `duration`); no hay "mantener mientras se sostiene el press" (Material
//! `hold`), que requeriría rastrear el release por key.
use std::time::{Duration, Instant};
use vello::kurbo::{Affine, Circle};
use vello::peniko::{BlendMode, Color, Fill};
use vello::Scene;
use crate::{ComputedLayout, Mounted};
/// Declara que este nodo emite un **ripple** (salpicadura Material) al recibir
/// un press. `key` debe ser estable entre rebuilds del `View` (igual que la
/// key de [`crate::Anim`]) — es lo que enlaza la salpicadura retenida con el
/// nodo entre frames. `color` es el tinte de la onda (típicamente
/// semitransparente, p. ej. blanco a alpha ~0.25 sobre superficies oscuras o
/// negro a alpha ~0.12 sobre claras); su alpha se multiplica por el fade.
#[derive(Clone, Copy, Debug)]
pub struct Ripple {
pub key: u64,
pub color: Color,
pub duration: Duration,
}
/// Una salpicadura viva: el punto de origen **relativo al rect del nodo** al
/// momento del press, su color/duración y el reloj de expansión.
struct Splash {
key: u64,
/// Origen del tap relativo a la esquina superior-izquierda del rect del
/// nodo (mismo espacio que los handlers `*_at`). Se reancla al rect actual
/// del nodo en cada frame, así la onda sigue al nodo si éste se mueve.
lx: f32,
ly: f32,
color: Color,
start: Instant,
duration: Duration,
easing: fn(f32) -> f32,
}
impl Splash {
/// Progreso `[0,1]` sin easing (lineal en el tiempo).
fn raw(&self, now: Instant) -> f32 {
if self.duration.is_zero() {
return 1.0;
}
let elapsed = now.saturating_duration_since(self.start).as_secs_f32();
(elapsed / self.duration.as_secs_f32()).clamp(0.0, 1.0)
}
fn done(&self, now: Instant) -> bool {
now.saturating_duration_since(self.start) >= self.duration
}
}
/// Registro de ripples vivos, retenido por el runtime entre frames. Una
/// instancia por ventana; el runtime llama [`Self::trigger`] en el press y
/// [`Self::paint`] tras el paint del contenido.
#[derive(Default)]
pub struct RippleRegistry {
splashes: Vec<Splash>,
}
impl RippleRegistry {
pub fn new() -> Self {
Self::default()
}
/// Registra una salpicadura nueva sobre el nodo de key `key`, originada en
/// `(lx, ly)` relativo a su rect. `now` es el instante del press. Varios
/// presses rápidos apilan ondas concurrentes (como Material).
pub fn trigger(
&mut self,
key: u64,
lx: f32,
ly: f32,
color: Color,
duration: Duration,
now: Instant,
) {
self.splashes.push(Splash {
key,
lx,
ly,
color,
start: now,
duration,
easing: crate::ease_out_cubic,
});
}
/// `true` si hay alguna salpicadura viva (el runtime ya lo sabe por el
/// retorno de [`Self::paint`], pero es cómodo para decidir antes).
pub fn animating(&self) -> bool {
!self.splashes.is_empty()
}
/// Pinta las salpicaduras vivas sobre `scene`, cada una como un círculo que
/// crece (radio con ease-out hasta cubrir el nodo) y se desvanece, recortado
/// al contorno redondeado del nodo. Resuelve el rect de cada nodo por su
/// `ripple.key` en `mounted`/`computed` (así sigue al nodo si se redimensiona).
/// Descarta las agotadas. Devuelve `true` si queda alguna viva → pedir frame.
///
/// Llamar DESPUÉS del paint del contenido (la onda va encima, translúcida).
pub fn paint<Msg>(
&mut self,
scene: &mut Scene,
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
now: Instant,
) -> bool {
// Descartá primero las agotadas (no dependen del nodo).
self.splashes.retain(|s| !s.done(now));
if self.splashes.is_empty() {
return false;
}
for s in &self.splashes {
// Resolvé el nodo ripple de esta key (el primero que la declare).
let Some(node) = mounted.nodes.iter().find(|n| {
n.ripple.map(|r| r.key) == Some(s.key)
}) else {
continue;
};
let Some(r) = computed.get(node.id) else {
continue;
};
if r.w <= 0.0 || r.h <= 0.0 {
continue;
}
let cx = r.x as f64 + s.lx as f64;
let cy = r.y as f64 + s.ly as f64;
// Radio máximo = distancia al rincón más lejano, así la onda llega a
// cubrir todo el nodo cualquiera sea el punto de origen.
let corners = [
(r.x as f64, r.y as f64),
((r.x + r.w) as f64, r.y as f64),
(r.x as f64, (r.y + r.h) as f64),
((r.x + r.w) as f64, (r.y + r.h) as f64),
];
let max_radius = corners
.iter()
.map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt())
.fold(0.0_f64, f64::max);
let t = s.raw(now);
let radius = (s.easing)(t) as f64 * max_radius;
if radius <= 0.0 {
continue;
}
// Fade: la onda arranca a su alpha y se apaga al expandirse.
let fade = 1.0 - t;
let mut col = s.color;
col.components[3] *= fade;
if col.components[3] <= 0.0 {
continue;
}
// Recorte al contorno del nodo (respeta radio/esquinas), para que la
// onda no sangre fuera de un botón redondeado.
let rrect = crate::render::node_rrect(
r.x as f64,
r.y as f64,
(r.x + r.w) as f64,
(r.y + r.h) as f64,
node.radius,
node.corner_radii,
0.0,
);
scene.push_layer(Fill::NonZero, BlendMode::default(), 1.0, Affine::IDENTITY, &rrect);
let circle = Circle::new((cx, cy), radius);
scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
scene.pop_layer();
}
!self.splashes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{mount, View};
use llimphi_layout::taffy::prelude::*;
use llimphi_layout::{LayoutTree, Style};
fn rgba(r: u8, g: u8, b: u8, a: u8) -> Color {
Color::from_rgba8(r, g, b, a)
}
/// Monta un botón 100×100 con ripple(key=5) y devuelve (mounted, computed).
fn boton() -> (Mounted<()>, ComputedLayout) {
let v = View::<()>::new(Style {
size: Size { width: length(100.0), height: length(100.0) },
..Default::default()
})
.ripple(5, rgba(255, 255, 255, 80));
let mut layout = LayoutTree::new();
let m = mount(&mut layout, v);
let c = layout.compute(m.root, (200.0, 200.0)).expect("layout");
(m, c)
}
#[test]
fn sin_trigger_no_anima() {
let mut reg = RippleRegistry::new();
let (m, c) = boton();
let mut scene = Scene::new();
assert!(!reg.paint(&mut scene, &m, &c, Instant::now()));
assert!(!reg.animating());
}
#[test]
fn trigger_anima_y_se_autodetiene() {
let mut reg = RippleRegistry::new();
let (m, c) = boton();
let t0 = Instant::now();
reg.trigger(5, 50.0, 50.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
assert!(reg.animating(), "tras el trigger hay onda viva");
let mut scene = Scene::new();
// A mitad de la duración sigue animando.
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
// Pasada la duración, se descarta y el ticker para.
assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(250)));
assert!(!reg.animating());
}
#[test]
fn presses_concurrentes_apilan_ondas() {
let mut reg = RippleRegistry::new();
let t0 = Instant::now();
reg.trigger(5, 10.0, 10.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0);
reg.trigger(5, 90.0, 90.0, rgba(255, 255, 255, 80), Duration::from_millis(200), t0 + Duration::from_millis(20));
assert_eq!(reg.splashes.len(), 2);
let (m, c) = boton();
let mut scene = Scene::new();
// En t0+100 la primera vive (80ms restantes) y la segunda también.
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(100)));
assert_eq!(reg.splashes.len(), 2);
// En t0+210 la primera murió (210>200) pero la segunda vive (190<200).
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(210)));
assert_eq!(reg.splashes.len(), 1);
}
#[test]
fn key_inexistente_se_descarta_al_agotarse_sin_panico() {
// Una onda cuya key no existe en el árbol no debe pintar ni panico;
// simplemente no encuentra nodo y se descarta cuando su reloj vence.
let mut reg = RippleRegistry::new();
let t0 = Instant::now();
reg.trigger(999, 0.0, 0.0, rgba(255, 255, 255, 80), Duration::from_millis(100), t0);
let (m, c) = boton();
let mut scene = Scene::new();
assert!(reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(50)));
assert!(!reg.paint(&mut scene, &m, &c, t0 + Duration::from_millis(150)));
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Modelo de **semántica accesible** de un nodo. Es el dato que el runtime
//! traduce a un árbol [AccessKit](https://accesskit.dev) por frame para
//! alimentar lectores de pantalla (NVDA, VoiceOver, Orca, TalkBack) y otras
//! ayudas técnicas — TTS, navegación por voz, switch control.
//!
//! Este módulo es **pura data**: define los tipos sin acoplarse al crate
//! `accesskit`. La conversión a `accesskit::Node` vive en `llimphi-ui::a11y`
//! (iter 2 del plan), donde el cableado del adapter winit ya importa la
//! librería. Tener acá solo el modelo permite:
//!
//! - Compilar el compositor con o sin la integración AccessKit habilitada.
//! - Testear semántica a nivel "qué declaran los widgets" sin levantar un
//! adapter ni un lector real.
//! - Mantener la API estable aunque cambien versiones de `accesskit`.
//!
//! ## Cuándo declarar semántica
//!
//! - **Siempre** en controles interactivos: botones, inputs, checkboxes, tabs,
//! ítems de menú, sliders. Sin rol declarado, el lector no sabe que el nodo
//! ES un botón aunque tenga `on_click`.
//! - **Para texto significativo** que no es un botón: títulos (`Heading`),
//! etiquetas asociadas, valores (`Label` / `Static`). El text de un nodo se
//! lee igual aunque no tenga `semantics`, pero un rol explícito mejora la
//! navegación por rol de los lectores.
//! - **Para grouping**: tabbar, dock, toolbars, listas — `Role::Group` o un
//! rol específico (`TabList`, `Menu`, `Toolbar`) ayuda a saltar bloques.
//!
//! ## Cuándo NO declarar
//!
//! Decorativo puro (un divider, un fondo con gradiente, una sombra) **no debe**
//! declarar semántica — los lectores ya filtran texto vacío, pero un rol
//! superfluo (`Role::Group` en cada `View` envoltorio) ensucia la navegación.
use std::sync::Arc;
/// Rol semántico del nodo. Los nombres y la granularidad siguen los roles de
/// AccessKit / ARIA. Subset acotado: agregamos lo que falte cuando aparezca un
/// caller real (regla del repo — no diseñamos para lo hipotético).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Role {
/// Botón clickeable. El lector dice "botón <label>" + el flag `pressed`
/// para botones de toggle.
Button,
/// Campo de texto editable (single-line o multi-line). Combinable con
/// `value` (texto actual) y los flags `readonly`/`required`.
TextInput,
/// Título de sección (h1..h6 en HTML). El `value` puede llevar el nivel
/// como string ("1", "2", …) si la app lo necesita; v1 no lo distingue.
Heading,
/// Casilla de verificación. Combina con `checked`.
Checkbox,
/// Texto estático significativo (no interactivo, no título). Si solo es
/// decorativo, no declarar semántica.
Label,
/// Hipervínculo / acción que navega a otra ubicación.
Link,
/// Ítem de un menú (context-menu, menubar, dropdown).
MenuItem,
/// Pestaña de un tabbar / segmented control.
Tab,
/// Imagen significativa. El `label` actúa como alt-text.
Image,
/// Control deslizable continuo (volumen, brillo, range). Combinable con
/// `value` (string del valor actual) — los rangos numéricos se modelan
/// más fino en iter posteriores si hace falta.
Slider,
/// Agrupador genérico (toolbar, panel, sección). Sirve para que los
/// lectores ofrezcan "saltar al siguiente grupo".
Group,
}
/// Banderas booleanas del nodo accesible. Todas opcionales (`None` = no aplica,
/// que es distinto de "aplica pero es false"). Mantienelas en None salvo que el
/// widget realmente las exponga — los lectores diferencian "no es checkable" de
/// "es checkable y no checked".
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SemanticsFlags {
/// Estado de un checkbox / radio / toggle button.
pub checked: Option<bool>,
/// Estado on/off de un botón de toggle (separado de `checked` porque ARIA
/// los distingue: un toggle `<button>` usa `aria-pressed`, una checkbox
/// `aria-checked`).
pub pressed: Option<bool>,
/// Para acordeones, menús, tree-rows que se expanden.
pub expanded: Option<bool>,
/// El control está deshabilitado (no responde a input).
pub disabled: Option<bool>,
/// Sólo lectura (típicamente input de texto que no se edita).
pub readonly: Option<bool>,
/// Campo requerido (formularios).
pub required: Option<bool>,
}
impl SemanticsFlags {
pub const EMPTY: Self = Self {
checked: None,
pressed: None,
expanded: None,
disabled: None,
readonly: None,
required: None,
};
}
/// Especificación semántica completa de un nodo. Lo que el runtime traduce a
/// un `accesskit::Node` cada frame.
///
/// `label` es lo que el lector enuncia primero (el "nombre accesible"). Si el
/// nodo ya tiene un `text` visible y significativo, podés dejar `label = None`
/// y el runtime usará ese texto como nombre — pero declararlo explícito es más
/// robusto (e.g. un botón con sólo un ícono necesita label porque no hay texto
/// visible).
///
/// `value` es el dato dinámico (texto del input, valor del slider). El lector
/// suele leer label + value juntos: "Volumen, 70".
///
/// `description` es contexto adicional ("Disminuye el volumen del sistema").
/// Los lectores lo leen tras una pausa o con un atajo distinto; usalo para
/// info que ayude PERO no sobreloadées (los usuarios de TTS perciben ruido
/// más que falta de info).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SemanticsSpec {
pub role: Option<Role>,
pub label: Option<Arc<str>>,
pub description: Option<Arc<str>>,
pub value: Option<Arc<str>>,
pub flags: SemanticsFlags,
}
impl SemanticsSpec {
/// Especificación con sólo el rol fijado. Atajo común; los demás campos
/// quedan `None` y los flags vacíos.
pub fn role(role: Role) -> Self {
Self {
role: Some(role),
..Self::default()
}
}
/// Pone `label` (consumiendo cualquier valor previo).
pub fn with_label(mut self, s: impl Into<Arc<str>>) -> Self {
self.label = Some(s.into());
self
}
/// Pone `description`.
pub fn with_description(mut self, s: impl Into<Arc<str>>) -> Self {
self.description = Some(s.into());
self
}
/// Pone `value`.
pub fn with_value(mut self, s: impl Into<Arc<str>>) -> Self {
self.value = Some(s.into());
self
}
/// Pone `flags.checked = Some(v)`.
pub fn with_checked(mut self, v: bool) -> Self {
self.flags.checked = Some(v);
self
}
pub fn with_pressed(mut self, v: bool) -> Self {
self.flags.pressed = Some(v);
self
}
pub fn with_expanded(mut self, v: bool) -> Self {
self.flags.expanded = Some(v);
self
}
pub fn with_disabled(mut self, v: bool) -> Self {
self.flags.disabled = Some(v);
self
}
pub fn with_readonly(mut self, v: bool) -> Self {
self.flags.readonly = Some(v);
self
}
pub fn with_required(mut self, v: bool) -> Self {
self.flags.required = Some(v);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_es_todo_none_y_flags_empty() {
let s = SemanticsSpec::default();
assert!(s.role.is_none());
assert!(s.label.is_none());
assert!(s.value.is_none());
assert_eq!(s.flags, SemanticsFlags::EMPTY);
}
#[test]
fn role_builder_pone_solo_el_rol() {
let s = SemanticsSpec::role(Role::Button);
assert_eq!(s.role, Some(Role::Button));
assert!(s.label.is_none());
assert!(s.value.is_none());
assert_eq!(s.flags, SemanticsFlags::EMPTY);
}
#[test]
fn with_label_y_with_value_componen() {
let s = SemanticsSpec::role(Role::Slider)
.with_label("Volumen")
.with_value("70");
assert_eq!(s.role, Some(Role::Slider));
assert_eq!(s.label.as_deref(), Some("Volumen"));
assert_eq!(s.value.as_deref(), Some("70"));
}
#[test]
fn flags_con_with_son_independientes() {
let s = SemanticsSpec::role(Role::Checkbox)
.with_checked(true)
.with_required(true);
assert_eq!(s.flags.checked, Some(true));
assert_eq!(s.flags.required, Some(true));
assert!(s.flags.disabled.is_none(), "no setear flags no tocados");
}
}
File diff suppressed because it is too large Load Diff
+111
View File
@@ -57,6 +57,53 @@ fn parrafo_largo_reserva_varias_lineas() {
assert!(rect.w <= 200.0 + 1.0, "no debería exceder el ancho del bloque");
}
#[test]
fn no_wrap_mide_una_sola_linea_fase_7_1253() {
use llimphi_layout::taffy::AvailableSpace;
// Mismo texto largo, mismo ancho disponible (angosto): con `no_wrap` se
// mide en una sola línea (ancho completo, ignora el available); sin él,
// envuelve (más alto, ancho acotado al disponible).
let texto = "una linea larga que normalmente envuelve en varios renglones \
cuando el ancho disponible es angosto de verdad";
let mk = |no_wrap: bool| llimphi_compositor::TextMeasure {
content: texto.to_string(),
size_px: 16.0,
alignment: llimphi_text::Alignment::Start,
italic: false,
font_family: None,
line_height: 1.2,
weight: 400.0,
max_lines: None,
ellipsis: false,
underline: false,
strikethrough: false,
spans: None,
letter_spacing: 0.0,
word_spacing: 0.0,
no_wrap,
overflow_wrap: false,
};
let mut ts = llimphi_text::Typesetter::new();
let known = TSize { width: None, height: None };
let avail = TSize {
width: AvailableSpace::Definite(160.0),
height: AvailableSpace::MaxContent,
};
let env = measure_text_node(&mut ts, &mk(false), known, avail);
let nw = measure_text_node(&mut ts, &mk(true), known, avail);
// Envuelto: ancho acotado al disponible y alto de varias líneas.
assert!(env.width <= 160.0 + 1.0, "wrap acota el ancho: {}", env.width);
assert!(env.height > 40.0, "wrap reserva varias líneas: {}", env.height);
// no_wrap: una sola línea → mucho más ancho que el disponible y bajo.
assert!(nw.width > 160.0, "no_wrap mide ancho completo: {}", nw.width);
assert!(
nw.height < env.height,
"no_wrap es una línea (más bajo que el envuelto): nw={} env={}",
nw.height,
env.height
);
}
#[test]
fn line_height_mayor_reserva_mas_alto() {
let texto = "una línea de texto que envuelve en dos o tres renglones según \
@@ -70,6 +117,16 @@ fn line_height_mayor_reserva_mas_alto() {
italic: false,
font_family: None,
line_height: lh,
weight: 400.0,
max_lines: None,
ellipsis: false,
underline: false,
strikethrough: false,
spans: None,
letter_spacing: 0.0,
word_spacing: 0.0,
no_wrap: false,
overflow_wrap: false,
};
let known = TSize { width: Some(180.0_f32), height: None };
let avail = TSize {
@@ -85,3 +142,57 @@ fn line_height_mayor_reserva_mas_alto() {
"line-height: 2 debería reservar bastante más alto que 1.0 (got {compacto} vs {comodo})"
);
}
#[test]
fn overflow_wrap_parte_la_palabra_larga_fase_7_1254() {
use llimphi_layout::taffy::AvailableSpace;
// Una sola palabra sin espacios, más ancha que la caja angosta. Sin
// `overflow-wrap` parley la deja desbordar (mide más ancho que la caja);
// con `overflow-wrap` la parte para que entre (ancho acotado, varias líneas
// ⇒ más alto). Es el regresor directo de la Fase 7.1254.
let palabrota = "supercalifragilisticoexpialidosoineluctableantidisestablishmentariano";
let mk = |overflow_wrap: bool| llimphi_compositor::TextMeasure {
content: palabrota.to_string(),
size_px: 16.0,
alignment: llimphi_text::Alignment::Start,
italic: false,
font_family: None,
line_height: 1.2,
weight: 400.0,
max_lines: None,
ellipsis: false,
underline: false,
strikethrough: false,
spans: None,
letter_spacing: 0.0,
word_spacing: 0.0,
no_wrap: false,
overflow_wrap,
};
let mut ts = llimphi_text::Typesetter::new();
let known = TSize { width: None, height: None };
let avail = TSize {
width: AvailableSpace::Definite(80.0),
height: AvailableSpace::MaxContent,
};
let normal = measure_text_node(&mut ts, &mk(false), known, avail);
let roto = measure_text_node(&mut ts, &mk(true), known, avail);
// Sin overflow-wrap: la palabra desborda → ancho mayor que la caja.
assert!(
normal.width > 80.0,
"sin overflow-wrap la palabra desborda: {}",
normal.width
);
// Con overflow-wrap: se parte → ancho acotado a la caja y más alto.
assert!(
roto.width <= 80.0 + 1.0,
"overflow-wrap acota el ancho a la caja: {}",
roto.width
);
assert!(
roto.height > normal.height,
"overflow-wrap parte en varias líneas (más alto): roto={} normal={}",
roto.height,
normal.height
);
}
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`."
[[bin]]
+3 -2
View File
@@ -5,11 +5,12 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "Binario standalone que valida el SDD §'GPU directo wgpu' en una máquina con GPU real: imprime info del adapter, corre vello vs GPU directo a varios N, evalúa el criterio (≥5× a 500K, ≥60 fps @ 1M) y exporta PNGs de verificación."
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
vello = { workspace = true }
pollster = { workspace = true }
png = { workspace = true }
+2
View File
@@ -1,10 +1,12 @@
[package]
name = "llimphi-hal"
description = "Surface/device HAL for the llimphi UI framework (wgpu) — one scene tree across Wayland/X11/Win32/Android/bare-metal."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
wgpu = { workspace = true }
+1
View File
@@ -95,6 +95,7 @@ impl ApplicationHandler for App {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame.view(),
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
+771 -20
View File
@@ -145,10 +145,13 @@ impl Hal {
..Default::default()
});
let (instance, adapter) = match primary.request_adapter(&opts).await {
Some(a) => (primary, a),
None => {
Ok(a) => (primary, a),
Err(_) => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let a = all.request_adapter(&opts).await.ok_or(HalError::NoAdapter)?;
let a = all
.request_adapter(&opts)
.await
.map_err(|_| HalError::NoAdapter)?;
(all, a)
}
};
@@ -158,15 +161,14 @@ impl Hal {
// (texturas/buffers grandes) preservando los conteos mínimos.
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
.request_device(&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
experimental_features: wgpu::ExperimentalFeatures::default(),
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
Ok(Self {
@@ -220,8 +222,8 @@ impl Hal {
})
.await;
let (instance, adapter, wgpu_surface) = match prim_adapter {
Some(a) => (primary, a, prim_surface),
None => {
Ok(a) => (primary, a, prim_surface),
Err(_) => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let surface = unsafe { all.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
@@ -232,21 +234,20 @@ impl Hal {
compatible_surface: Some(&surface),
})
.await
.ok_or(HalError::NoAdapter)?;
.map_err(|_| HalError::NoAdapter)?;
(all, a, surface)
}
};
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
.request_device(&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
experimental_features: wgpu::ExperimentalFeatures::default(),
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
let hal = Self {
@@ -403,11 +404,31 @@ impl RawSurface {
)))
}
};
let alpha_mode = caps
.alpha_modes
.first()
// Para una layer surface (wlr-layer-shell) la transparencia es
// crítica: la usamos para popovers/menús que pintan un panel chico y
// dejan el resto transparente para ver el escritorio. La heurística
// ingenua `caps.alpha_modes.first()` cae a veces en `Opaque` (el
// compositor descarta alpha) — el clear TRANSPARENT se compone como
// negro literal y el menú inicio sale como un cuadrón negro.
//
// Preferencia: PreMultiplied > PostMultiplied > Inherit > Auto >
// Opaque. Los dos primeros componen alpha como esperamos; los dos
// siguientes dejan que el compositor decida (típicamente respeta el
// alpha del buffer ARGB); Opaque es el último recurso.
let alpha_mode = {
use wgpu::CompositeAlphaMode as Mode;
let want = [
Mode::PreMultiplied,
Mode::PostMultiplied,
Mode::Inherit,
Mode::Auto,
];
want.iter()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto);
.find(|m| caps.alpha_modes.contains(m))
.or_else(|| caps.alpha_modes.first().copied())
.unwrap_or(Mode::Auto)
};
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
@@ -719,6 +740,7 @@ impl OverlayCompositor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
@@ -766,6 +788,735 @@ fn fs(in: VsOut) -> @location(0) vec4<f32> {
}
"#;
/// Gaussian backdrop blur sobre la intermediate (la textura donde vello pinta
/// la UI). El compositor empuja dos render passes separables (horizontal +
/// vertical) restringidas por scissor al rect del nodo `.backdrop_blur(sigma)`,
/// usando una textura scratch interna del mismo tamaño que la intermediate.
///
/// **Pipeline**: vs = triángulo grande full-screen (clip-space), fs = suma
/// ponderada de N samples a lo largo de `direction`, pesos Gauss `exp(-i²/2σ²)`.
/// El bind group lleva la textura source + sampler bilinear + UBO con
/// `(direction, pixel_size, sigma, radius)`. El scissor recorta el output al
/// rect del nodo; el resto del target queda intacto (LoadOp::Load).
///
/// **Coste**: una pasada por dirección por nodo blur, ~`2*radius+1` taps por
/// pixel del rect. Para `sigma=8` (radius=24), ~49 taps/pixel — barato si el
/// rect es pequeño (chrome), pesado si es full-screen. v1: sin cap dinámico,
/// se asume que el caller no abusa.
///
/// **Limitaciones v1**:
/// - Un scratch full-screen alocado por compositor; resize sigue al `Surface`.
/// - `radius` cap en 32 — sigmas > ~10 se ven menos suaves (clip de cola).
/// - Bordes del rect: clamp-to-edge (sampler) → los pixeles fuera del rect
/// que se muestrean en la cola del Gauss salen como espejo del borde. En
/// un viewport razonable la diferencia es invisible; documentado.
pub struct BlurCompositor {
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
bind_layout: wgpu::BindGroupLayout,
scratch: Option<BlurScratch>,
}
struct BlurScratch {
_texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
}
/// Layout en GPU del UBO del blur. Debe coincidir con el `BlurParams` del WGSL.
/// Padding explícito al final para llegar a múltiplo de 16 bytes (alignment
/// estándar de uniformes en wgpu).
#[repr(C)]
#[derive(Clone, Copy)]
struct BlurUniforms {
direction: [f32; 2],
pixel_size: [f32; 2],
sigma: f32,
radius: f32,
_pad: [f32; 2],
}
const BLUR_UBO_SIZE: u64 = std::mem::size_of::<BlurUniforms>() as u64;
const BLUR_MAX_RADIUS: f32 = 32.0;
impl BlurCompositor {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-blur-shader"),
source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-blur-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-blur-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-blur-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState {
format: INTERMEDIATE_FORMAT,
// El blur OVERWRITE el rect; no necesita alpha-over. El
// resultado del Gauss es opaco si los pixeles muestreados
// lo son (la intermediate tiene UI + background opaco).
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-blur-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
BlurCompositor {
pipeline,
sampler,
bind_layout,
scratch: None,
}
}
/// Aplica un blur Gaussiano sobre `target` en el rect dado (coords pixel
/// del viewport). Si el rect cae fuera del viewport, no hace nada. Usa
/// un scratch interno del mismo tamaño que el viewport — se aloca lazy y
/// se reusa entre frames; se recrea si el viewport cambió.
///
/// `sigma` controla el ancho del kernel. ~`σ=4` da "frosted glass" suave,
/// `σ=16` un blur fuerte. El radius efectivo se cap a [`BLUR_MAX_RADIUS`].
pub fn blur(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
viewport: (u32, u32),
rect: (f32, f32, f32, f32),
sigma: f32,
) {
let (vw, vh) = viewport;
if vw == 0 || vh == 0 || sigma <= 0.0 {
return;
}
let (rx, ry, rw, rh) = rect;
// Clamp scissor al viewport (un rect fuera del viewport pifia el
// RenderPass).
let x0 = rx.max(0.0) as u32;
let y0 = ry.max(0.0) as u32;
let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
if x1 <= x0 || y1 <= y0 {
return;
}
let scissor = (x0, y0, x1 - x0, y1 - y0);
// Scratch del tamaño del viewport. Si cambió, recrear.
let need_new = match &self.scratch {
Some(s) => s.width != vw || s.height != vh,
None => true,
};
if need_new {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-blur-scratch"),
size: wgpu::Extent3d {
width: vw,
height: vh,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.scratch = Some(BlurScratch {
_texture: texture,
view,
width: vw,
height: vh,
});
}
let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
let radius = (sigma * 3.0).ceil().min(BLUR_MAX_RADIUS);
let pixel_size = [1.0 / vw as f32, 1.0 / vh as f32];
let ubo_h_data = BlurUniforms {
direction: [1.0, 0.0],
pixel_size,
sigma,
radius,
_pad: [0.0, 0.0],
};
let ubo_v_data = BlurUniforms {
direction: [0.0, 1.0],
pixel_size,
sigma,
radius,
_pad: [0.0, 0.0],
};
// UBOs por llamada (ver nota en `ColorFilterCompositor::apply`): varios
// blurs en el mismo submit con sigmas distintos no deben aliasar un UBO
// compartido (ganaría el último). Buffers frescos por llamada (32 bytes).
let ubo_h = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-blur-ubo-h"),
size: BLUR_UBO_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ubo_v = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-blur-ubo-v"),
size: BLUR_UBO_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&ubo_h, 0, bytemuck_cast(&ubo_h_data));
queue.write_buffer(&ubo_v, 0, bytemuck_cast(&ubo_v_data));
// Pass 1: target → scratch (horizontal).
let bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-blur-bg-h"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(target),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: ubo_h.as_entire_binding(),
},
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-blur-pass-h"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: scratch_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
// No nos importa qué hay fuera del scissor: el segundo
// pase sólo lee dentro del scissor también.
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg_h, &[]);
pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
pass.draw(0..3, 0..1);
}
// Pass 2: scratch → target (vertical), preservando lo fuera del scissor.
let bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-blur-bg-v"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scratch_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: ubo_v.as_entire_binding(),
},
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-blur-pass-v"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg_v, &[]);
pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
pass.draw(0..3, 0..1);
}
}
}
/// Aplica una **matriz de color 4×5** (CSS `filter: brightness/contrast/
/// grayscale/sepia/saturate/invert/hue-rotate/opacity`) sobre un rect de la
/// intermediate. Espejo de [`BlurCompositor`] pero con un fragment shader que
/// multiplica cada píxel por la matriz: `out = M·rgba + bias`, clampeado a
/// `[0,1]`. Dos pases (target→scratch aplicando la matriz, scratch→target
/// copia identidad) por la misma razón que el blur: un render pass no puede
/// leer y escribir la misma textura. Fase 7.1233.
pub struct ColorFilterCompositor {
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
bind_layout: wgpu::BindGroupLayout,
scratch: Option<BlurScratch>,
}
/// UBO de la matriz de color. 5 `vec4` (filas R/G/B/A + bias) = 80 bytes,
/// múltiplo de 16. Debe coincidir con `ColorParams` del WGSL.
#[repr(C)]
#[derive(Clone, Copy)]
struct ColorUniforms {
r: [f32; 4],
g: [f32; 4],
b: [f32; 4],
a: [f32; 4],
bias: [f32; 4],
}
const COLOR_UBO_SIZE: u64 = std::mem::size_of::<ColorUniforms>() as u64;
/// La matriz identidad (copia sin cambios), usada en el segundo pase.
const COLOR_IDENTITY: ColorUniforms = ColorUniforms {
r: [1.0, 0.0, 0.0, 0.0],
g: [0.0, 1.0, 0.0, 0.0],
b: [0.0, 0.0, 1.0, 0.0],
a: [0.0, 0.0, 0.0, 1.0],
bias: [0.0, 0.0, 0.0, 0.0],
};
impl ColorFilterCompositor {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-color-filter-shader"),
source: wgpu::ShaderSource::Wgsl(COLOR_WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-color-filter-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-color-filter-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-color-filter-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState {
format: INTERMEDIATE_FORMAT,
// OVERWRITE el rect, igual que el blur — el resultado de la
// matriz reemplaza el píxel.
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-color-filter-sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
ColorFilterCompositor {
pipeline,
sampler,
bind_layout,
scratch: None,
}
}
/// Aplica la matriz de color `matrix` (4×5 row-major: por fila
/// `[c0, c1, c2, c3, bias]`, salida R/G/B/A) sobre `target` en el rect dado
/// (coords pixel del viewport). Fuera del viewport no hace nada. Usa un
/// scratch del tamaño del viewport (lazy, reusado entre frames).
pub fn apply(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
viewport: (u32, u32),
rect: (f32, f32, f32, f32),
matrix: [f32; 20],
) {
let (vw, vh) = viewport;
if vw == 0 || vh == 0 {
return;
}
let (rx, ry, rw, rh) = rect;
let x0 = rx.max(0.0) as u32;
let y0 = ry.max(0.0) as u32;
let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
if x1 <= x0 || y1 <= y0 {
return;
}
let scissor = (x0, y0, x1 - x0, y1 - y0);
let need_new = match &self.scratch {
Some(s) => s.width != vw || s.height != vh,
None => true,
};
if need_new {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-color-filter-scratch"),
size: wgpu::Extent3d {
width: vw,
height: vh,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
self.scratch = Some(BlurScratch {
_texture: texture,
view,
width: vw,
height: vh,
});
}
let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
// El [f32;20] viene por filas de 5 (`[c0,c1,c2,c3,bias]`); lo partimos
// en 4 vec4 de coeficientes + un vec4 de bias para el UBO.
let apply = ColorUniforms {
r: [matrix[0], matrix[1], matrix[2], matrix[3]],
g: [matrix[5], matrix[6], matrix[7], matrix[8]],
b: [matrix[10], matrix[11], matrix[12], matrix[13]],
a: [matrix[15], matrix[16], matrix[17], matrix[18]],
bias: [matrix[4], matrix[9], matrix[14], matrix[19]],
};
// UBOs **por llamada**: varias `apply` en el mismo encoder/submit
// comparten cola; `write_buffer` se aplica una vez antes de los command
// buffers (gana el último valor escrito), así que un UBO compartido haría
// que todas las pasadas leyeran la última matriz. Buffers frescos por
// llamada evitan ese alias (80 bytes c/u, despreciable).
let ubo_apply = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-color-filter-ubo-apply"),
size: COLOR_UBO_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let ubo_copy = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-color-filter-ubo-copy"),
size: COLOR_UBO_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&ubo_apply, 0, bytemuck_cast(&apply));
queue.write_buffer(&ubo_copy, 0, bytemuck_cast(&COLOR_IDENTITY));
// Pass 1: target → scratch (aplica la matriz).
let bg_apply = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-color-filter-bg-apply"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(target),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: ubo_apply.as_entire_binding(),
},
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-color-filter-pass-apply"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: scratch_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg_apply, &[]);
pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
pass.draw(0..3, 0..1);
}
// Pass 2: scratch → target (copia identidad), preservando lo de afuera.
let bg_copy = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-color-filter-bg-copy"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scratch_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: ubo_copy.as_entire_binding(),
},
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-color-filter-pass-copy"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bg_copy, &[]);
pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
pass.draw(0..3, 0..1);
}
}
}
/// "bytemuck" minimal sin dep: convierte `&T` a `&[u8]`. Sólo para POD repr(C)
/// — usado para escribir los UBOs del blur con `queue.write_buffer`.
fn bytemuck_cast<T: Copy>(v: &T) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
v as *const T as *const u8,
std::mem::size_of::<T>(),
)
}
}
/// Separable Gaussian, una dirección por pase. El vs es el mismo triángulo
/// grande del overlay; el fs samplea `2*radius+1` taps a lo largo de
/// `direction*pixel_size`. Pesos `exp(-i²/2σ²)` normalizados por la suma —
/// independiente del radius por si quedó cortada la cola.
const BLUR_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let xy = corners[vi];
var out: VsOut;
out.pos = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
return out;
}
struct BlurParams {
direction: vec2<f32>,
pixel_size: vec2<f32>,
sigma: f32,
radius: f32,
_pad: vec2<f32>,
};
@group(0) @binding(0) var src_tex: texture_2d<f32>;
@group(0) @binding(1) var src_samp: sampler;
@group(0) @binding(2) var<uniform> params: BlurParams;
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
let dir = params.direction * params.pixel_size;
let r = i32(params.radius);
let two_sigma_sq = 2.0 * params.sigma * params.sigma;
var acc = vec4<f32>(0.0);
var weight_sum = 0.0;
for (var i = -r; i <= r; i = i + 1) {
let fi = f32(i);
let w = exp(-(fi * fi) / two_sigma_sq);
acc = acc + textureSample(src_tex, src_samp, in.uv + dir * fi) * w;
weight_sum = weight_sum + w;
}
return acc / weight_sum;
}
"#;
/// Matriz de color 4×5: `out = M·rgba + bias`, clampeado a `[0,1]`. El vs es el
/// mismo triángulo grande; el fs hace 4 `dot` (una fila por canal) más el bias.
const COLOR_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let xy = corners[vi];
var out: VsOut;
out.pos = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
return out;
}
struct ColorParams {
r: vec4<f32>,
g: vec4<f32>,
b: vec4<f32>,
a: vec4<f32>,
bias: vec4<f32>,
};
@group(0) @binding(0) var src_tex: texture_2d<f32>;
@group(0) @binding(1) var src_samp: sampler;
@group(0) @binding(2) var<uniform> params: ColorParams;
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
let c = textureSample(src_tex, src_samp, in.uv);
var o: vec4<f32>;
o.r = dot(params.r, c) + params.bias.r;
o.g = dot(params.g, c) + params.bias.g;
o.b = dot(params.b, c) + params.bias.b;
o.a = dot(params.a, c) + params.bias.a;
return clamp(o, vec4<f32>(0.0), vec4<f32>(1.0));
}
"#;
impl Surface for WinitSurface {
fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
+2 -1
View File
@@ -5,7 +5,8 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI gioser."
repository.workspace = true
description = "llimphi-icons — set mínimo de iconos vectoriales (BezPath en grid 24×24) renderizables vía paint_with. Stroke-based, escalables. Cubre las acciones canónicas de cualquier UI tawasuyu."
[dependencies]
llimphi-ui = { workspace = true }
+1 -1
View File
@@ -1,4 +1,4 @@
//! Galería de los iconos de marca de todas las apps de gioser.
//! Galería de los iconos de marca de todas las apps de tawasuyu.
//!
//! Pinta los 29 [`AppIcon`] en una grilla, cada uno en su color de marca
//! con su nombre debajo. Sirve para eyeballear de un vistazo que el set
+2 -2
View File
@@ -1,4 +1,4 @@
//! `app_icons` — iconos de marca, uno por dominio/app de gioser.
//! `app_icons` — iconos de marca, uno por dominio/app de tawasuyu.
//!
//! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de
//! acción: file, save, search…), acá vive **un glifo distintivo por app**.
@@ -31,7 +31,7 @@ use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::View;
/// Una app de gioser con icono de marca. El identificador (`name`) coincide
/// Una app de tawasuyu con icono de marca. El identificador (`name`) coincide
/// con el `id` del `AppEntry` en `app-bus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppIcon {
+75 -2
View File
@@ -1,4 +1,4 @@
//! `llimphi-icons` — set canónico de iconos vectoriales para apps gioser.
//! `llimphi-icons` — set canónico de iconos vectoriales para apps tawasuyu.
//!
//! Cada icono es una función pura que devuelve un `BezPath` definido en
//! un grid lógico de **24×24 unidades**. El renderer escala al rect que
@@ -16,7 +16,7 @@
//! no "marca registrada". Cada uno debe ser reconocible al primer
//! vistazo aún en 12×12.
//! - **Set acotado**: suficientes para cubrir el grueso de acciones y
//! tipos que aparecen en cualquier UI gioser. Si una app necesita uno
//! tipos que aparecen en cualquier UI tawasuyu. Si una app necesita uno
//! más, lo agrega aquí (no en su propio crate) — la consistencia
//! visual importa más que el aislamiento.
//!
@@ -115,6 +115,15 @@ pub enum Icon {
FileText,
Link,
Font,
// --- Vistas / layout (toolbars de file manager) ---
/// Grilla 2×2 (vista iconos / galería).
Grid,
/// Tres filas horizontales (vista lista).
Rows,
/// Tabla: marco + fila de encabezado + columnas (vista detalle).
Table,
/// Dos paneles verticales lado a lado (modo dual).
Columns,
}
impl Icon {
@@ -169,6 +178,10 @@ impl Icon {
Icon::FileText => "file_text",
Icon::Link => "link",
Icon::Font => "font",
Icon::Grid => "grid",
Icon::Rows => "rows",
Icon::Table => "table",
Icon::Columns => "columns",
}
}
@@ -223,6 +236,10 @@ impl Icon {
Icon::FileText => path_file_text(),
Icon::Link => path_link(),
Icon::Font => path_font(),
Icon::Grid => path_grid(),
Icon::Rows => path_rows(),
Icon::Table => path_table(),
Icon::Columns => path_columns(),
}
}
}
@@ -952,6 +969,62 @@ fn path_font() -> BezPath {
p
}
fn path_grid() -> BezPath {
let mut p = BezPath::new();
// Cuatro celdas 2x2.
for (x, y) in [(4.0, 4.0), (13.0, 4.0), (4.0, 13.0), (13.0, 13.0)] {
p.move_to((x, y));
p.line_to((x + 7.0, y));
p.line_to((x + 7.0, y + 7.0));
p.line_to((x, y + 7.0));
p.close_path();
}
p
}
fn path_rows() -> BezPath {
let mut p = BezPath::new();
// Tres filas (vista lista).
for y in [6.0, 12.0, 18.0] {
p.move_to((4.0, y));
p.line_to((20.0, y));
}
p
}
fn path_table() -> BezPath {
let mut p = BezPath::new();
// Marco.
p.move_to((4.0, 5.0));
p.line_to((20.0, 5.0));
p.line_to((20.0, 19.0));
p.line_to((4.0, 19.0));
p.close_path();
// Fila de encabezado.
p.move_to((4.0, 9.5));
p.line_to((20.0, 9.5));
// Separador de columnas (debajo del encabezado).
p.move_to((11.0, 9.5));
p.line_to((11.0, 19.0));
p
}
fn path_columns() -> BezPath {
let mut p = BezPath::new();
// Dos paneles verticales lado a lado (modo dual).
p.move_to((4.0, 4.0));
p.line_to((11.0, 4.0));
p.line_to((11.0, 20.0));
p.line_to((4.0, 20.0));
p.close_path();
p.move_to((13.0, 4.0));
p.line_to((20.0, 4.0));
p.line_to((20.0, 20.0));
p.line_to((13.0, 20.0));
p.close_path();
p
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -1,10 +1,12 @@
[package]
name = "llimphi-layout"
description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
taffy = { workspace = true }
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-motion — Tween<T> + helpers de animación integrados al bucle Elm de llimphi-ui (Handle::spawn_periodic). Lerp para f32, Color, (f32,f32). Easings comparten convenciones de llimphi-theme::motion."
[dependencies]
+1 -1
View File
@@ -227,7 +227,7 @@ mod tests {
fn lerp_color_endpoints() {
let a = Color::from_rgba8(0, 0, 0, 0);
let b = Color::from_rgba8(255, 255, 255, 255);
let mid = a.lerp(b, 0.5);
let mid = <Color as Lerp>::lerp(a, b, 0.5);
let [r, g, bl, al] = mid.components;
assert!((r - 0.5).abs() < 1e-3);
assert!((g - 0.5).abs() < 1e-3);
+15 -1
View File
@@ -1,16 +1,30 @@
[package]
name = "llimphi-raster"
description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
vello = { workspace = true }
# Renderer "hybrid" CPU+GPU sin compute shaders. Útil para targets sin
# WebGPU completo (WebGL2, Adreno/Mali viejas) — alinea con el plan
# Android del workspace. Opt-in vía la feature `hybrid`; el default
# sigue siendo el renderer "wgpu" full-GPU de vello.
vello_hybrid = { workspace = true, optional = true }
pollster = { workspace = true }
[features]
default = []
# Activa el renderer alternativo vello_hybrid (CPU+GPU, sin compute).
# Re-exporta `vello_hybrid` desde el crate para que apps avanzadas
# puedan instanciar `vello_hybrid::Renderer` cuando el target lo pida.
hybrid = ["dep:vello_hybrid"]
[[example]]
name = "render_node"
path = "examples/render_node.rs"
@@ -100,7 +100,7 @@ fn bench(hal: &Hal, pipelines: &GpuPipelines, view: &wgpu::TextureView, n: u32)
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP {
samples.push(dt);
+3 -2
View File
@@ -191,7 +191,7 @@ fn bench_vello(
.expect("vello render");
// Bloquear hasta que la GPU termine este frame. Sin esto medimos
// sólo el submit + queue building, no el trabajo real.
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP_FRAMES {
samples.push(dt);
@@ -233,6 +233,7 @@ fn bench_directo(
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
@@ -248,7 +249,7 @@ fn bench_directo(
pass.draw(0..6, 0..points.len() as u32);
}
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
let dt = t0.elapsed().as_secs_f64() * 1000.0;
if frame >= WARMUP_FRAMES {
samples.push(dt);
+433 -14
View File
@@ -1,7 +1,7 @@
//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu").
//!
//! Tres pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
//! rects) + un acumulador [`GpuBatch`] que las apps usan por frame para
//! Cuatro pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
//! rects / discs) + un acumulador [`GpuBatch`] que las apps usan por frame para
//! emitir centenares de miles a millones de primitivos en una draw call
//! por tipo, sin pasar por vello.
//!
@@ -10,8 +10,11 @@
//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
//! - Instance format líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg).
//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect).
//! - Sin texturas. Sin AA por shader — quien necesite AA fino sigue por
//! vello. Para puntos densos el "popping" no se nota.
//! - Instance format discos: `[cx, cy, r, stroke, rgba]` (20 B/disco).
//! - Sin texturas. Rects/líneas/tris obtienen AA de **bordes** vía MSAA 4×
//! (ver más abajo); los discos SÍ traen AA por SDF en el fragment
//! (smoothstep sobre `fwidth`), que MSAA respeta. Así rects/tris/líneas
//! instanciados salen con bordes suaves sin que el caller toque nada.
//! - Blending alfa habilitado: el alpha del color es respetado.
//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
//! los shaders convierten pixel → NDC ahí.
@@ -25,10 +28,36 @@
//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
//! introducirá el `GpuBuffers` persistente que dobla capacidad si
//! aparece la necesidad.
//!
//! ## MSAA 4× (antialiasing de bordes)
//!
//! El pase no dibuja directo sobre el `view` que recibe `flush`. En su
//! lugar rasteriza todos los primitivos a una textura **multisample 4×**
//! (cleared a transparente), la *resuelve* a una textura single-sample
//! scratch y **compone con alpha** ese resultado sobre el `view`. Así:
//!
//! - Los bordes de rects/tris/líneas quedan suaves (4 muestras/pixel),
//! no escalonados.
//! - El contenido previo del `view` (lo que vello pintó) se preserva,
//! porque el composite es alpha-over con `LoadOp::Load` — exactamente
//! la semántica que tenía el viejo render pass directo con `LoadOp::Load`.
//! - `LoadOp::Clear(c)` se respeta: el `view` se limpia a `c` antes del
//! composite (equivalente a la pasada directa anterior).
//!
//! Backward-compat: la firma pública de `flush` / `GpuPipelines::new` no
//! cambia. Las texturas MSAA + scratch se crean por-flush dimensionadas
//! al `viewport` (mismo patrón que los buffers por-frame), así el resize
//! "sale gratis" — cada frame usa el tamaño que se le pasa, sin estado
//! persistente que recrear. El pipeline de composite se compila una vez
//! y se cachea en `GpuPipelines` (es `Sync`, vive en `OnceLock`).
use llimphi_hal::wgpu;
use vello::peniko::Color;
/// Número de muestras del MSAA del pase GPU. 4× es el punto dulce
/// universal (soportado por todo hardware moderno, coste moderado).
const MSAA_SAMPLES: u32 = 4;
/// Pipelines cacheadas. Crear uno por proceso (o por surface format).
///
/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La
@@ -46,7 +75,21 @@ pub struct GpuPipelines {
pub lines: wgpu::RenderPipeline,
pub tris: wgpu::RenderPipeline,
pub rects: wgpu::RenderPipeline,
/// Discos/anillos rellenos con AA por SDF en el fragment. Instance
/// format: `[cx, cy, r, stroke, rgba]` (20 B/disco). `stroke <= 0`
/// → disco lleno; `stroke > 0` → anillo de ese grosor (px). Ver
/// [`GpuBatch::add_disc`] / [`GpuBatch::add_ring`].
pub discs: wgpu::RenderPipeline,
pub bind_layout: wgpu::BindGroupLayout,
/// Pipeline de pantalla completa que compone (alpha-over) la textura
/// scratch resuelta del MSAA sobre el `view` del `flush`. Single-sample.
/// El formato del target es el `color_format` con el que se construyó.
composite: wgpu::RenderPipeline,
composite_bgl: wgpu::BindGroupLayout,
composite_sampler: wgpu::Sampler,
/// Formato de color del target — necesario para crear las texturas
/// MSAA/scratch del `flush` con el mismo formato que el `view`.
color_format: wgpu::TextureFormat,
}
impl GpuPipelines {
@@ -112,7 +155,10 @@ impl GpuPipelines {
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
..Default::default()
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
@@ -155,7 +201,10 @@ impl GpuPipelines {
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
..Default::default()
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
@@ -195,7 +244,10 @@ impl GpuPipelines {
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
..Default::default()
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
@@ -206,11 +258,142 @@ impl GpuPipelines {
cache: None,
});
// Discos/anillos (instanced quad + SDF AA en el fragment). Cada
// disco es una instancia de 24 B: `[cx, cy, r, stroke, rgba]`. El
// VS expande un quad que cubre el disco (con 1 px de margen para
// que el smoothstep del borde no se recorte) y pasa al FS la
// posición local en px; el FS calcula la distancia al centro y
// hace smoothstep sobre ~1 px (`fwidth`) → borde antialiased.
let discs = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-raster-gpu-discs"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_discs"),
compilation_options: Default::default(),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
// cx, cy
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
// r, stroke
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 8,
shader_location: 1,
},
// rgba
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Uint32,
offset: 16,
shader_location: 2,
},
],
}],
},
primitive: tri_primitive(),
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
..Default::default()
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_disc"),
compilation_options: Default::default(),
targets: &color_targets,
}),
multiview: None,
cache: None,
});
// Pipeline de composite (alpha-over) de la scratch resuelta del
// MSAA sobre el `view`. Single-sample (count = 1), pase fullscreen
// de un triángulo. Asume alpha **premultiplicado** — el MSAA + el
// blending de los primitivos producen color premultiplicado, así
// que el over correcto es `src.rgb*1 + dst.rgb*(1-src.a)`.
let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-raster-gpu-composite-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let composite_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-raster-gpu-composite-pl"),
bind_group_layouts: &[&composite_bgl],
push_constant_ranges: &[],
});
let composite = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-raster-gpu-composite"),
layout: Some(&composite_pl),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_composite"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_composite"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-raster-gpu-composite-sampler"),
..Default::default()
});
Self {
lines,
tris,
rects,
discs,
bind_layout,
composite,
composite_bgl,
composite_sampler,
color_format,
}
}
}
@@ -233,10 +416,12 @@ pub struct GpuBatch<'a> {
line_verts: Vec<u8>,
tri_verts: Vec<u8>,
rect_insts: Vec<u8>,
disc_insts: Vec<u8>,
line_width: f32,
line_count: u32,
tri_vert_count: u32,
rect_count: u32,
disc_count: u32,
}
impl<'a> GpuBatch<'a> {
@@ -246,10 +431,12 @@ impl<'a> GpuBatch<'a> {
line_verts: Vec::new(),
tri_verts: Vec::new(),
rect_insts: Vec::new(),
disc_insts: Vec::new(),
line_width: 1.0,
line_count: 0,
tri_vert_count: 0,
rect_count: 0,
disc_count: 0,
}
}
@@ -326,9 +513,37 @@ impl<'a> GpuBatch<'a> {
self.rect_count += 1;
}
/// Añade un disco (círculo relleno) con AA por shader como instancia.
/// `(cx, cy)` es el centro y `r` el radio, ambos en pixels del frame.
/// El borde queda antialiased vía un SDF + `smoothstep` de ~1 px en
/// el fragment — no escalonado, sin MSAA. El alpha del color se
/// respeta (blending alfa activo).
pub fn add_disc(&mut self, cx: f32, cy: f32, r: f32, color: Color) {
self.push_disc(cx, cy, r, 0.0, color);
}
/// Añade un anillo (círculo hueco / stroke circular) con AA por
/// shader. `r` es el radio exterior; `stroke` el grosor del trazo en
/// px (el agujero interior tiene radio `r - stroke`). `stroke <= 0`
/// degenera en un disco lleno. Ambos bordes (externo e interno)
/// quedan antialiased.
pub fn add_ring(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
self.push_disc(cx, cy, r, stroke.max(0.0), color);
}
fn push_disc(&mut self, cx: f32, cy: f32, r: f32, stroke: f32, color: Color) {
let rgba = pack_rgba(color);
self.disc_insts.extend_from_slice(&cx.to_ne_bytes());
self.disc_insts.extend_from_slice(&cy.to_ne_bytes());
self.disc_insts.extend_from_slice(&r.to_ne_bytes());
self.disc_insts.extend_from_slice(&stroke.to_ne_bytes());
self.disc_insts.extend_from_slice(&rgba.to_ne_bytes());
self.disc_count += 1;
}
/// Cuenta total de primitivas pendientes (útil para benches).
pub fn primitive_count(&self) -> u32 {
self.line_count + self.rect_count + self.tri_vert_count / 3
self.line_count + self.rect_count + self.disc_count + self.tri_vert_count / 3
}
/// Despacha las primitivas acumuladas como 1 draw call por tipo
@@ -348,7 +563,8 @@ impl<'a> GpuBatch<'a> {
viewport: (f32, f32),
load_op: wgpu::LoadOp<wgpu::Color>,
) {
let total = self.line_count + self.tri_vert_count + self.rect_count;
let total =
self.line_count + self.tri_vert_count + self.rect_count + self.disc_count;
if total == 0 {
return;
}
@@ -407,14 +623,71 @@ impl<'a> GpuBatch<'a> {
queue.write_buffer(&b, 0, &self.rect_insts);
b
});
let discs_buf = (!self.disc_insts.is_empty()).then(|| {
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-raster-gpu-discs-buf"),
size: self.disc_insts.len() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&b, 0, &self.disc_insts);
b
});
// ── MSAA 4× ──────────────────────────────────────────────────
// Texturas por-flush dimensionadas al viewport (mismo patrón que
// los buffers de arriba; el resize "sale gratis"). `tex_w/h` se
// clampean a ≥1 para evitar Extent3d de 0 (un viewport degenerado
// no debería llegar acá, pero defensivo).
let tex_w = (viewport.0.round() as u32).max(1);
let tex_h = (viewport.1.round() as u32).max(1);
let extent = wgpu::Extent3d {
width: tex_w,
height: tex_h,
depth_or_array_layers: 1,
};
let fmt = self.pipelines.color_format;
// Color attachment multisample: lo rasterizan los 4 pipelines.
let msaa_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-raster-gpu-msaa"),
size: extent,
mip_level_count: 1,
sample_count: MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: fmt,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
// Scratch single-sample: recibe el resolve del MSAA y luego se
// samplea en el composite sobre el `view`.
let resolve_tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-raster-gpu-resolve"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: fmt,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let resolve_view =
resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
// Pase de primitivos: MSAA cleared a TRANSPARENT, resuelto al
// scratch single-sample. El scratch queda con alpha
// **premultiplicado** (el blending alfa de los pipelines sobre
// fondo transparente produce `rgb = color*alpha`, `a = alpha`).
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-raster-gpu-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
view: &msaa_view,
resolve_target: Some(&resolve_view),
depth_slice: None,
ops: wgpu::Operations {
load: load_op,
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
@@ -424,13 +697,18 @@ impl<'a> GpuBatch<'a> {
});
pass.set_bind_group(0, &bind_group, &[]);
// Orden de draws: rects (fondo) → tris → lines (encima). Match
// de la convención usual "fill abajo, stroke arriba".
// Orden de draws: rects (fondo) → discos → tris → lines (encima).
// Match de la convención usual "fill abajo, stroke arriba".
if let Some(buf) = rects_buf.as_ref() {
pass.set_pipeline(&self.pipelines.rects);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..6, 0..self.rect_count);
}
if let Some(buf) = discs_buf.as_ref() {
pass.set_pipeline(&self.pipelines.discs);
pass.set_vertex_buffer(0, buf.slice(..));
pass.draw(0..6, 0..self.disc_count);
}
if let Some(buf) = tris_buf.as_ref() {
pass.set_pipeline(&self.pipelines.tris);
pass.set_vertex_buffer(0, buf.slice(..));
@@ -442,6 +720,51 @@ impl<'a> GpuBatch<'a> {
pass.draw(0..6, 0..self.line_count);
}
}
// Composite del scratch resuelto sobre el `view`. Respeta el
// `load_op` recibido:
// - `Load` → alpha-over: preserva lo que ya está en `view`
// (vello), exactamente como el viejo pase directo.
// - `Clear` → limpia `view` al color pedido y luego compone
// el scratch encima (mismo resultado que limpiar y
// dibujar directo).
let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-raster-gpu-composite-bg"),
layout: &self.pipelines.composite_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&resolve_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(
&self.pipelines.composite_sampler,
),
},
],
});
let mut cpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-raster-gpu-composite-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
// El blend del pipeline ya hace el alpha-over; con
// Load conserva el fondo, con Clear lo borra primero.
load: load_op,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
cpass.set_pipeline(&self.pipelines.composite);
cpass.set_bind_group(0, &composite_bg, &[]);
cpass.draw(0..3, 0..1);
}
}
/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8.
@@ -546,8 +869,104 @@ fn vs_lines(
return out;
}
// -------- discos/anillos: 1 instancia = (cxcy, r/stroke, rgba) --------
//
// Quad que cubre el disco con 1.5 px de margen (para que el smoothstep
// del borde no se recorte). El VS pasa al FS la posición local en px
// relativa al centro; el FS evalúa el SDF del círculo y hace smoothstep
// sobre `fwidth` → borde antialiased. `stroke > 0` recorta un anillo.
struct DiscV2F {
@builtin(position) pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) local: vec2<f32>, // px relativos al centro
@location(2) params: vec2<f32>, // r, stroke (px)
};
@vertex
fn vs_discs(
@builtin(vertex_index) vid: u32,
@location(0) inst_c: vec2<f32>,
@location(1) inst_rs: vec2<f32>,
@location(2) inst_rgba: u32,
) -> DiscV2F {
var corners = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, 1.0),
);
let r = inst_rs.x;
let margin = r + 1.5; // 1.5 px de aire para el AA del borde
let local = corners[vid] * margin;
let px = inst_c + local;
var out: DiscV2F;
out.pos = vec4<f32>(px_to_ndc(px), 0.0, 1.0);
out.color = unpack_rgba(inst_rgba);
out.local = local;
out.params = inst_rs;
return out;
}
@fragment
fn fs_disc(in: DiscV2F) -> @location(0) vec4<f32> {
let r = in.params.x;
let stroke = in.params.y;
let dist = length(in.local); // distancia al centro en px
// Ancho del filtro AA en px (≈ 1 px en pantalla).
let aa = fwidth(dist);
// Borde exterior: cobertura 1 dentro de r, 0 fuera de r+aa.
var cov = 1.0 - smoothstep(r - aa, r + aa, dist);
// Anillo: si hay stroke, recortamos el agujero interior con AA.
if (stroke > 0.0) {
let inner = max(r - stroke, 0.0);
cov = cov * smoothstep(inner - aa, inner + aa, dist);
}
if (cov <= 0.0) {
discard;
}
return vec4<f32>(in.color.rgb, in.color.a * cov);
}
@fragment
fn fs(in: V2F) -> @location(0) vec4<f32> {
return in.color;
}
// -------- composite: blit fullscreen de la scratch resuelta del MSAA ----
//
// Triángulo de pantalla completa (3 vértices, sin vertex buffer). Samplea
// la scratch (alpha **premultiplicado**) y la emite tal cual; el alpha-over
// real lo hace el BlendState del pipeline (`One, OneMinusSrcAlpha`).
@group(0) @binding(0) var src_tex: texture_2d<f32>;
@group(0) @binding(1) var src_samp: sampler;
struct CompV2F {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_composite(@builtin(vertex_index) vid: u32) -> CompV2F {
// Triángulo gigante que cubre el viewport (técnica estándar).
var uvs = array<vec2<f32>, 3>(
vec2<f32>(0.0, 0.0),
vec2<f32>(2.0, 0.0),
vec2<f32>(0.0, 2.0),
);
let uv = uvs[vid];
var out: CompV2F;
out.pos = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
// El framebuffer tiene Y hacia abajo; la textura, hacia arriba en UV.
out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
return out;
}
@fragment
fn fs_composite(in: CompV2F) -> @location(0) vec4<f32> {
return textureSample(src_tex, src_samp, in.uv);
}
"#;
+7
View File
@@ -10,6 +10,13 @@ use llimphi_hal::{Frame, Hal};
pub use vello;
pub use vello::kurbo;
pub use vello::peniko;
// Renderer "hybrid" CPU+GPU sin compute shaders (feature `hybrid`):
// vello 0.7 trae `vello_hybrid::Renderer` como alternativa al `vello::Renderer`
// estándar — sin compute, mejor compat WebGL2 + Adreno/Mali viejas. Lo
// re-exportamos cuando la feature está activa para que apps avanzadas (web,
// móvil entry-level) puedan instanciarlo sin agregar otra dep.
#[cfg(feature = "hybrid")]
pub use vello_hybrid;
pub mod gpu;
pub use gpu::{GpuBatch, GpuPipelines};
+2 -2
View File
@@ -97,7 +97,7 @@ fn batch_with_rects_lines_tris_does_not_panic() {
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
}
#[test]
@@ -124,5 +124,5 @@ fn empty_batch_flush_is_no_op() {
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
);
hal.queue.submit(std::iter::once(encoder.finish()));
hal.device.poll(wgpu::Maintain::Wait);
hal.device.poll(wgpu::PollType::wait_indefinitely());
}
+4 -2
View File
@@ -1,12 +1,14 @@
[package]
name = "llimphi-surface"
description = "Surface/compositing glue for the llimphi UI framework."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-ui = { path = "../llimphi-ui" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" }
parking_lot = { workspace = true }
+63 -12
View File
@@ -150,10 +150,13 @@ impl ExternalSurface {
..Default::default()
});
// Uniforms: 8 floats — rect (x, y, w, h) + viewport (vw, vh, _, _).
// Uniforms: 12 floats — rect destino (x, y, w, h) + viewport
// (vw, vh, _, _) + src_uv (u0, v0, du, dv): el sub-rectángulo de la
// textura a muestrear, en UV 0..1. `blit` lo deja en (0,0,1,1)
// (textura entera); `blit_layout` lo usa para crop/zoom/pan (V2).
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("llimphi-surface-uniforms"),
size: 32,
size: 48,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -237,19 +240,54 @@ impl ExternalSurface {
dst_view: &wgpu::TextureView,
rect: PaintRect,
viewport: (u32, u32),
) {
// Comportamiento clásico: la textura entera estirada al rect, recortada
// al propio rect. `blit_layout` con `src_uv` = textura completa y el
// `clip` = el mismo rect.
self.blit_layout(
queue,
encoder,
dst_view,
[rect.x, rect.y, rect.w, rect.h],
[0.0, 0.0, 1.0, 1.0],
[rect.x, rect.y, rect.w, rect.h],
viewport,
);
}
/// Blit con control de **origen y destino** (V2: aspect/crop/zoom/pan).
/// `dst` es el rectángulo (px de viewport) donde dibujar — puede exceder el
/// `clip`; `src_uv` (`u0, v0, du, dv` en 0..1) es el sub-rectángulo de la
/// textura a muestrear (crop); `clip` (px de viewport) acota el dibujado con
/// un scissor — típicamente el rect del canvas, así el sobrante de
/// Fill/zoom/pan no pinta fuera de su área. El `dst`/`src_uv` los calcula
/// `media_core::viewport::compute_layout`.
pub fn blit_layout(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
dst_view: &wgpu::TextureView,
dst: [f32; 4],
src_uv: [f32; 4],
clip: [f32; 4],
viewport: (u32, u32),
) {
let inner = self.inner.lock();
let uniforms = [
rect.x,
rect.y,
rect.w,
rect.h,
dst[0],
dst[1],
dst[2],
dst[3],
viewport.0 as f32,
viewport.1 as f32,
0.0,
0.0,
src_uv[0],
src_uv[1],
src_uv[2],
src_uv[3],
];
let mut bytes = [0u8; 32];
let mut bytes = [0u8; 48];
for (i, v) in uniforms.iter().enumerate() {
bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
}
@@ -260,6 +298,7 @@ impl ExternalSurface {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: dst_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
@@ -271,6 +310,16 @@ impl ExternalSurface {
});
pass.set_pipeline(&inner.pipeline);
pass.set_bind_group(0, &inner.bind_group, &[]);
// Scissor = clip ∩ viewport (en px enteros). Evita que el sobrante de
// Fill/zoom/pan pinte fuera del área del canvas.
let (vw, vh) = (viewport.0 as f32, viewport.1 as f32);
let x0 = clip[0].max(0.0).floor();
let y0 = clip[1].max(0.0).floor();
let x1 = (clip[0] + clip[2]).min(vw).ceil();
let y1 = (clip[1] + clip[3]).min(vh).ceil();
if x1 > x0 && y1 > y0 {
pass.set_scissor_rect(x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32);
}
pass.draw(0..6, 0..1);
}
@@ -356,8 +405,9 @@ fn make_texture_and_bg(
const WGSL: &str = r#"
struct Uniforms {
rect: vec4<f32>, // x, y, w, h en pixels del frame
rect: vec4<f32>, // x, y, w, h del rect destino en pixels del frame
viewport: vec4<f32>, // vw, vh, _, _
src_uv: vec4<f32>, // u0, v0, du, dv: sub-rect de textura a muestrear (UV)
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@@ -380,10 +430,11 @@ fn vs(@builtin(vertex_index) vid: u32) -> V2F {
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0),
);
let uv = uvs[vid];
// `q` recorre el quad destino (0..1); el muestreo va al sub-rect `src_uv`.
let q = uvs[vid];
let px = u.rect.x + uv.x * u.rect.z;
let py = u.rect.y + uv.y * u.rect.w;
let px = u.rect.x + q.x * u.rect.z;
let py = u.rect.y + q.y * u.rect.w;
// NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
let ndc = vec2<f32>(
@@ -393,7 +444,7 @@ fn vs(@builtin(vertex_index) vid: u32) -> V2F {
var out: V2F;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
out.uv = uv;
out.uv = u.src_uv.xy + q * u.src_uv.zw;
return out;
}
+2
View File
@@ -1,10 +1,12 @@
[package]
name = "llimphi-text"
description = "Text shaping for llimphi over parley (Inter default, DejaVu Sans symbol fallback)."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
# vello directo (no llimphi-raster): el motor de texto sólo necesita
# Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+95
View File
@@ -0,0 +1,95 @@
//! Evidencia del caché de shaping: simula N redraws de una UI con texto
//! mayormente estable (chrome + un párrafo) más una línea que cambia cada
//! frame (un contador/caret tipeando). Reporta tiempo total y hit-rate con el
//! caché vivo vs. el costo de re-shapear siempre (clave única por frame).
//!
//! cargo run -p llimphi-text --example bench_cache --release
use llimphi_text::{Alignment, Typesetter};
use std::time::Instant;
const FRAMES: usize = 600; // ~10 s a 60 fps
// Un bloque de chrome típico: labels que NO cambian entre frames.
const CHROME: &[&str] = &[
"Archivo", "Editar", "Ver", "Insertar", "Formato", "Herramientas", "Ayuda",
"Guardar", "Abrir", "Nuevo", "Buscar", "Reemplazar", "Deshacer", "Rehacer",
];
const PARRAFO: &str = "Un documento es un haz de cuerpos sobre el mismo material, \
alineados párrafo a párrafo por sus hebras; si la madre cambia, la hija queda stale.";
fn pintar_frame(ts: &mut Typesetter, frame: usize, estatico: bool) {
// Chrome estable + párrafo estable: misma clave cada frame ⇒ hit con caché.
for label in CHROME {
let _ = ts.layout(label, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
}
let _ = ts.layout(PARRAFO, 15.0, Some(420.0), Alignment::Start, 1.4, false, None, 400.0, false, false, 0.0, 0.0);
// Una línea que cambia cada frame (caret/contador): siempre miss.
// Con `estatico=true` la forzamos constante para ver el techo del caché.
let dinamico = if estatico {
"estado: listo".to_string()
} else {
format!("línea {frame} · col {}", frame % 80)
};
let _ = ts.layout(&dinamico, 13.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
}
fn corrida(nombre: &str, estatico: bool) {
let mut ts = Typesetter::new();
// Warmup: primera pasada llena el caché (no la medimos).
pintar_frame(&mut ts, 0, estatico);
let base = ts.cache_stats();
let t0 = Instant::now();
for f in 1..=FRAMES {
pintar_frame(&mut ts, f, estatico);
}
let dt = t0.elapsed();
let s = ts.cache_stats();
let hits = s.hits - base.hits;
let misses = s.misses - base.misses;
let total = hits + misses;
println!(
"{nombre:<28} {FRAMES} frames en {:>7.2?} ({:>6.1} µs/frame) hit-rate {:.1}% ({hits}/{total}) entradas vivas {}",
dt,
dt.as_micros() as f64 / FRAMES as f64,
100.0 * hits as f64 / total as f64,
s.entries,
);
}
/// Baseline sin caché para la MISMA carga: cada texto se hace único por frame
/// (sufijo invisible) ⇒ 100% miss ⇒ shaping completo siempre. Es el costo que
/// el caché evita en el chrome+párrafo estables.
fn corrida_sin_cache() {
let mut ts = Typesetter::new();
let frame_texts = |f: usize| -> Vec<String> {
let mut v: Vec<String> = CHROME.iter().map(|l| format!("{l}\u{200b}{f}")).collect();
v.push(format!("{PARRAFO}\u{200b}{f}"));
v.push(format!("línea {f} · col {}", f % 80));
v
};
let _ = frame_texts(0); // simetría con el warmup de `corrida`
let t0 = Instant::now();
for f in 1..=FRAMES {
for t in frame_texts(f) {
let _ = ts.layout(&t, 13.0, Some(420.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
}
}
let dt = t0.elapsed();
println!(
"{:<28} {FRAMES} frames en {:>7.2?} ({:>6.1} µs/frame) hit-rate 0.0% (todo re-shapeado)",
"Sin caché (baseline)",
dt,
dt.as_micros() as f64 / FRAMES as f64,
);
}
fn main() {
println!("Caché de shaping de llimphi-text — {FRAMES} frames\n");
// Baseline: la misma carga, re-shapeando todo cada frame.
corrida_sin_cache();
// Caso real: chrome+párrafo estable, 1 línea cambiante por frame.
corrida("UI típica (1 línea cambia)", false);
// Techo: todo estable (lo que pasa en idle/hover sin cambio de texto).
corrida("Todo estable (techo)", true);
}
+836 -8
View File
@@ -21,8 +21,128 @@ pub struct Typesetter {
/// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
/// el mismo `LayoutContext`, así que mantenemos uno por sabor.
runs_cx: parley::LayoutContext<RunBrush>,
/// Caché de shaping: `[`Self::layout`]` es el único chokepoint por el que
/// pasan medición y pintado (vía `layout_clamped`), y se invoca por cada
/// nodo de texto en **cada** redraw — dos veces (medir + pintar). Shapear
/// con parley (font matching, bidi, clusters, line break) es lo caro; el
/// `parley::Layout` resultante es `Clone`. Cacheamos por los parámetros
/// que lo determinan y clonamos en el hit: durante scroll/tipeo, el texto
/// que no cambió no se re-shapea.
cache: ShapeCache,
cache_hits: u64,
cache_misses: u64,
}
/// Estadísticas del caché de shaping (evidencia/benchmark). `entries` es el
/// total vivo entre las dos generaciones.
#[derive(Debug, Clone, Copy, Default)]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
pub entries: usize,
}
/// Clave de caché: todos los parámetros que determinan un `layout`. Los `f32`
/// van por `to_bits` para ser `Hash + Eq` exactos (sin problemas de NaN/0.0:
/// comparamos los bits crudos, no el valor numérico). `Alignment` se mapea a
/// un tag `u8` porque su enum no deriva `Hash`.
#[derive(Clone, PartialEq, Eq, Hash)]
struct ShapeKey {
text: String,
size_bits: u32,
max_width_bits: Option<u32>,
align: u8,
line_height_bits: u32,
italic: bool,
font_family: Option<String>,
weight_bits: u32,
/// Underline activo. parley emite `Decoration` por run cuando este flag
/// está, así que el layout difiere y el caché tiene que separarlos.
underline: bool,
/// Strikethrough activo. Idem `underline`.
strikethrough: bool,
/// `letter-spacing` (px extra entre letras). 0 = sin override. Cambia el
/// shaping/ancho, así que entra en la clave.
letter_bits: u32,
/// `word-spacing` (px extra entre palabras). Idem `letter_bits`.
word_bits: u32,
/// `overflow-wrap: break-word`/`anywhere`: si está, parley puede partir
/// dentro de una palabra para que entre en la caja. Cambia el line-break,
/// así que separa la entrada del caché.
overflow_wrap: bool,
}
fn align_tag(a: Alignment) -> u8 {
match a {
Alignment::Start => 0,
Alignment::Center => 1,
Alignment::End => 2,
Alignment::Justify => 3,
}
}
/// Caché generacional (LRU aproximado, sin dependencias). Dos mapas: `hot`
/// recibe inserciones y promociones; cuando `hot` llega a `cap`, rota
/// (`cold = hot`, `hot = ∅`) y la generación vieja se descarta. Un hit en
/// `cold` se promueve a `hot`, así lo accedido en la última época sobrevive a
/// la rotación — el texto visible, re-consultado cada frame, queda siempre
/// caliente; lo transitorio (candidatos de elipsis, tooltips) cae solo. Es el
/// patrón de los cachés de glyph/shape de swash/cosmic-text: O(1), sin orden
/// enlazado.
struct ShapeCache {
hot: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
cold: std::collections::HashMap<ShapeKey, parley::Layout<()>>,
cap: usize,
}
impl ShapeCache {
fn new(cap: usize) -> Self {
Self {
hot: std::collections::HashMap::new(),
cold: std::collections::HashMap::new(),
cap,
}
}
/// Devuelve un clon del layout cacheado si existe, promoviendo desde
/// `cold` a `hot` en el camino.
fn get(&mut self, key: &ShapeKey) -> Option<parley::Layout<()>> {
if let Some(v) = self.hot.get(key) {
return Some(v.clone());
}
// Hit frío: sacalo de cold y reinsertalo en hot (promoción). Una sola
// clonación: el clon queda en hot, el original se devuelve al caller.
if let Some(v) = self.cold.remove(key) {
self.hot.insert(key.clone(), v.clone());
return Some(v);
}
None
}
fn put(&mut self, key: ShapeKey, layout: parley::Layout<()>) {
if self.hot.len() >= self.cap {
// Rotá la generación: lo no reaccedido desde la última rotación
// (quedó sólo en cold) se libera acá.
self.cold = std::mem::take(&mut self.hot);
}
self.hot.insert(key, layout);
}
fn clear(&mut self) {
self.hot.clear();
self.cold.clear();
}
fn entries(&self) -> usize {
self.hot.len() + self.cold.len()
}
}
/// Capacidad de la generación caliente antes de rotar. 512 layouts cubre con
/// holgura el texto visible de una UI densa (un editor de ~50 líneas + chrome)
/// sin retener de más. La memoria real es ~2× (dos generaciones).
const SHAPE_CACHE_CAP: usize = 512;
impl Default for Typesetter {
fn default() -> Self {
Self::new()
@@ -40,14 +160,85 @@ impl Default for Typesetter {
/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
/// **Inter** embebida como **fuente de UI por defecto** (SIL OFL 1.1, libre y
/// redistribuible — ver `assets/Inter-LICENSE.txt`). Inter es una grotesca
/// neo-humanista diseñada específicamente para interfaces a tamaños chicos:
/// caja alta de la x, aperturas amplias y espaciado parejo. Es el look 2026
/// que queremos de fábrica, sin depender de que el sistema tenga una sans
/// linda instalada (en una instalación pelada el default de fontique podía
/// caer en Liberation/Adwaita, que envejecen mal). La enganchamos como
/// primera familia del genérico `sans-serif` (ver [`Typesetter::install_ui_font`]),
/// que es lo que parley resuelve cuando el bloque no pide `font_family`. El
/// fallback por-script sigue intacto: símbolos via DejaVu, CJK/árabe/etc. via
/// las fuentes del sistema.
const INTER_SANS: &[u8] = include_bytes!("../assets/Inter-Regular.ttf");
/// Fuente monoespaciada embebida (Liberation Mono, SIL OFL — metric-
/// compatible con Courier). Va embebida para que *cualquier* app Llimphi
/// pueda pedir ancho fijo (output de terminal, IDE-text, tablas que
/// columnean) sin depender de que el sistema tenga una mono instalada.
/// Se referencia por su nombre de familia con [`MONOSPACE`].
const LIBERATION_MONO: &[u8] = include_bytes!("../assets/LiberationMono.ttf");
/// Bytes de la fuente **monospace embebida** (Liberation Mono TTF). Pública
/// para que otros crates (p. ej. `llimphi-widget-terminal`, que necesita
/// rasterizar glifos para su atlas GPU) usen exactamente la misma fuente
/// que el render normal, sin volver a embeber el archivo.
pub const MONO_FONT_BYTES: &[u8] = LIBERATION_MONO;
/// Nombre de familia de la fuente monoespaciada embebida. Pasalo como
/// `font_family: Some(llimphi_text::MONOSPACE)` en un [`TextBlock`] (o el
/// `font_family` de `layout`) para render de ancho fijo garantizado.
pub const MONOSPACE: &str = "Liberation Mono";
/// Nombre de familia de la fuente de UI embebida ([Inter](https://rsms.me/inter/)).
/// Es el default proporcional cuando un bloque **no** especifica `font_family`
/// (la enganchamos como primera familia del genérico `sans-serif`). Exponemos
/// el nombre por si un caller quiere pedirla explícitamente.
pub const UI_SANS: &str = "Inter";
impl Typesetter {
pub fn new() -> Self {
let mut font_cx = parley::FontContext::new();
Self::install_ui_font(&mut font_cx);
Self::install_symbol_fallback(&mut font_cx);
Self::install_monospace(&mut font_cx);
Self {
font_cx,
layout_cx: parley::LayoutContext::new(),
runs_cx: parley::LayoutContext::new(),
cache: ShapeCache::new(SHAPE_CACHE_CAP),
cache_hits: 0,
cache_misses: 0,
}
}
/// Registra **Inter** y la pone como **primera familia del genérico
/// `sans-serif`**. Ese genérico es lo que parley resuelve cuando un bloque
/// no especifica `font_family` (su default es `FontStack::Source("sans-serif")`),
/// así que con esto toda app Llimphi tipografía en Inter de fábrica sin
/// tocar una línea de su código, y sin depender de la sans del sistema.
/// Usamos `append_*` (no `set_*`) para no borrar las familias que el SO ya
/// asociaba al genérico: Inter va primero, el resto queda detrás como
/// respaldo. La cobertura de scripts no-latinos / símbolos sigue saliendo
/// del fallback por-script (CJK del sistema, símbolos de DejaVu). Si una
/// app pide otra familia explícita, gana esa. Best-effort: si el registro
/// falla, el texto sigue con la sans del sistema.
fn install_ui_font(font_cx: &mut parley::FontContext) {
use parley::fontique::{Blob, GenericFamily};
let blob = Blob::new(std::sync::Arc::new(INTER_SANS));
let registered = font_cx.collection.register_fonts(blob, None);
if let Some((family_id, _)) = registered.first() {
// Las familias actuales del genérico (las del sistema) van detrás:
// Inter primero, luego el respaldo previo.
let existing: Vec<_> = font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.collect();
font_cx.collection.set_generic_families(
GenericFamily::SansSerif,
std::iter::once(*family_id).chain(existing),
);
}
}
@@ -68,16 +259,51 @@ impl Typesetter {
}
}
/// Registra la fuente monoespaciada embebida (Liberation Mono) bajo su
/// nombre de familia [`MONOSPACE`], para que `FontStack::Source`
/// (`font_family: Some(MONOSPACE)`) la resuelva aunque el sistema no
/// tenga ninguna mono instalada. Best-effort: si falla, los callers que
/// pidan monospace caen al fallback de fontique (mono del sistema, o la
/// proporcional si no hay) — el texto sigue, sólo pierde el ancho fijo.
fn install_monospace(font_cx: &mut parley::FontContext) {
use parley::fontique::Blob;
let blob = Blob::new(std::sync::Arc::new(LIBERATION_MONO));
font_cx.collection.register_fonts(blob, None);
}
/// Acceso al `FontContext` por si se necesita registrar fuentes extra
/// o cambiar la stack de fallback.
/// o cambiar la stack de fallback. **Invalida el caché de shaping**: tocar
/// el set de fuentes o el fallback puede cambiar el resultado de cualquier
/// layout, así que descartamos lo cacheado (operación rara, de setup).
pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
self.cache.clear();
&mut self.font_cx
}
/// Estadísticas del caché de shaping (hits/misses acumulados + entradas
/// vivas). Para benchmark/evidencia; no afecta el render.
pub fn cache_stats(&self) -> CacheStats {
CacheStats {
hits: self.cache_hits,
misses: self.cache_misses,
entries: self.cache.entries(),
}
}
/// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
/// `line_height` (multiplicador del font_size), `max_width` (line
/// break), y `alignment`. `italic`=true selecciona la variante
/// italic/oblique de la fuente activa (vía `parley::FontStyle`).
/// break), `alignment` y `weight` (peso de fuente CSS: 400 normal,
/// 700 bold). `italic`=true selecciona la variante italic/oblique de
/// la fuente activa (vía `parley::FontStyle`). `underline`/`strikethrough`
/// activan la decoración global del bloque — parley deja la metadata
/// (offset + grosor) en cada `Run` y el pintado (`draw_layout_*`) emite
/// el rect correspondiente sobre la línea base.
/// API pública 12-arg (sin `overflow-wrap`): la usan showreels, canvas,
/// hit-testing de selección, etc. Delega en [`Self::layout_inner`] con
/// `overflow_wrap = false` (la palabra larga desborda, comportamiento
/// histórico). El quiebre dentro de palabra entra sólo por `layout_clamped`
/// (camino del compositor), para no propagar el flag a todos los callers.
#[allow(clippy::too_many_arguments)]
pub fn layout(
&mut self,
text: &str,
@@ -87,12 +313,77 @@ impl Typesetter {
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
) -> parley::Layout<()> {
self.layout_inner(
text, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, false,
)
}
/// Impl real del shaping con el flag `overflow_wrap` (CSS
/// `overflow-wrap: break-word`/`anywhere`). Privado: sólo lo invocan
/// [`Self::layout`] (con `false`) y [`Self::layout_clamped`] (con el valor
/// del estilo). Así la firma pública 12-arg no cambia y los ~20 callers de
/// showreels/canvas siguen compilando sin tocar.
#[allow(clippy::too_many_arguments)]
fn layout_inner(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
overflow_wrap: bool,
) -> parley::Layout<()> {
// Caché de shaping: clave por todos los parámetros que determinan el
// layout. En el hit clonamos el `parley::Layout` (memcpy de vectores,
// ~órdenes de magnitud más barato que re-shapear). El `String`/clave
// que se aloca para consultar es un costo menor frente al shaping que
// evita; mantener la firma `&str` no fuerza alloc en el caller.
let key = ShapeKey {
text: text.to_string(),
size_bits: size_px.to_bits(),
max_width_bits: max_width.map(f32::to_bits),
align: align_tag(alignment),
line_height_bits: line_height.to_bits(),
italic,
font_family: font_family.map(str::to_string),
weight_bits: weight.to_bits(),
underline,
strikethrough,
letter_bits: letter_spacing.to_bits(),
word_bits: word_spacing.to_bits(),
overflow_wrap,
};
if let Some(hit) = self.cache.get(&key) {
self.cache_hits += 1;
return hit;
}
self.cache_misses += 1;
let mut builder =
self.layout_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
@@ -105,6 +396,30 @@ impl Typesetter {
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
// `letter-spacing`/`word-spacing` (px extra). 0 = sin override (normal).
if letter_spacing != 0.0 {
builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing));
}
if word_spacing != 0.0 {
builder.push_default(parley::StyleProperty::WordSpacing(word_spacing));
}
// `overflow-wrap: break-word`/`anywhere`: habilita la partición dentro
// de una palabra cuando no hay otra oportunidad de quiebre en la línea
// (un token más ancho que la caja). `Anywhere` cubre ambos valores CSS
// — su única diferencia con `BreakWord` es el min-content sizing, sin
// efecto visible en el wrap del bloque. Sin el flag (normal) parley deja
// desbordar la palabra larga (comportamiento previo).
if overflow_wrap {
builder.push_default(parley::StyleProperty::OverflowWrap(
parley::OverflowWrap::Anywhere,
));
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
@@ -112,15 +427,96 @@ impl Typesetter {
alignment.into(),
parley::AlignmentOptions::default(),
);
self.cache.put(key, layout.clone());
layout
}
/// Como [`Self::layout`] pero **clampado** a `max_lines` líneas (CSS
/// `-webkit-line-clamp` / Flutter `maxLines`). Si el texto envuelto cabe en
/// `max_lines` o menos, devuelve el layout completo. Si excede:
/// - `ellipsis = true` → la última línea visible termina en `…` (se
/// recortan graphemes del final hasta que el bloque vuelve a caber en
/// `max_lines`).
/// - `ellipsis = false` → se corta sin glifo (queda el prefijo que cupo).
///
/// `max_lines = None` o `Some(0)` ⇒ sin límite (idéntico a `layout`). El
/// clamp sólo recorta cuando hay envoltura, así que requiere un `max_width`
/// definido para tener efecto (un label en una caja dimensionada — el caso
/// típico). Reusa `layout` internamente: 0 costo extra cuando no trunca.
#[allow(clippy::too_many_arguments)]
pub fn layout_clamped(
&mut self,
text: &str,
size_px: f32,
max_width: Option<f32>,
alignment: Alignment,
line_height: f32,
italic: bool,
font_family: Option<&str>,
weight: f32,
max_lines: Option<usize>,
ellipsis: bool,
underline: bool,
strikethrough: bool,
letter_spacing: f32,
word_spacing: f32,
overflow_wrap: bool,
) -> parley::Layout<()> {
let full = self.layout_inner(
text, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
let limit = match max_lines {
Some(n) if n >= 1 => n,
_ => return full,
};
if full.lines().count() <= limit {
return full;
}
// Byte de fin de la última línea visible (rango sobre `text` original).
let mut cutoff = full
.lines()
.nth(limit - 1)
.map(|l| l.text_range().end)
.unwrap_or(text.len())
.min(text.len());
while cutoff > 0 && !text.is_char_boundary(cutoff) {
cutoff -= 1;
}
let base = text[..cutoff].trim_end();
if !ellipsis {
return self.layout_inner(
base, size_px, max_width, alignment, line_height, italic, font_family, weight,
underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
}
// Recortá graphemes del final hasta que `base…` vuelva a caber en
// `limit` líneas (apilar el `…` puede empujar una palabra a una línea
// extra). Acotado: cada vuelta quita ≥1 char.
let mut s = base.to_string();
loop {
let candidate = format!("{s}");
let lay = self.layout_inner(
&candidate, size_px, max_width, alignment, line_height, italic, font_family,
weight, underline, strikethrough, letter_spacing, word_spacing, overflow_wrap,
);
if s.is_empty() || lay.lines().count() <= limit {
return lay;
}
s.pop();
while s.ends_with(char::is_whitespace) {
s.pop();
}
}
}
/// Construye un layout **multicolor** en una sola pasada de shaping:
/// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
/// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
/// la convención de parley). Pensado para syntax highlighting: shapear
/// la línea entera una vez con un color por token, en vez de un layout
/// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
#[allow(clippy::too_many_arguments)]
pub fn layout_runs(
&mut self,
text: &str,
@@ -129,13 +525,29 @@ impl Typesetter {
runs: &[(usize, usize, Color)],
alignment: Alignment,
line_height: f32,
weight: f32,
underline: bool,
strikethrough: bool,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(line_height));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
let len = text.len();
for &(start, end, color) in runs {
if start < end && end <= len {
@@ -147,6 +559,118 @@ impl Typesetter {
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
layout
}
/// Construye un layout **RichText**: defaults a nivel bloque + un
/// arreglo de [`TextSpan`] que sobreescriben tamaño/peso/italic/familia/
/// color/decoración **por rango de bytes**. A diferencia de
/// [`Self::layout_runs`] (sólo color, sin wrap), este camino:
///
/// - permite `max_width` (envuelve a párrafo);
/// - aplica los siete `StyleProperty` por rango;
/// - usa el mismo `runs_cx` (`RunBrush`), así puede convivir con el
/// pintado multicolor.
///
/// **Sin caché** en v1 (a diferencia de `layout`/`layout_clamped`): el
/// RichText típico cambia frame-a-frame (cursor de editor, hover de
/// link), y la clave de caché de un span-set arbitrario es pesada.
/// Reusa todo el shaping interno de parley, que ya es rápido para
/// párrafos de la magnitud de una UI.
#[allow(clippy::too_many_arguments)]
pub fn layout_spans(
&mut self,
text: &str,
size_px: f32,
default_color: Color,
weight: f32,
line_height: f32,
italic: bool,
font_family: Option<&str>,
underline: bool,
strikethrough: bool,
spans: &[TextSpan],
max_width: Option<f32>,
alignment: Alignment,
) -> parley::Layout<RunBrush> {
let mut builder = self
.runs_cx
.ranged_builder(&mut self.font_cx, text, 1.0, true);
builder.push_default(parley::StyleProperty::FontSize(size_px));
builder.push_default(parley::StyleProperty::LineHeight(
parley::LineHeight::FontSizeRelative(line_height),
));
if weight != 400.0 {
builder.push_default(parley::StyleProperty::FontWeight(
parley::FontWeight::new(weight),
));
}
if italic {
builder.push_default(parley::StyleProperty::FontStyle(
parley::FontStyle::Italic,
));
}
if let Some(ff) = font_family {
builder.push_default(parley::StyleProperty::FontStack(
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
));
}
builder.push_default(parley::StyleProperty::Brush(RunBrush(default_color)));
if underline {
builder.push_default(parley::StyleProperty::Underline(true));
}
if strikethrough {
builder.push_default(parley::StyleProperty::Strikethrough(true));
}
let len = text.len();
for span in spans {
if span.start >= span.end || span.end > len {
continue;
}
let range = span.start..span.end;
let s = &span.style;
if let Some(v) = s.size_px {
builder.push(parley::StyleProperty::FontSize(v), range.clone());
}
if let Some(v) = s.weight {
builder.push(
parley::StyleProperty::FontWeight(parley::FontWeight::new(v)),
range.clone(),
);
}
if let Some(v) = s.italic {
let style = if v {
parley::FontStyle::Italic
} else {
parley::FontStyle::Normal
};
builder.push(parley::StyleProperty::FontStyle(style), range.clone());
}
if let Some(ff) = s.font_family.as_deref() {
builder.push(
parley::StyleProperty::FontStack(parley::FontStack::Source(
std::borrow::Cow::Owned(ff.to_string()),
)),
range.clone(),
);
}
if let Some(c) = s.color {
builder.push(parley::StyleProperty::Brush(RunBrush(c)), range.clone());
}
if let Some(v) = s.underline {
builder.push(parley::StyleProperty::Underline(v), range.clone());
}
if let Some(v) = s.strikethrough {
builder.push(parley::StyleProperty::Strikethrough(v), range.clone());
}
}
let mut layout = builder.build(text);
layout.break_all_lines(max_width);
layout.align(
max_width,
alignment.into(),
parley::AlignmentOptions::default(),
);
layout
}
}
/// Brush por-run para texto multicolor. Newtype sobre [`Color`] porque
@@ -162,6 +686,47 @@ impl Default for RunBrush {
}
}
/// Overrides de estilo aplicables a un **rango de bytes** dentro de un
/// bloque de texto, para `Typesetter::layout_spans` (RichText). Cada
/// campo es opcional: `None` hereda del default del bloque. La granularidad
/// es por bytes (convención de parley), igual que el `runs` multicolor.
#[derive(Default, Clone, Debug, PartialEq)]
pub struct TextSpanStyle {
/// Tamaño de fuente (CSS `font-size`). El reshape recalcula el alto
/// de la línea afectada.
pub size_px: Option<f32>,
/// Peso de fuente (400 = normal, 700 = bold).
pub weight: Option<f32>,
/// Italic on/off.
pub italic: Option<bool>,
/// Family CSS-like ("Helvetica, sans-serif"). Útil para `code` inline
/// (forzar monospace en una palabra).
pub font_family: Option<String>,
/// Color del texto (gana sobre el `default_color` del bloque).
pub color: Option<Color>,
/// Subrayado on/off.
pub underline: Option<bool>,
/// Tachado on/off.
pub strikethrough: Option<bool>,
}
/// Un span de RichText: rango de bytes `[start, end)` + overrides de
/// estilo (`style`). Los rangos pueden superponerse — parley aplica los
/// `StyleProperty` en orden de inserción, así el caller debería pushar de
/// menor a mayor especificidad.
#[derive(Clone, Debug, PartialEq)]
pub struct TextSpan {
pub start: usize,
pub end: usize,
pub style: TextSpanStyle,
}
impl TextSpan {
pub fn new(start: usize, end: usize, style: TextSpanStyle) -> Self {
Self { start, end, style }
}
}
/// Alineación horizontal del bloque dentro de su ancho máximo.
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
@@ -175,9 +740,9 @@ impl From<Alignment> for parley::Alignment {
fn from(a: Alignment) -> Self {
match a {
Alignment::Start => parley::Alignment::Start,
Alignment::Center => parley::Alignment::Middle,
Alignment::Center => parley::Alignment::Center,
Alignment::End => parley::Alignment::End,
Alignment::Justify => parley::Alignment::Justified,
Alignment::Justify => parley::Alignment::Justify,
}
}
}
@@ -238,6 +803,18 @@ pub fn layout_block(ts: &mut Typesetter, block: &TextBlock<'_>) -> parley::Layou
block.line_height,
block.italic,
block.font_family.as_deref(),
// `TextBlock` no transporta peso (su API queda en normal); el peso de
// fuente fluye por el camino del compositor, que llama a `layout`
// directamente con el `weight` del `TextSpec`/`TextMeasure`.
400.0,
// Decoración tampoco viaja por `TextBlock`: la activa el compositor
// por nodo según `TextSpec::{underline,strikethrough}`.
false,
false,
// `letter-spacing`/`word-spacing` tampoco viajan por `TextBlock`; el
// compositor los pasa por su camino directo (`layout_clamped`).
0.0,
0.0,
)
}
@@ -306,11 +883,46 @@ pub fn draw_layout_brush_xf(
y: g.y,
}),
);
paint_decoration(scene, &glyph_run, brush, transform);
}
}
}
}
/// Pinta las decoraciones (`underline`/`strikethrough`) del run si las trae
/// del shaping. El offset que devuelve parley sigue la convención OpenType
/// (positivo = sobre la línea base en font-space, eje Y arriba); en
/// coordenadas de pantalla (Y abajo) el rect va a `baseline - offset`. El
/// `transform` es el mismo que se usa para los glifos, así la decoración
/// hereda el scroll/rotación/zoom del subárbol.
fn paint_decoration<B: parley::Brush>(
scene: &mut vello::Scene,
glyph_run: &parley::GlyphRun<'_, B>,
brush: &Brush,
transform: vello::kurbo::Affine,
) {
let style = glyph_run.style();
let run = glyph_run.run();
let metrics = run.metrics();
let x = glyph_run.offset() as f64;
let baseline = glyph_run.baseline() as f64;
let advance = glyph_run.advance() as f64;
if let Some(dec) = &style.underline {
let offset = dec.offset.unwrap_or(metrics.underline_offset) as f64;
let size = dec.size.unwrap_or(metrics.underline_size) as f64;
let y0 = baseline - offset;
let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
}
if let Some(dec) = &style.strikethrough {
let offset = dec.offset.unwrap_or(metrics.strikethrough_offset) as f64;
let size = dec.size.unwrap_or(metrics.strikethrough_size) as f64;
let y0 = baseline - offset;
let rect = vello::kurbo::Rect::new(x, y0, x + advance, y0 + size);
scene.fill(peniko::Fill::NonZero, transform, brush, None, &rect);
}
}
/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
@@ -319,7 +931,22 @@ pub fn draw_layout_runs(
layout: &parley::Layout<RunBrush>,
origin: (f64, f64),
) {
let transform = vello::kurbo::Affine::translate(origin);
draw_layout_runs_xf(scene, layout, vello::kurbo::Affine::translate(origin));
}
/// Igual que [`draw_layout_runs`] pero con una **afín completa** en vez de sólo
/// un desplazamiento — el equivalente multicolor de [`draw_layout_xf`]. Lo
/// necesita el compositor para que el texto multicolor herede la
/// transformación acumulada del subárbol (scroll/rotación del padre): sin esto,
/// el texto con `runs` se pintaba en coords de layout crudas, **ignorando** el
/// transform, y se desalineaba del resto (p. ej. el cuerpo coloreado del shell
/// no seguía el scroll del panel). El origen del layout (0,0) lo mapea
/// `transform`; las posiciones de glifo se aplican en ese espacio.
pub fn draw_layout_runs_xf(
scene: &mut vello::Scene,
layout: &parley::Layout<RunBrush>,
transform: vello::kurbo::Affine,
) {
for line in layout.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
@@ -340,6 +967,7 @@ pub fn draw_layout_runs(
y: g.y,
}),
);
paint_decoration(scene, &glyph_run, &brush, transform);
}
}
}
@@ -357,3 +985,203 @@ pub fn draw_block(scene: &mut vello::Scene, ts: &mut Typesetter, block: &TextBlo
let layout = layout_block(ts, block);
draw_layout(scene, &layout, block.color, block.origin);
}
#[cfg(test)]
mod tests {
use super::*;
/// Texto que envuelve a muchas líneas en un ancho angosto.
const LARGO: &str =
"palabras varias que envuelven en bastantes renglones cuando el ancho \
disponible es realmente angosto y no caben de un solo tirón";
fn n_lineas(ts: &mut Typesetter, max_lines: Option<usize>, ellipsis: bool) -> usize {
ts.layout_clamped(
LARGO,
14.0,
Some(120.0),
Alignment::Start,
1.2,
false,
None,
400.0,
max_lines,
ellipsis,
false,
false,
0.0,
0.0,
false,
)
.lines()
.count()
}
#[test]
fn clamp_limita_el_numero_de_lineas() {
let mut ts = Typesetter::new();
let libre = n_lineas(&mut ts, None, false);
assert!(libre > 2, "el fixture debe envolver a >2 líneas (dio {libre})");
// Con clamp, nunca más que el límite — con o sin ellipsis.
assert_eq!(n_lineas(&mut ts, Some(1), false), 1);
assert_eq!(n_lineas(&mut ts, Some(1), true), 1);
assert!(n_lineas(&mut ts, Some(2), true) <= 2);
// max_lines None ⇒ sin límite (idéntico a layout).
assert_eq!(n_lineas(&mut ts, None, true), libre);
}
#[test]
fn letter_y_word_spacing_ensanchan_la_medida() {
// letter-spacing y word-spacing agregan px al ancho del shaping; 0 es
// el baseline (normal). Prueba directa del feature (Fase 7.1252).
let mut ts = Typesetter::new();
let w = |ts: &mut Typesetter, ls: f32, ws: f32| {
measurement(&ts.layout(
"hola mundo cruel", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false,
false, ls, ws,
))
.width
};
let base = w(&mut ts, 0.0, 0.0);
let con_letter = w(&mut ts, 4.0, 0.0);
let con_word = w(&mut ts, 0.0, 10.0);
assert!(con_letter > base, "letter-spacing ensancha ({con_letter} > {base})");
assert!(con_word > base, "word-spacing ensancha ({con_word} > {base})");
}
#[test]
fn clamp_no_trunca_si_ya_cabe() {
let mut ts = Typesetter::new();
// "Hola" cabe en una línea: pedir 3 no debe inventar truncado.
let lay = ts.layout_clamped(
"Hola", 14.0, Some(200.0), Alignment::Start, 1.2, false, None, 400.0, Some(3), true,
false, false, 0.0, 0.0, false,
);
assert_eq!(lay.lines().count(), 1);
}
/// El caché no debe cambiar el resultado: misma medida con o sin hit, y la
/// segunda llamada idéntica tiene que pegar en el caché (hit), no re-shapear.
#[test]
fn cache_es_transparente_y_pega() {
let mut ts = Typesetter::new();
let m1 = {
let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
(l.width(), l.height(), l.lines().count())
};
let s1 = ts.cache_stats();
assert_eq!(s1.misses, 1, "primera vez = miss");
assert_eq!(s1.hits, 0);
// Misma llamada exacta: debe ser hit y dar la misma geometría.
let m2 = {
let l = ts.layout(LARGO, 14.0, Some(120.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
(l.width(), l.height(), l.lines().count())
};
let s2 = ts.cache_stats();
assert_eq!(s2.hits, 1, "segunda vez idéntica = hit");
assert_eq!(s2.misses, 1, "no hubo nuevo miss");
assert_eq!(m1, m2, "el layout cacheado es idéntico al fresco");
// Cambiar un parámetro (ancho) es una clave distinta: miss nuevo.
let _ = ts.layout(LARGO, 14.0, Some(80.0), Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().misses, 2, "otro ancho = otra clave");
}
/// `font_context_mut` invalida el caché (cambiar fuentes puede alterar el
/// shaping): la siguiente llamada idéntica vuelve a ser miss.
#[test]
fn font_context_mut_invalida_el_cache() {
let mut ts = Typesetter::new();
let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().entries, 1);
let _ = ts.font_context_mut();
assert_eq!(ts.cache_stats().entries, 0, "el caché quedó vacío");
let _ = ts.layout("hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0);
assert_eq!(ts.cache_stats().misses, 2, "post-invalidación = miss");
}
/// Decoración (underline / strikethrough): el flag de entrada debe
/// llegar al `parley::Layout` como `style.underline`/`style.strikethrough`
/// presentes en cada run, y el caché debe distinguir su clave (mismo
/// texto con vs sin decoración = entradas separadas).
#[test]
fn underline_y_strikethrough_se_propagan_al_layout() {
let mut ts = Typesetter::new();
let with_dec = ts.layout(
"Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, true, true, 0.0, 0.0,
);
// Caminamos los runs del layout y verificamos que cada GlyphRun trae
// ambas decoraciones marcadas (no usamos `is_some` directo porque
// `Layout::lines/items` exige iterar para llegar al Style).
let mut visto_u = false;
let mut visto_s = false;
for line in with_dec.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
if gr.style().underline.is_some() {
visto_u = true;
}
if gr.style().strikethrough.is_some() {
visto_s = true;
}
}
}
}
assert!(visto_u, "underline=true ⇒ Decoration en al menos un run");
assert!(visto_s, "strikethrough=true ⇒ Decoration en al menos un run");
// Sin decoración el layout no las trae.
let plain = ts.layout(
"Hola", 14.0, None, Alignment::Start, 1.2, false, None, 400.0, false, false, 0.0, 0.0,
);
for line in plain.lines() {
for item in line.items() {
if let parley::PositionedLayoutItem::GlyphRun(gr) = item {
assert!(gr.style().underline.is_none(), "sin underline=true ⇒ None");
assert!(gr.style().strikethrough.is_none(), "sin strikethrough=true ⇒ None");
}
}
}
// Caché: dos misses (uno por cada variante), no se pisan.
let s = ts.cache_stats();
assert!(s.misses >= 2, "claves distintas por decoración ⇒ misses separados");
}
/// Mecánica generacional: al pasar `cap`, `hot` rota a `cold`; un ítem
/// reaccedido se promueve y sobrevive a la siguiente rotación.
#[test]
fn cache_generacional_promueve_y_rota() {
let mut c = ShapeCache::new(2);
let mk = |s: &str| ShapeKey {
text: s.to_string(),
size_bits: 0,
max_width_bits: None,
align: 0,
line_height_bits: 0,
italic: false,
font_family: None,
weight_bits: 0,
underline: false,
strikethrough: false,
letter_bits: 0,
word_bits: 0,
overflow_wrap: false,
};
// Layouts vacíos como valores (sólo nos importa la presencia de claves).
let dummy = parley::Layout::<()>::default;
c.put(mk("a"), dummy());
c.put(mk("b"), dummy());
// "a" sigue caliente; lo accedemos para que se quede al rotar.
assert!(c.get(&mk("a")).is_some());
// Tercer insert: hot llegó a cap(2) → rota (a,b→cold), c entra a hot.
c.put(mk("c"), dummy());
// "a" estaba en cold; get lo encuentra y lo promueve a hot.
assert!(c.get(&mk("a")).is_some(), "ítem reaccedido sobrevive la rotación");
// "b" no se reaccedió: cae en la siguiente rotación.
c.put(mk("d"), dummy()); // hot = {c, a-promovido}? -> al llegar a cap rota
// Tras suficientes rotaciones sin tocar "b", desaparece.
c.put(mk("e"), dummy());
c.put(mk("f"), dummy());
assert!(c.get(&mk("b")).is_none(), "ítem nunca reaccedido se libera");
}
}
+2 -1
View File
@@ -5,8 +5,9 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-theme — paleta compartida entre apps Llimphi. Define los slots semánticos (bg_app, fg_text, accent, etc.) en `peniko::Color`; cada widget toma su paleta del Theme vía `Palette::from_theme(&theme)`."
[dependencies]
# Reexporta peniko::Color para que las apps consuman sin pull-in directo.
llimphi-raster = { path = "../llimphi-raster" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
+325 -13
View File
@@ -20,6 +20,37 @@ pub use llimphi_raster::peniko::Color;
use std::time::Duration;
// =====================================================================
// Color estable por semilla — avatares, etiquetas, hash-coloring
// =====================================================================
/// Paleta sobria de 8 tonos para colorear entidades por hash (avatares de
/// contactos, etiquetas de calendario…). Tonos apagados que conviven con
/// cualquier `Theme`. Usada vía [`stable_color`].
pub const ENTITY_PALETTE: [(u8, u8, u8); 8] = [
(94, 129, 172), // azul acero
(163, 109, 156), // malva
(122, 162, 110), // verde salvia
(191, 138, 92), // terracota
(108, 153, 168), // celeste apagado
(170, 120, 120), // rosa viejo
(130, 140, 175), // lavanda
(150, 150, 110), // oliva
];
/// Color estable derivado de una semilla: hash FNV-1a del texto → índice en
/// [`ENTITY_PALETTE`]. La misma semilla da siempre el mismo color, sin estado.
/// Para avatares (por correo), etiquetas, badges de entidad, etc.
pub fn stable_color(seed: &str) -> Color {
let mut h: u32 = 2_166_136_261;
for b in seed.bytes() {
h ^= b as u32;
h = h.wrapping_mul(16_777_619);
}
let (r, g, b) = ENTITY_PALETTE[(h as usize) % ENTITY_PALETTE.len()];
Color::from_rgba8(r, g, b, 255)
}
// =====================================================================
// Tokens transversales — motion, alpha, radius
// =====================================================================
@@ -32,16 +63,21 @@ use std::time::Duration;
// future variante por preset lo requiera.
/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
/// soporífero). Los widgets eligen `FAST` para microinteracciones
/// (hover, focus), `NORMAL` para transiciones principales (toast entrar,
/// modal abrir) y `SLOW` para énfasis o entradas dramáticas (splash de
/// boot).
/// soporífero). Los widgets eligen `MICRO` para tintes de hover/focus
/// que sólo necesitan suavizar el "salto", `FAST` para microinteracciones
/// completas (chip que pulsa), `NORMAL` para transiciones principales
/// (toast entrar, modal abrir), `SLOW` para énfasis o entradas dramáticas
/// (splash de boot, hero shared-element).
pub mod motion {
use super::Duration;
/// Tintes hover/focus — apenas perceptible, sólo elimina el "clack".
pub const MICRO: Duration = Duration::from_millis(50);
pub const FAST: Duration = Duration::from_millis(80);
pub const NORMAL: Duration = Duration::from_millis(160);
pub const SLOW: Duration = Duration::from_millis(320);
/// Entradas dramáticas (splash, hero shared-element).
pub const DRAMATIC: Duration = Duration::from_millis(480);
/// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
/// La gran mayoría de transiciones de salida / aparición.
@@ -64,6 +100,29 @@ pub mod motion {
}
}
/// Easing fuerte — quint-out. Arranca más rápido que cubic-out y
/// asienta más suave. Para elementos que aparecen "lanzados" (toast,
/// FAB).
#[inline]
pub fn ease_out_quint(t: f32) -> f32 {
let inv = 1.0 - t.clamp(0.0, 1.0);
1.0 - inv * inv * inv * inv * inv
}
/// Overshoot suave — back-out con `c1=1.70158` (Material/Penner
/// estándar). El valor pasa de 0 al objetivo, lo sobrepasa ~10 % y
/// vuelve. Para entradas que necesitan "ping" (modal, snackbar,
/// elemento nuevo en una lista). No usar para hover — la oscilación
/// se percibe nerviosa.
#[inline]
pub fn ease_out_back(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
const C1: f32 = 1.701_58;
const C3: f32 = C1 + 1.0;
let u = t - 1.0;
1.0 + C3 * u * u * u + C1 * u * u
}
/// Lineal — no es elegante pero a veces es lo correcto (barra de
/// progreso, valores numéricos crudos).
#[inline]
@@ -72,6 +131,29 @@ pub mod motion {
}
}
/// Tokens de **elevación** — sombras escalonadas. Como `Shadow` vive en
/// `llimphi-compositor` (y `llimphi-theme` no depende de él para
/// quedarse leaf), cada nivel se expone como `(alpha_u8, blur_px,
/// dy_px)`. Los widgets construyen su `Shadow` puenteándolo:
/// `Shadow { color: Color::from_rgba8(0,0,0, a), blur, dy, dx: 0.0, spread: 0.0 }`.
/// Escala perceptual logarítmica: cada nivel ~×2 de blur.
pub mod elevation {
/// `(alpha 0255, blur px, dy px)`. dy ≈ blur·0.4 (sombra natural,
/// fuente de luz un poco arriba).
pub type Elev = (u8, f64, f64);
/// E1 — chip levantado del fondo (hover button, badge).
pub const E1: Elev = (44, 4.0, 1.5);
/// E2 — card/tile flotante sobre el panel (default cards).
pub const E2: Elev = (60, 10.0, 4.0);
/// E3 — superficie destacada (menú contextual, dropdown).
pub const E3: Elev = (84, 18.0, 8.0);
/// E4 — overlay sobre la app (modal, dialog).
pub const E4: Elev = (110, 32.0, 14.0);
/// E5 — sello de identidad (FAB, hero, picker activo).
pub const E5: Elev = (140, 48.0, 22.0);
}
/// Valores de opacidad alfa (0255) para capas semánticas. Usar siempre
/// que se quiera *transparencia coherente*. El widget que improvisa su
/// propio alpha rompe la firma visual.
@@ -177,6 +259,48 @@ impl Theme {
}
}
/// Tema **"Tawa"** — el LOOK FIRMA de la suite, el que se publica en
/// screenshots. Decisiones de paleta:
///
/// - **Base negro cálido, no azul marino.** A diferencia de `dark()` (un
/// navy genérico, R<B) acá el fondo es un casi-negro con temperatura:
/// los canales rojo/verde van un punto por encima del azul, así el grafito
/// "respira" tibio en vez de frío. Jerarquía de superficies en escalera
/// suave: `bg_app` (más profundo) → `bg_panel`/`bg_input` → barras → chips,
/// sin saltos bruscos.
/// - **Acento teal-eléctrico (#2BD9A6), NO azul.** El mar de unixporn es
/// todo azul (Catppuccin/Tokyo Night); elegimos un verde-aguamarina
/// vibrante para destacar de inmediato — distintivo pero no chillón, con
/// raíz en el teal del CDE / del logo de la suite. `accent` y
/// `border_focus` comparten ese tono; la selección lo usa atenuado para
/// no encandilar filas enteras.
/// - **Texto legible (WCAG AA).** `fg_text` (#E8E6E0, marfil cálido) supera
/// 12:1 sobre `bg_app` y ~10:1 sobre `bg_panel`; `fg_muted` ronda 5:1
/// (texto secundario cómodo); `fg_placeholder` queda como hint tenue.
/// - **Armonía:** todos los grises llevan el mismo tinte cálido y el acento
/// es el único color saturado — la paleta se lee como una sola pieza.
pub const fn tawa() -> Self {
Self {
name: "Tawa",
bg_app: Color::from_rgba8(20, 19, 17, 255), // grafito cálido casi-negro
bg_panel: Color::from_rgba8(30, 28, 26, 255),
bg_panel_alt: Color::from_rgba8(25, 24, 22, 255),
bg_input: Color::from_rgba8(24, 23, 21, 255),
bg_input_focus: Color::from_rgba8(32, 30, 28, 255),
bg_button: Color::from_rgba8(42, 40, 37, 255),
bg_button_hover: Color::from_rgba8(56, 53, 49, 255),
bg_selected: Color::from_rgba8(26, 74, 64, 255), // teal hundido (selección)
bg_row_hover: Color::from_rgba8(40, 38, 35, 255),
fg_text: Color::from_rgba8(232, 230, 224, 255), // marfil cálido
fg_muted: Color::from_rgba8(160, 154, 144, 255),
fg_placeholder: Color::from_rgba8(112, 107, 99, 255),
fg_destructive: Color::from_rgba8(232, 116, 97, 255), // coral (cálido, no rojo puro)
border: Color::from_rgba8(54, 51, 47, 255),
border_focus: Color::from_rgba8(43, 217, 166, 255), // teal-eléctrico
accent: Color::from_rgba8(43, 217, 166, 255), // #2BD9A6 — la firma
}
}
/// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
/// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
/// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
@@ -278,18 +402,179 @@ impl Theme {
}
}
/// Todos los presets del repo, en el orden canónico de rotación
/// (Dark → Light → Aurora → Sunset → Dark…). El theme-switcher
/// los consume vía [`Theme::next_after`]. `print()` queda fuera de la
/// rotación a propósito — es un modo deliberado (imprimir), no un
/// gusto estético que se cicle por accidente.
pub fn all() -> Vec<Self> {
vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()]
/// Skin **Windows XP "Luna"** — escritorio azul-gris claro, selección y
/// acento en el azul XP (#316AC5), chrome celeste. Para la vista `windows-xp`.
pub const fn xp_blue() -> Self {
Self {
name: "WinXP",
bg_app: Color::from_rgba8(236, 240, 249, 255),
bg_panel: Color::from_rgba8(214, 223, 247, 255),
bg_panel_alt: Color::from_rgba8(60, 100, 190, 255), // franja azul (taskbar)
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(248, 250, 255, 255),
bg_button: Color::from_rgba8(222, 230, 246, 255),
bg_button_hover: Color::from_rgba8(198, 214, 244, 255),
bg_selected: Color::from_rgba8(49, 106, 197, 255), // azul de selección XP
bg_row_hover: Color::from_rgba8(214, 226, 248, 255),
fg_text: Color::from_rgba8(20, 30, 50, 255),
fg_muted: Color::from_rgba8(78, 92, 120, 255),
fg_placeholder: Color::from_rgba8(130, 142, 168, 255),
fg_destructive: Color::from_rgba8(176, 32, 32, 255),
border: Color::from_rgba8(122, 152, 206, 255),
border_focus: Color::from_rgba8(49, 106, 197, 255),
accent: Color::from_rgba8(36, 94, 220, 255), // Luna blue
}
}
/// Busca un preset por nombre exacto.
/// Skin **macOS (Big Sur claro)** — casi blanco, grises sutiles, acento
/// azul de sistema (#0A84FF). Para la vista `mac`.
pub const fn mac_light() -> Self {
Self {
name: "macOS",
bg_app: Color::from_rgba8(246, 246, 248, 255),
bg_panel: Color::from_rgba8(236, 236, 240, 255),
bg_panel_alt: Color::from_rgba8(242, 242, 245, 235), // menubar translúcida
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(252, 252, 255, 255),
bg_button: Color::from_rgba8(228, 228, 233, 255),
bg_button_hover: Color::from_rgba8(214, 214, 221, 255),
bg_selected: Color::from_rgba8(10, 132, 255, 255),
bg_row_hover: Color::from_rgba8(232, 234, 240, 255),
fg_text: Color::from_rgba8(28, 28, 32, 255),
fg_muted: Color::from_rgba8(110, 110, 120, 255),
fg_placeholder: Color::from_rgba8(160, 160, 170, 255),
fg_destructive: Color::from_rgba8(215, 58, 50, 255),
border: Color::from_rgba8(208, 208, 215, 255),
border_focus: Color::from_rgba8(10, 132, 255, 255),
accent: Color::from_rgba8(10, 132, 255, 255),
}
}
/// Skin **KDE Plasma "Breeze" (claro)** — gris papel (#eff0f1), acento
/// azul Breeze (#3daee9). Para la vista `kde`.
pub const fn kde_breeze() -> Self {
Self {
name: "Breeze",
bg_app: Color::from_rgba8(239, 240, 241, 255),
bg_panel: Color::from_rgba8(252, 252, 252, 255),
bg_panel_alt: Color::from_rgba8(49, 54, 59, 255), // panel oscuro Breeze
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(248, 252, 254, 255),
bg_button: Color::from_rgba8(224, 226, 228, 255),
bg_button_hover: Color::from_rgba8(208, 211, 214, 255),
bg_selected: Color::from_rgba8(61, 174, 233, 255),
bg_row_hover: Color::from_rgba8(227, 229, 231, 255),
fg_text: Color::from_rgba8(35, 38, 41, 255),
fg_muted: Color::from_rgba8(99, 104, 109, 255),
fg_placeholder: Color::from_rgba8(150, 155, 160, 255),
fg_destructive: Color::from_rgba8(218, 68, 83, 255),
border: Color::from_rgba8(188, 192, 196, 255),
border_focus: Color::from_rgba8(61, 174, 233, 255),
accent: Color::from_rgba8(61, 174, 233, 255),
}
}
/// Skin **Windows 3.1**: gris Motif (#c0c0c0) con barra de título azul
/// marino (#000080) y escritorio teal. La era de los biseles. Para la vista
/// `windows-3.1`.
pub const fn win31() -> Self {
Self {
name: "Win3.1",
bg_app: Color::from_rgba8(0, 128, 128, 255), // escritorio teal clásico
bg_panel: Color::from_rgba8(192, 192, 192, 255), // gris ventana
bg_panel_alt: Color::from_rgba8(0, 0, 128, 255), // barra de título azul marino
bg_input: Color::from_rgba8(255, 255, 255, 255),
bg_input_focus: Color::from_rgba8(255, 255, 255, 255),
bg_button: Color::from_rgba8(192, 192, 192, 255),
bg_button_hover: Color::from_rgba8(208, 208, 208, 255),
bg_selected: Color::from_rgba8(0, 0, 128, 255),
bg_row_hover: Color::from_rgba8(200, 200, 200, 255),
fg_text: Color::from_rgba8(0, 0, 0, 255),
fg_muted: Color::from_rgba8(64, 64, 64, 255),
fg_placeholder: Color::from_rgba8(112, 112, 112, 255),
fg_destructive: Color::from_rgba8(128, 0, 0, 255),
border: Color::from_rgba8(128, 128, 128, 255),
border_focus: Color::from_rgba8(0, 0, 128, 255),
accent: Color::from_rgba8(0, 0, 128, 255), // azul Win3.1
}
}
/// Skin **Solaris CDE** (era dorada): gris-beige Motif con acento teal —
/// el Common Desktop Environment. Para la vista `solaris`.
pub const fn cde() -> Self {
Self {
name: "CDE",
bg_app: Color::from_rgba8(45, 70, 90, 255), // fondo azul-gris CDE
bg_panel: Color::from_rgba8(174, 178, 195, 255), // gris-lila Motif
bg_panel_alt: Color::from_rgba8(120, 130, 150, 255),
bg_input: Color::from_rgba8(220, 222, 230, 255),
bg_input_focus: Color::from_rgba8(235, 237, 244, 255),
bg_button: Color::from_rgba8(160, 166, 185, 255),
bg_button_hover: Color::from_rgba8(176, 182, 200, 255),
bg_selected: Color::from_rgba8(90, 130, 130, 255),
bg_row_hover: Color::from_rgba8(168, 174, 192, 255),
fg_text: Color::from_rgba8(20, 24, 32, 255),
fg_muted: Color::from_rgba8(64, 72, 84, 255),
fg_placeholder: Color::from_rgba8(100, 108, 120, 255),
fg_destructive: Color::from_rgba8(140, 40, 40, 255),
border: Color::from_rgba8(108, 116, 134, 255),
border_focus: Color::from_rgba8(64, 132, 132, 255),
accent: Color::from_rgba8(64, 132, 132, 255), // teal CDE
}
}
/// Superficie "hundida" — un escalón más profunda que `bg_app`, para
/// áreas de lectura intensa (output de terminal, viewports de log,
/// IDE-text) que deben recibir el texto con más contraste que el chrome
/// y leerse recesadas respecto del marco. En temas oscuros oscurece
/// `bg_app` hacia el negro; en claros lo aleja un paso del blanco. Las
/// cards/strips (`bg_panel`, `bg_panel_alt`) quedan flotando por encima.
/// Derivada de la paleta — no inventa un color suelto.
pub fn sunken(&self) -> Color {
let c = self.bg_app.components;
// Luminancia relativa aproximada en sRGB (sin linealizar — alcanza
// para decidir oscuro/claro).
let lum = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
let factor = if lum < 0.5 { 0.5 } else { 0.93 };
Color::from_rgba8(
(c[0] * factor * 255.0).round().clamp(0.0, 255.0) as u8,
(c[1] * factor * 255.0).round().clamp(0.0, 255.0) as u8,
(c[2] * factor * 255.0).round().clamp(0.0, 255.0) as u8,
255,
)
}
/// Todos los presets del repo, en el orden canónico de rotación
/// (Tawa → Dark → Light → Aurora → Sunset → Tawa…). `tawa()` va **al
/// frente**: es el look firma de la suite, el primero que se ve. El
/// theme-switcher los consume vía [`Theme::next_after`]. `print()` queda
/// fuera de la rotación a propósito — es un modo deliberado (imprimir), no
/// un gusto estético que se cicle por accidente.
pub fn all() -> Vec<Self> {
vec![
Self::tawa(),
Self::dark(),
Self::light(),
Self::aurora(),
Self::sunset(),
]
}
/// Busca un preset por nombre exacto. Incluye los modos deliberados que
/// quedan fuera de la rotación casual (`print` y los skins de vista
/// `WinXP`/`macOS`/`Breeze`), para que `Config::theme` los resuelva.
pub fn by_name(name: &str) -> Option<Self> {
Self::all().into_iter().find(|t| t.name == name)
Self::all()
.into_iter()
.chain([
Self::print(),
Self::xp_blue(),
Self::mac_light(),
Self::kde_breeze(),
Self::win31(),
Self::cde(),
])
.find(|t| t.name == name)
}
/// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
@@ -358,4 +643,31 @@ mod tests {
fn dark_is_the_default() {
assert_eq!(Theme::default().name, "Dark");
}
/// "Tawa" — el look firma — entra en la rotación y va al frente, y
/// `by_name` lo resuelve.
#[test]
fn tawa_es_el_primero_y_se_resuelve() {
let all = Theme::all();
assert_eq!(all[0].name, "Tawa", "Tawa debe ir al frente de la rotación");
assert_eq!(Theme::by_name("Tawa").expect("registrado").name, "Tawa");
}
/// En temas oscuros la superficie hundida es más oscura que el chrome
/// (`bg_app`); en claros, también desciende (se lee recesada). En ambos
/// casos difiere de `bg_app` — no es un no-op.
#[test]
fn sunken_is_deeper_than_bg_app() {
let lum = |c: Color| {
let k = c.components;
0.2126 * k[0] + 0.7152 * k[1] + 0.0722 * k[2]
};
for t in Theme::all() {
assert!(
lum(t.sunken()) < lum(t.bg_app),
"{}: sunken debe ser más oscura que bg_app",
t.name
);
}
}
}
+33 -5
View File
@@ -1,19 +1,39 @@
[package]
name = "llimphi-ui"
description = "Native Rust UI framework: retained-mode View<Msg> Elm loop over vello + wgpu + taffy + parley."
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
[dependencies]
llimphi-hal = { path = "../llimphi-hal" }
llimphi-layout = { path = "../llimphi-layout" }
llimphi-raster = { path = "../llimphi-raster" }
llimphi-text = { path = "../llimphi-text" }
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
# El compositor declarativo (winit-free): View, mount, paint, hit-test.
llimphi-compositor = { path = "../llimphi-compositor" }
llimphi-compositor = { path = "../llimphi-compositor", version = "0.1.0" }
pollster = { workspace = true }
# Árbol de accesibilidad por frame (NVDA/VoiceOver/Orca/TalkBack). Lo
# alimentamos desde `View::semantics` + el árbol Mounted. `accesskit_winit`
# es el adapter que conecta el árbol a la API nativa del SO vía winit.
accesskit = { workspace = true }
accesskit_winit = { workspace = true }
# `accesskit::TreeId` envuelve un `uuid::Uuid` — generamos uno por proceso
# para identificar el árbol entre actualizaciones (el lector lo necesita
# para distinguir nuestra app de otras ventanas AccessKit del SO).
uuid = { version = "1", features = ["v4"] }
# Portapapeles del sistema para copiar texto seleccionado fuera del editor
# (Ctrl/Cmd+C). Best-effort: si no hay backend (headless, android) degrada a
# no-op sin panicar. Feature `clipboard` (default) para que builds sin display
# o targets sin arboard puedan apagarlo con --no-default-features.
arboard = { workspace = true, optional = true }
[features]
default = ["clipboard"]
clipboard = ["dep:arboard"]
[[example]]
name = "counter"
@@ -26,3 +46,11 @@ path = "examples/editor.rs"
[[example]]
name = "gpu_paint_demo"
path = "examples/gpu_paint_demo.rs"
[[example]]
name = "gestos"
path = "examples/gestos.rs"
[[example]]
name = "selectable_text"
path = "examples/selectable_text.rs"
+183
View File
@@ -0,0 +1,183 @@
//! Demo de la **arena de gestos** de Llimphi (Tier 4 de PARIDAD-FLUTTER).
//!
//! Un canvas pannable + zoomable que ejercita los tres gestos nuevos:
//!
//! - **Ctrl + rueda** → `on_scale`: zoom hacia el cursor (camino universal de
//! desktop; en macOS también responde al pinch del trackpad).
//! - **Arrastrar** (botón izquierdo) → `draggable`: paneo. Mover cancela un
//! long-press en curso — esa desambiguación es la "arena".
//! - **Doble-click** → `on_double_tap`: resetea zoom y paneo.
//! - **Mantener apretado ~500 ms quieto** → `on_long_press_at`: deja una marca
//! en el punto (coordenadas de mundo, así sigue al zoom/paneo).
//!
//! La barra inferior muestra el zoom, la cantidad de marcas y el último gesto.
//!
//! Corre con: `cargo run -p llimphi-ui --example gestos --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent,
};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Line, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::{App, DragPhase, GesturePhase, Handle, View};
#[derive(Clone)]
enum Msg {
/// Zoom incremental con factor multiplicativo + punto focal (local al canvas).
Zoom { factor: f32, fx: f32, fy: f32 },
/// Paneo por delta de arrastre.
Pan { dx: f32, dy: f32 },
/// Doble-tap: resetear la vista.
Reset,
/// Long-press: dejar una marca en el punto (local al canvas).
Mark { lx: f32, ly: f32 },
}
struct Model {
zoom: f32,
pan: (f32, f32),
/// Marcas en coordenadas de **mundo** (independientes del zoom/paneo).
marks: Vec<(f32, f32)>,
last: String,
}
struct Gestos;
impl App for Gestos {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"llimphi · gestos (pinch-zoom · double-tap · long-press)"
}
fn initial_size() -> (u32, u32) {
(900, 640)
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {
Model {
zoom: 1.0,
pan: (0.0, 0.0),
marks: Vec::new(),
last: "probá: Ctrl+rueda (zoom) · arrastrar (paneo) · doble-click (reset) · mantener (marca)".into(),
}
}
fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
match msg {
Msg::Zoom { factor, fx, fy } => {
// Zoom hacia el cursor: mantené fijo el punto de mundo bajo
// (fx, fy) reajustando el paneo. new_pan = focal - rf·(focal - pan).
let new_zoom = (model.zoom * factor).clamp(0.15, 12.0);
let rf = new_zoom / model.zoom; // factor real tras el clamp
model.pan.0 = fx - rf * (fx - model.pan.0);
model.pan.1 = fy - rf * (fy - model.pan.1);
model.zoom = new_zoom;
model.last = format!("zoom ×{:.2}", model.zoom);
}
Msg::Pan { dx, dy } => {
model.pan.0 += dx;
model.pan.1 += dy;
model.last = "paneo".into();
}
Msg::Reset => {
model.zoom = 1.0;
model.pan = (0.0, 0.0);
model.last = "doble-tap → reset".into();
}
Msg::Mark { lx, ly } => {
// Local del canvas → mundo: (local - pan) / zoom.
let wx = (lx - model.pan.0) / model.zoom;
let wy = (ly - model.pan.1) / model.zoom;
model.marks.push((wx, wy));
model.last = format!("long-press → marca #{} @ ({wx:.0}, {wy:.0})", model.marks.len());
}
}
model
}
fn view(model: &Self::Model) -> View<Self::Msg> {
let zoom = model.zoom;
let pan = model.pan;
let marks = model.marks.clone();
let canvas = View::new(Style {
size: Size { width: percent(1.0_f32), height: Dimension::auto() },
flex_grow: 1.0,
..Default::default()
})
.fill(Color::from_rgba8(16, 18, 26, 255))
.clip(true)
.paint_with(move |scene, _ts, rect| {
// Grilla de mundo paso 40px, escalada por zoom y desplazada por pan.
let step = 40.0 * zoom as f64;
if step >= 4.0 {
let thin = Stroke::new(1.0);
let grid = Color::from_rgba8(40, 46, 60, 255);
// Offset del primer línea visible (pan módulo step).
let ox = (rect.x as f64) + (pan.0 as f64).rem_euclid(step);
let mut x = ox;
while x < (rect.x + rect.w) as f64 {
scene.stroke(&thin, Affine::IDENTITY, grid, None,
&Line::new((x, rect.y as f64), (x, (rect.y + rect.h) as f64)));
x += step;
}
let oy = (rect.y as f64) + (pan.1 as f64).rem_euclid(step);
let mut y = oy;
while y < (rect.y + rect.h) as f64 {
scene.stroke(&thin, Affine::IDENTITY, grid, None,
&Line::new((rect.x as f64, y), ((rect.x + rect.w) as f64, y)));
y += step;
}
}
// Marcas (coords de mundo → pantalla): pan + world·zoom.
let dot = Color::from_rgba8(90, 220, 150, 255);
let r = (6.0 * zoom as f64).clamp(3.0, 24.0);
for (wx, wy) in &marks {
let sx = rect.x as f64 + pan.0 as f64 + (*wx as f64) * zoom as f64;
let sy = rect.y as f64 + pan.1 as f64 + (*wy as f64) * zoom as f64;
scene.fill(Fill::NonZero, Affine::IDENTITY, dot, None, &Circle::new((sx, sy), r));
}
})
// Pinch-to-zoom (Ctrl+rueda / trackpad). El focal viene local al canvas.
.on_scale(|phase, factor, fx, fy| match phase {
GesturePhase::Update => Some(Msg::Zoom { factor, fx, fy }),
_ => None,
})
// Paneo por arrastre. El movimiento cancela un long-press en curso.
.draggable(|phase, dx, dy| match phase {
DragPhase::Move => Some(Msg::Pan { dx, dy }),
DragPhase::End => None,
})
// Doble-tap: reset. Long-press: marca en el punto.
.on_double_tap(Msg::Reset)
.on_long_press_at(|lx, ly, _w, _h| Some(Msg::Mark { lx, ly }));
let status = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(40.0_f32) },
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.fill(Color::from_rgba8(28, 32, 42, 255))
.text(
format!("{} · ×{:.2} · {} marcas", model.last, model.zoom, model.marks.len()),
18.0,
Color::from_rgba8(210, 220, 235, 255),
);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.fill(Color::from_rgba8(16, 18, 26, 255))
.children(vec![canvas, status])
}
}
fn main() {
llimphi_ui::run::<Gestos>();
}
+1
View File
@@ -177,6 +177,7 @@ fn draw_points(
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
// Load preserva el fondo vello ya pintado en este frame.
load: wgpu::LoadOp::Load,
+102
View File
@@ -0,0 +1,102 @@
//! Texto seleccionable **fuera del editor**: arrastrá el mouse sobre los
//! párrafos para resaltar y Ctrl/Cmd+C para copiar al portapapeles. La
//! selección la maneja el runtime (`View::selectable(key)`) — la app no
//! guarda estado de selección en su `Model`.
//!
//! Corre con: `cargo run -p llimphi-ui --example selectable_text --release`.
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Rect, Size, Style},
AlignItems,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{App, Handle, View};
struct Demo;
const PARRAFOS: [&str; 3] = [
"Arrastrá el cursor sobre este texto para seleccionarlo. La selección \
vive en el runtime de Llimphi, no en el Model de la app.",
"Cada párrafo tiene su propia key estable; empezar a arrastrar en otro \
reemplaza la selección anterior. Ctrl+C (o Cmd+C en macOS) copia el \
rango resaltado al portapapeles del sistema.",
"No es un editor: es texto de sólo lectura que igual se puede leer, \
resaltar y copiar labels, párrafos, celdas, salidas de consola.",
];
impl App for Demo {
type Model = ();
type Msg = ();
fn title() -> &'static str {
"llimphi · texto seleccionable"
}
fn init(_: &Handle<Self::Msg>) -> Self::Model {}
fn update(_: Self::Model, _: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {}
fn view(_: &Self::Model) -> View<Self::Msg> {
let mut children: Vec<View<()>> = vec![View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
"Texto seleccionable (arrastrá + Ctrl/Cmd+C)",
22.0,
Color::from_rgba8(230, 240, 250, 255),
Alignment::Start,
)];
for (i, p) in PARRAFOS.iter().enumerate() {
children.push(
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
..Default::default()
})
.text_aligned(
*p,
16.0,
Color::from_rgba8(205, 214, 226, 255),
Alignment::Start,
)
// La línea clave: cada párrafo es seleccionable con una key
// estable (su índice). El resaltado + copy los hace el runtime.
.selectable(i as u64),
);
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
gap: Size {
width: length(0.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(40.0_f32),
right: length(40.0_f32),
top: length(36.0_f32),
bottom: length(36.0_f32),
},
align_items: Some(AlignItems::Start),
..Default::default()
})
.fill(Color::from_rgba8(20, 24, 32, 255))
.children(children)
}
}
fn main() {
llimphi_ui::run::<Demo>();
}
+310
View File
@@ -0,0 +1,310 @@
//! Traducción del árbol Llimphi a un árbol [AccessKit](https://accesskit.dev)
//! para alimentar lectores de pantalla y otras tecnologías de asistencia.
//!
//! Cada frame el runtime llama a [`build_tree`] con el árbol montado +
//! `ComputedLayout` + el id de foco actual. La función produce un
//! `accesskit::TreeUpdate` que el adapter (`accesskit_winit::Adapter`) empuja
//! al sistema operativo.
//!
//! ## Mapeo de identidades
//!
//! Cada `MountedNode` recibe un `NodeId(idx + ROOT_OFFSET)` derivado de su
//! índice en `Mounted::nodes` — estable dentro de un frame, no necesariamente
//! entre frames (si la app re-renderiza un árbol distinto, los ids cambian).
//! `ROOT_NODE_ID` queda reservado para el nodo raíz sintético que envuelve
//! todo el árbol. Los nodos sin semántica declarada igual aparecen en el árbol
//! si contienen texto, son `focusable` o tienen `on_click` — los lectores los
//! anuncian aunque el caller no haya marcado un rol explícito.
//!
//! ## Acciones soportadas (v1)
//!
//! - `Action::Focus`: mueve el foco de Llimphi a ese nodo (vía
//! [`crate::App::on_focus`]).
//! - `Action::Click` / `Default`: ejecuta el `on_click` del nodo si existe;
//! los handlers `*_at` se ignoran en v1 (no tienen una posición sintética
//! coherente — la documentamos como limitación).
use accesskit::{Action, Node, NodeId, Rect as AkRect, Role as AkRole, Tree, TreeId, TreeUpdate};
use llimphi_compositor::{Mounted, Role as LRole, SemanticsSpec};
use llimphi_layout::ComputedLayout;
/// NodeId reservado para la raíz sintética del árbol. El nodo App es siempre
/// el padre lógico de todos los `MountedNode` que producimos.
pub const ROOT_NODE_ID: NodeId = NodeId(1);
/// Offset desde el cual numeramos los `MountedNode`. Deja el rango [0, OFFSET)
/// para ids reservados (root y futuros nodos sintéticos como overlays).
const MOUNTED_OFFSET: u64 = 1000;
/// `NodeId` AccessKit asignado al `MountedNode` con índice `idx`. La función
/// es inversa de [`mounted_idx_for`].
pub fn node_id_for(idx: usize) -> NodeId {
NodeId(MOUNTED_OFFSET + idx as u64)
}
/// Recupera el índice del `MountedNode` que corresponde a un `NodeId`
/// AccessKit, o `None` si el id está fuera del rango de nodos montados.
pub fn mounted_idx_for(id: NodeId) -> Option<usize> {
let v = id.0;
if v >= MOUNTED_OFFSET {
Some((v - MOUNTED_OFFSET) as usize)
} else {
None
}
}
/// Construye el árbol AccessKit completo para el frame actual.
///
/// `focused_idx` es el índice del `MountedNode` enfocado (si lo hay) —
/// resolvelo desde `state.focused: Option<u64>` mapeando contra el campo
/// `focusable` de cada MountedNode.
pub fn build_tree<Msg>(
mounted: &Mounted<Msg>,
computed: &ComputedLayout,
focused_idx: Option<usize>,
app_name: &str,
tree_id: TreeId,
) -> TreeUpdate {
let mut nodes: Vec<(NodeId, Node)> = Vec::with_capacity(mounted.nodes.len() + 1);
// 1) Raíz sintética: lista los hijos top-level (los nodos cuya posición en
// el array es 0 o queda fuera de cualquier subárbol previo). En la práctica
// el `MountedNode` con índice 0 es la raíz del View — la usamos directo.
let mut root = Node::new(AkRole::Window);
root.set_label(app_name.to_string());
if !mounted.nodes.is_empty() {
root.set_children(vec![node_id_for(0)]);
}
nodes.push((ROOT_NODE_ID, root));
// 2) Un Node AccessKit por cada MountedNode. Hijos = los hijos directos en
// el árbol pre-orden (subtree_end nos da el rango).
for (idx, mn) in mounted.nodes.iter().enumerate() {
let mut node = Node::new(map_role(&mn.semantics, mn));
// Bounds del nodo (rect absoluto del layout). Sin esto el lector no
// sabe dónde está visualmente y la navegación por reading order
// degrada a "como vinieron".
if let Some(r) = computed.get(mn.id) {
node.set_bounds(AkRect {
x0: r.x as f64,
y0: r.y as f64,
x1: (r.x + r.w) as f64,
y1: (r.y + r.h) as f64,
});
}
// Label / value / description. Si la app declaró semantics, mandamos
// esos. Si no, intentamos derivar un label del texto visible — los
// lectores leen igualmente texto sin rol, pero un label explícito es
// más claro.
if let Some(spec) = &mn.semantics {
apply_semantics(&mut node, spec);
// Si declaró rol pero no label y hay texto plano en el nodo,
// caemos al texto: cubre widgets como `app-header` que setean
// `.role(Heading)` sobre un nodo con `text_aligned("Título", …)`
// sin duplicar el string en `.aria_label(...)`.
if spec.label.is_none() {
if let Some(t) = &mn.text {
node.set_label(t.content.clone());
}
}
} else if let Some(t) = &mn.text {
node.set_label(t.content.clone());
}
// Acciones: declaramos las que el adapter va a recibir y ejecutar.
// `Focus` para cualquier nodo enfocable; `Click` para cualquier nodo
// con `on_click`. El handler del runtime las despacha en `act` (ver
// eventloop.rs).
if mn.focusable.is_some() {
node.add_action(Action::Focus);
}
if mn.on_click.is_some() || mn.on_click_at.is_some() {
node.add_action(Action::Click);
}
// Hijos: rango [idx+1, subtree_end) — pero acá necesitamos sólo los
// hijos DIRECTOS, no descendientes. Los hijos directos son los nodos
// cuyo padre es este: en el orden pre-orden con `subtree_end`, los
// hijos directos del nodo idx son los nodos h tales que h.parent == idx.
// Lo computamos: empezamos desde idx+1 y saltamos por subtree_end de
// cada hijo, hasta salir del rango.
let children = direct_children(mounted, idx);
if !children.is_empty() {
node.set_children(children.into_iter().map(node_id_for).collect::<Vec<_>>());
}
nodes.push((node_id_for(idx), node));
}
let focus = focused_idx
.map(node_id_for)
.unwrap_or(ROOT_NODE_ID);
TreeUpdate {
nodes,
tree: Some(Tree::new(ROOT_NODE_ID)),
focus,
tree_id,
}
}
/// Índices de los hijos directos del MountedNode `parent_idx`. Asume el
/// recorrido pre-orden estándar del `mount`: el primer hijo está en
/// `parent_idx + 1`; los siguientes se obtienen saltando por `subtree_end`.
fn direct_children<Msg>(mounted: &Mounted<Msg>, parent_idx: usize) -> Vec<usize> {
let parent = &mounted.nodes[parent_idx];
let mut out = Vec::new();
let mut cursor = parent_idx + 1;
while cursor < parent.subtree_end {
out.push(cursor);
cursor = mounted.nodes[cursor].subtree_end;
}
out
}
/// Aplica los campos de un `SemanticsSpec` sobre un `accesskit::Node` recién
/// creado (rol ya fijado). Mapea flags ARIA → setters AccessKit.
fn apply_semantics(node: &mut Node, spec: &SemanticsSpec) {
if let Some(label) = &spec.label {
node.set_label(label.to_string());
}
if let Some(desc) = &spec.description {
node.set_description(desc.to_string());
}
if let Some(value) = &spec.value {
node.set_value(value.to_string());
}
// Flags. AccessKit usa `toggled` para checked/pressed (mismo enum); para
// expanded hay `set_expanded(bool)`; disabled = is_disabled flag.
if let Some(checked) = spec.flags.checked.or(spec.flags.pressed) {
node.set_toggled(if checked {
accesskit::Toggled::True
} else {
accesskit::Toggled::False
});
}
if let Some(expanded) = spec.flags.expanded {
node.set_expanded(expanded);
}
if spec.flags.disabled == Some(true) {
node.set_disabled();
}
if spec.flags.readonly == Some(true) {
node.set_read_only();
}
if spec.flags.required == Some(true) {
node.set_required();
}
}
/// Mapea un `Role` de Llimphi a un `accesskit::Role`. Para nodos sin
/// `semantics` declarado, fallback a `Role::GenericContainer` (un grupo
/// transparente que no aporta semántica propia pero permite que la jerarquía
/// se navegue).
fn map_role<Msg>(spec: &Option<SemanticsSpec>, _mn: &llimphi_compositor::MountedNode<Msg>) -> AkRole {
let Some(role) = spec.as_ref().and_then(|s| s.role) else {
return AkRole::GenericContainer;
};
match role {
LRole::Button => AkRole::Button,
LRole::TextInput => AkRole::TextInput,
LRole::Heading => AkRole::Heading,
LRole::Checkbox => AkRole::CheckBox,
LRole::Label => AkRole::Label,
LRole::Link => AkRole::Link,
LRole::MenuItem => AkRole::MenuItem,
LRole::Tab => AkRole::Tab,
LRole::Image => AkRole::Image,
LRole::Slider => AkRole::Slider,
LRole::Group => AkRole::Group,
}
}
#[cfg(test)]
mod tests {
use super::*;
use llimphi_compositor::{mount, Role as LRole, View};
use llimphi_layout::taffy::prelude::length;
use llimphi_layout::taffy::Size;
use llimphi_layout::{LayoutTree, Style};
/// Monta un árbol con un nodo botón + un texto plano. Devuelve mounted +
/// computed contra un viewport razonable.
fn arbol_simple() -> (llimphi_compositor::Mounted<()>, ComputedLayout) {
let boton = View::<()>::new(Style {
size: Size { width: length(80.0_f32), height: length(40.0_f32) },
..Default::default()
})
.role(LRole::Button)
.aria_label("Guardar");
let texto = View::<()>::new(Style::default()).text(
"Hola",
14.0,
llimphi_raster::peniko::Color::WHITE,
);
let raiz = View::<()>::new(Style::default()).children(vec![boton, texto]);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, raiz);
let computed = layout
.compute(mounted.root, (1000.0_f32, 1000.0_f32))
.expect("layout");
(mounted, computed)
}
#[test]
fn build_tree_arma_raiz_y_un_node_por_mounted() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "tawasuyu-test", TreeId(uuid::Uuid::nil()));
// root + 3 nodos (raíz View, boton, texto).
assert_eq!(tree.nodes.len(), 1 + m.nodes.len());
assert_eq!(tree.nodes[0].0, ROOT_NODE_ID);
// El segundo Node es el primer MountedNode (raíz del View).
assert_eq!(tree.nodes[1].0, node_id_for(0));
// Foco fallback = root sintético.
assert_eq!(tree.focus, ROOT_NODE_ID);
}
#[test]
fn boton_con_label_se_traduce_a_role_button() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
// El nodo con role=Button debería tener rol Button en accesskit.
let boton_node = tree
.nodes
.iter()
.find(|(_, n)| n.role() == AkRole::Button)
.expect("hay un Button");
assert_eq!(boton_node.1.label().as_deref(), Some("Guardar"));
}
#[test]
fn texto_sin_semantica_se_lee_como_label_del_node_generico() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, None, "test", TreeId(uuid::Uuid::nil()));
// Algún nodo con label "Hola" (el texto plano).
assert!(
tree.nodes
.iter()
.any(|(_, n)| n.label().as_deref() == Some("Hola")),
"el texto plano debería aparecer como label"
);
}
#[test]
fn foco_explicito_se_refleja_en_treeupdate_focus() {
let (m, c) = arbol_simple();
let tree = build_tree(&m, &c, Some(1), "test", TreeId(uuid::Uuid::nil()));
assert_eq!(tree.focus, node_id_for(1));
}
#[test]
fn mounted_idx_for_invierte_node_id_for() {
for i in 0..16 {
let nid = node_id_for(i);
assert_eq!(mounted_idx_for(nid), Some(i));
}
assert_eq!(mounted_idx_for(ROOT_NODE_ID), None);
}
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
// a11y_rt.rs — Integración con AccessKit en tiempo de ejecución.
// Empuja el árbol de accesibilidad al SO y atiende las acciones del lector
// de pantalla (focus, click). Separado para no contaminar el flujo de input
// con detalles de la API de accesskit_winit.
use super::super::*;
use super::push_a11y_tree;
impl<A: App> Runtime<A> {
/// Recibe un `accesskit_winit::Event` (ruteado vía `EventLoopProxy` como
/// `UserEvent::A11y(...)`) y reacciona:
/// - `InitialTreeRequested`: el lector pidió el árbol inicial → empujamos
/// uno desde `last_render` si lo hay, o pedimos un redraw que lo creará.
/// - `ActionRequested(req)`: el lector quiere ejecutar una acción sobre un
/// `NodeId`. v1 soporta `Action::Focus` (mueve `state.focused` + dispara
/// `App::on_focus`) y `Action::Click` (ejecuta el `on_click` del nodo).
/// - `AccessibilityDeactivated`: nada que hacer; el siguiente paint dejará
/// de construir trees (el `update_if_active` se autoinhibe).
pub(super) fn handle_a11y_event(&mut self, ev: accesskit_winit::Event) {
use accesskit_winit::WindowEvent as AkWinEvent;
let Some(state) = self.state.as_mut() else { return };
match ev.window_event {
AkWinEvent::InitialTreeRequested => {
// Si ya pintamos un frame, ese mounted sirve para el árbol
// inicial. Si no, forzamos un redraw — el path normal llamará
// a `push_a11y_tree::<A>` al final.
if state.last_render.is_some() {
push_a11y_tree::<A>(state);
} else {
state.window.request_redraw();
}
}
AkWinEvent::ActionRequested(req) => {
let Some(idx) = crate::a11y::mounted_idx_for(req.target_node) else {
return;
};
let Some(cache) = state.last_render.as_ref() else {
return;
};
let Some(node) = cache.mounted.nodes.get(idx) else {
return;
};
match req.action {
accesskit::Action::Focus => {
// Si el nodo es focusable, movemos el foco a su id
// opaco; si no, lo limpiamos. La app recibe la
// transición vía `App::on_focus`.
let new_focus = node.focusable;
state.focused = new_focus;
let model = state.model.as_ref().expect("model");
if let Some(msg) = A::on_focus(model, new_focus) {
let m = state.model.take().expect("model");
state.model = Some(A::update(m, msg, &self.handle));
}
state.last_render = None;
state.window.request_redraw();
}
accesskit::Action::Click => {
// Sólo soportamos `on_click` (Msg directo) en v1. Los
// handlers `*_at` necesitan una posición sintética
// coherente que no tenemos — los ignoramos.
if let Some(msg) = node.on_click.clone() {
let m = state.model.take().expect("model");
state.model = Some(A::update(m, msg, &self.handle));
state.last_render = None;
state.window.request_redraw();
}
}
_ => {
// Otras acciones (Expand/Collapse/Increment/Decrement/
// SetValue/ScrollIntoView/etc.) se sumarán cuando un
// widget concreto lo pida — el modelo `SemanticsSpec`
// ya tiene los flags relevantes; solo falta cablear el
// efecto inverso (acción → mutación de Model).
}
}
}
AkWinEvent::AccessibilityDeactivated => {}
}
}
}
+305
View File
@@ -0,0 +1,305 @@
// helpers.rs — Funciones puras auxiliares del bucle Elm.
// Todas sin efecto observable sobre el runtime; se testean fácil de forma aislada.
use super::super::*;
/// Mapea el [`Cursor`](llimphi_compositor::Cursor) llimphi-native (resuelto por
/// el hit-test de hover) a `winit::window::CursorIcon`. `None` → flecha default.
/// Mantiene al compositor winit-free: la traducción vive sólo en el runtime.
pub(super) fn to_winit_cursor(c: Option<llimphi_compositor::Cursor>) -> llimphi_hal::winit::window::CursorIcon {
use llimphi_compositor::Cursor as C;
use llimphi_hal::winit::window::CursorIcon as I;
match c {
None | Some(C::Default) => I::Default,
Some(C::Pointer) => I::Pointer,
Some(C::Text) => I::Text,
Some(C::Crosshair) => I::Crosshair,
Some(C::Move) => I::Move,
Some(C::Grab) => I::Grab,
Some(C::Grabbing) => I::Grabbing,
Some(C::NotAllowed) => I::NotAllowed,
Some(C::Wait) => I::Wait,
Some(C::Progress) => I::Progress,
Some(C::Help) => I::Help,
Some(C::ColResize) => I::ColResize,
Some(C::RowResize) => I::RowResize,
Some(C::EwResize) => I::EwResize,
Some(C::NsResize) => I::NsResize,
Some(C::NeswResize) => I::NeswResize,
Some(C::NwseResize) => I::NwseResize,
Some(C::ZoomIn) => I::ZoomIn,
Some(C::ZoomOut) => I::ZoomOut,
}
}
/// Resuelve el handler de **escala** (pinch-to-zoom) bajo el punto `(x, y)`
/// contra el cache del último frame (overlay con prioridad, igual que clicks).
/// Devuelve `(handler, focal_x, focal_y)` con el punto focal ya en coordenadas
/// **locales** al rect del nodo. `None` si no hay nodo `on_scale` bajo el
/// cursor. Compartido por el camino Ctrl+rueda y el de `PinchGesture`.
pub(super) fn scale_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(ScaleFn<Msg>, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_scale(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.on_scale.clone().map(|h| {
let (fx, fy) = c
.get(node.id)
.map(|r| (x - r.x, y - r.y))
.unwrap_or((0.0, 0.0));
(h, fx, fy)
})
})
}
/// Resuelve el handler de **rotación** (trackpad) bajo `(x, y)` contra el
/// cache del último frame (overlay con prioridad). Espejo de
/// [`scale_hit_from_cache`]. Devuelve `(handler, focal_x, focal_y)` con el
/// punto focal local al rect del nodo. `None` si no hay nodo `on_rotate`.
pub(super) fn rotate_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(RotateFn<Msg>, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_rotate(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.on_rotate.clone().map(|h| {
let (fx, fy) = c
.get(node.id)
.map(|r| (x - r.x, y - r.y))
.unwrap_or((0.0, 0.0));
(h, fx, fy)
})
})
}
/// Resuelve el handler de **doble-tap** bajo `(x, y)` contra el cache del
/// último frame (overlay con prioridad). Elige la variante `_at` (con focal
/// local) si está, o el `Msg` directo. `None` si no hay nodo con doble-tap.
pub(super) fn double_tap_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<GestureResolved<Msg>> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_double_tap(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
if let Some(h) = node.on_double_tap_at.clone() {
Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
} else {
node.on_double_tap.clone().map(GestureResolved::Direct)
}
})
}
/// Como [`double_tap_hit_from_cache`] pero para **long-press**.
pub(super) fn long_press_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<GestureResolved<Msg>> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_long_press(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
let (rx, ry, rw, rh) = c.get(node.id).map(|r| (r.x, r.y, r.w, r.h)).unwrap_or_default();
if let Some(h) = node.on_long_press_at.clone() {
Some(GestureResolved::At(h, x - rx, y - ry, rw, rh))
} else {
node.on_long_press.clone().map(GestureResolved::Direct)
}
})
}
/// Resuelve el **ripple** bajo `(x, y)` contra el cache del último frame
/// (overlay con prioridad). Devuelve `(Ripple, lx, ly)`: la config de la onda
/// + el punto del tap relativo al rect del nodo. `None` si no hay nodo ripple.
pub(super) fn ripple_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(llimphi_compositor::Ripple, f32, f32)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
hit_test_ripple(m, c, x, y).and_then(|i| {
let node = &m.nodes[i];
node.ripple.map(|rp| {
let (rx, ry) = c.get(node.id).map(|r| (r.x, r.y)).unwrap_or_default();
(rp, x - rx, y - ry)
})
})
}
// ── Selección de texto fuera del editor (ver `View::selectable`) ──
/// Rect absoluto de un nodo: `(x, y, w, h)`.
pub(super) type AbsRect = (f32, f32, f32, f32);
/// `true` si el `TextSpec` es de texto **uniforme** (sin `runs`/`spans`): los
/// únicos que la selección fuera-del-editor soporta. Los multicolor/RichText
/// son del editor y se ignoran.
pub(super) fn spec_is_uniform(spec: &llimphi_compositor::TextSpec) -> bool {
spec.runs.is_none() && spec.spans.is_none()
}
/// Bajo `(x, y)`, el nodo de texto seleccionable más al frente: su key, su
/// `TextSpec` clonado y su rect absoluto. `None` si no hay texto seleccionable
/// uniforme ahí.
pub(super) fn selectable_hit_from_cache<Msg: Clone>(
cache: &RenderCache<Msg>,
x: f32,
y: f32,
) -> Option<(u64, llimphi_compositor::TextSpec, AbsRect)> {
let (m, c) = match cache.overlay.as_ref() {
Some(ov) => (&ov.mounted, &ov.computed),
None => (&cache.mounted, &cache.computed),
};
let i = hit_test_selectable(m, c, x, y)?;
let node = &m.nodes[i];
let key = node.text_select_key?;
let spec = node.text.as_ref()?;
if !spec_is_uniform(spec) {
return None;
}
let r = c.get(node.id)?;
Some((key, spec.clone(), (r.x, r.y, r.w, r.h)))
}
/// Busca el nodo seleccionable por su `key` estable (para extender el drag o
/// pintar el resaltado en frames posteriores, cuando el `NodeId` ya cambió).
/// Recorre el overlay y el árbol principal. `None` si la key ya no está.
pub(super) fn selectable_by_key<Msg>(
cache: &RenderCache<Msg>,
key: u64,
) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
let trees = [
cache.overlay.as_ref().map(|ov| (&ov.mounted, &ov.computed)),
Some((&cache.mounted, &cache.computed)),
];
trees
.into_iter()
.flatten()
.find_map(|(m, c)| selectable_node_in(m, c, key))
}
/// Busca en un árbol montado concreto el nodo de texto seleccionable con esa
/// `key` y devuelve su `TextSpec` clonado + rect. Lo usa tanto la búsqueda por
/// cache como el pintado del resaltado en el redraw (que tiene el `Mounted`
/// del frame a mano, no un `RenderCache`).
pub(super) fn selectable_node_in<Msg>(
m: &Mounted<Msg>,
c: &ComputedLayout,
key: u64,
) -> Option<(llimphi_compositor::TextSpec, AbsRect)> {
for node in &m.nodes {
if node.text_select_key == Some(key) {
let spec = node.text.as_ref()?;
if !spec_is_uniform(spec) {
return None;
}
let r = c.get(node.id)?;
return Some((spec.clone(), (r.x, r.y, r.w, r.h)));
}
}
None
}
/// Reconstruye el `parley::Layout` de un nodo de texto, idéntico al que pinta
/// el render (misma ruta cacheada `Typesetter::layout`), para hit-testear y
/// medir la selección. El ancho de wrap es el del rect del nodo.
pub(super) fn build_selectable_layout(
ts: &mut llimphi_text::Typesetter,
spec: &llimphi_compositor::TextSpec,
width: f32,
) -> llimphi_text::parley::Layout<()> {
ts.layout(
&spec.content,
spec.size_px,
Some(width),
spec.alignment,
spec.line_height,
spec.italic,
spec.font_family.as_deref(),
spec.weight,
spec.underline,
spec.strikethrough,
spec.letter_spacing,
spec.word_spacing,
)
}
/// `true` si la tecla lógica es el carácter `c` (case-insensitive). Para
/// atajos como Ctrl+C sin acoplarse a mayúsculas/minúsculas ni layout.
pub(super) fn key_is_char(key: &Key, c: char) -> bool {
matches!(
key,
Key::Character(s) if s.chars().next().map(|k| k.eq_ignore_ascii_case(&c)).unwrap_or(false)
)
}
/// Copia texto al portapapeles del sistema (best-effort). Con la feature
/// `clipboard` usa `arboard`; sin backend (headless) o sin la feature es no-op
/// silencioso — nunca panica.
#[cfg(feature = "clipboard")]
pub(super) fn copy_to_clipboard(text: &str) {
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text(text.to_string());
}
}
#[cfg(not(feature = "clipboard"))]
pub(super) fn copy_to_clipboard(_text: &str) {}
/// Resuelve los [`View::layout_builder`] del árbol de la app en dos pasadas
/// (ver [`llimphi_compositor::expand_layout_builders`]). **Coste cero** cuando
/// ningún nodo usa el builder: devuelve el `view()` sin tocar tras un walk
/// barato. Cuando hay builders: monta el árbol (builders como hojas), computa
/// para conocer sus slots, y reconstruye un `view()` fresco expandiendo cada
/// builder con sus constraints reales. `viewport` en px físicos; `ts` para medir
/// texto igual que el compute principal. Lo llaman el redraw (vía cache) y el
/// fallback de press.
pub(super) fn resolve_layout_builders<A: App>(
model: &A::Model,
viewport: (f32, f32),
ts: &mut llimphi_text::Typesetter,
) -> View<A::Msg> {
let view = A::view(model);
if !has_layout_builder(&view) {
return view;
}
// Pasada 1: montar (builders = hojas con su Style) y computar el layout.
let mut l1 = LayoutTree::new();
let m1: Mounted<A::Msg> = mount(&mut l1, view);
let c1 = {
let tmap = &m1.text_measures;
l1.compute_with_measure(m1.root, viewport, |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout layout_builder pasada 1")
};
let cons = collect_builder_constraints(&m1, &c1);
// Pasada 2: árbol fresco (mismo Model → misma estructura, mismo pre-orden de
// builders) + expand con las constraints resueltas.
expand_layout_builders(A::view(model), &cons)
}
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
// eventloop/mod.rs — Bucle Elm sobre winit: núcleo del runtime llimphi-ui.
//
// Este módulo actúa como organizador: declara los submódulos por responsabilidad
// e implementa los tres puntos de entrada del `ApplicationHandler` de winit
// (`resumed`, `user_event`, `about_to_wait`). El handler de `window_event`
// delega a `input` (primaria) o `secondary` según el `WindowId`.
//
// Submódulos:
// - helpers — funciones puras (hit-test helpers, selección, layout builders)
// - input — manejo de todos los WindowEvent de la ventana primaria
// - redraw — ciclo mount → layout → paint → GPU → present
// - secondary — gestión de ventanas OS secundarias (opt-in)
// - a11y_rt — integración AccessKit en tiempo de ejecución
mod a11y_rt;
mod helpers;
mod input;
mod redraw;
mod secondary;
use super::*;
pub(crate) fn build_window_attributes<A: App>() -> WindowAttributes {
let (w, h) = A::initial_size();
let attrs = WindowAttributes::default()
.with_title(A::title())
.with_inner_size(LogicalSize::new(w, h));
// En Linux, `with_name` del trait de Wayland mapea al `app_id` del
// xdg-toplevel — lo que el compositor (`mirada-compositor`) usa para
// reconocer ventanas especiales (greeter, launcher…).
#[cfg(all(target_os = "linux", not(target_os = "android")))]
{
if let Some(id) = A::app_id() {
use llimphi_hal::winit::platform::wayland::WindowAttributesExtWayland;
return attrs.with_name(id, "");
}
}
attrs
}
/// Empuja al adapter AccessKit el árbol del último frame pintado. Llamar tras
/// guardar `state.last_render`. `update_if_active` no construye el árbol si no
/// hay tecnología asistiva activa (coste cero en ese caso). Pública sólo
/// dentro del crate; las tests no la necesitan.
fn push_a11y_tree<A: App>(state: &mut RuntimeState<A>) {
let Some(cache) = state.last_render.as_ref() else {
return;
};
// El foco que tenemos es un id opaco u64 (`focusable`); el árbol AccessKit
// necesita el índice del MountedNode. Resolvemos buscando.
let focused_idx = state.focused.and_then(|fid| {
cache
.mounted
.nodes
.iter()
.position(|n| n.focusable == Some(fid))
});
let app_name = A::window_title(state.model.as_ref().expect("model"))
.unwrap_or_else(|| String::from("Llimphi"));
let tree_id = state.a11y_tree_id;
state.a11y_adapter.update_if_active(|| {
crate::a11y::build_tree(&cache.mounted, &cache.computed, focused_idx, &app_name, tree_id)
});
}
impl<A: App> ApplicationHandler<UserEvent<A::Msg>> for Runtime<A> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(build_window_attributes::<A>())
.expect("create window");
let window = Arc::new(window);
// IME opt-in: sólo se habilita si la app lo pide (ver `App::ime_allowed`).
// Con IME activo el texto compuesto llega por `WindowEvent::Ime`.
if A::ime_allowed() {
window.set_ime_allowed(true);
}
// Adapter AccessKit: lo creamos ANTES del primer redraw, conectado al
// EventLoopProxy del runtime. El adapter emitirá `accesskit_winit::Event`
// (Initial tree requested, ActionRequested, deactivated) — nuestro
// `From<accesskit_winit::Event> for UserEvent<Msg>` los rutea como
// `UserEvent::A11y(...)` para que entren por el mismo `user_event`.
let a11y_proxy: EventLoopProxy<UserEvent<A::Msg>> =
match &self.handle.inner {
HandleInner::Real(p) => p.clone(),
HandleInner::Test => unreachable!("resumed sin event loop real"),
HandleInner::Lifted(_) => unreachable!("el runtime nunca corre con un handle lifteado"),
};
let a11y_adapter =
accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, a11y_proxy);
let a11y_tree_id = accesskit::TreeId(uuid::Uuid::new_v4());
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
let renderer = Renderer::new(&hal).expect("renderer");
let overlay_compositor = llimphi_hal::OverlayCompositor::new(&hal.device);
let blur_compositor = llimphi_hal::BlurCompositor::new(&hal.device);
let color_filter_compositor = llimphi_hal::ColorFilterCompositor::new(&hal.device);
let typesetter = llimphi_text::Typesetter::new();
window.request_redraw();
self.state = Some(RuntimeState {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
overlay_compositor,
blur_compositor,
color_filter_compositor,
model: Some(A::init(&self.handle)),
cursor: PhysicalPosition::new(0.0, 0.0),
modifiers: Modifiers::default(),
typesetter,
layout: LayoutTree::new(),
overlay_layout: LayoutTree::new(),
last_render: None,
hovered: None,
drag: None,
focused: None,
last_title: None,
anim_registry: llimphi_compositor::AnimRegistry::new(),
size_anim_registry: llimphi_compositor::SizeAnimRegistry::new(),
hero_registry: llimphi_compositor::HeroRegistry::new(),
ripple_registry: llimphi_compositor::RippleRegistry::new(),
last_tap: None,
pending_long_press: None,
retained: None,
selection: None,
a11y_adapter,
a11y_tree_id,
});
// Sincroniza el factor de escala inicial (el de la ventana recién
// creada) ANTES del primer render: así una app que dependa del DPI
// (p. ej. `devicePixelRatio` en puriy) ya lo tiene correcto en su
// primera pasada, sin esperar a un ScaleFactorChanged.
if let Some(state) = self.state.as_mut() {
let scale = state.window.scale_factor();
if let Some(msg) = A::on_scale_factor(state.model.as_ref().expect("model"), scale) {
let model = state.model.take().expect("model");
state.model = Some(A::update(model, msg, &self.handle));
state.last_render = None;
}
}
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent<A::Msg>) {
match event {
UserEvent::Quit => event_loop.exit(),
UserEvent::Msg(msg) => {
// Un Msg del canal (Handle::dispatch, ticks periódicos, trabajo
// de fondo) muta el modelo compartido y repinta TODAS las
// ventanas — así un cambio se refleja tanto en la primaria como
// en las secundarias (config) sin importar de dónde vino.
self.dispatch_model(msg);
}
UserEvent::OpenWindow { key, title, width, height } => {
self.open_secondary(event_loop, key, title, width, height);
}
UserEvent::CloseWindow { key } => {
if let Some(pos) = self.secondaries.iter().position(|s| s.key == key) {
// Drop de la SecondaryState → se destruye la ventana/surface.
self.secondaries.remove(pos);
}
}
UserEvent::A11y(ev) => {
self.handle_a11y_event(ev);
}
}
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
// ¿El evento es de una ventana secundaria? Lo atiende su handler
// dedicado (path aparte: la primaria queda 100% intacta).
if let Some(idx) = self.secondaries.iter().position(|s| s.window.id() == _id) {
self.handle_secondary_event(idx, event);
return;
}
self.handle_primary_window_event(event_loop, event);
}
/// Se ejecuta tras procesar los eventos de cada vuelta, justo antes de que
/// el loop se duerma. Es donde vence el **long-press**: si hay uno armado y
/// ya pasó su `deadline` (el botón siguió apretado y quieto), se dispara su
/// `Msg`. Mientras quede uno pendiente, ponemos `WaitUntil(deadline)` para
/// que winit nos despierte a tiempo (con `ControlFlow::Wait` el loop dormiría
/// indefinidamente sin un evento que lo despierte). Sin long-press armado,
/// volvemos a `Wait` (no dejar un `WaitUntil` viejo: con un deadline pasado
/// el loop spinearía). Las animaciones implícitas no usan el control flow
/// (piden frames con `request_redraw`), así que esto no las afecta.
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let Some(state) = self.state.as_mut() else {
return;
};
match state.pending_long_press.as_ref() {
Some(p) => {
if std::time::Instant::now() >= p.deadline {
let handler = state.pending_long_press.take().expect("pending").handler;
event_loop.set_control_flow(ControlFlow::Wait);
if let Some(msg) = handler.invoke() {
let model = state.model.take().expect("model");
state.model = Some(A::update(model, msg, &self.handle));
state.last_render = None;
state.window.request_redraw();
}
} else {
event_loop.set_control_flow(ControlFlow::WaitUntil(p.deadline));
}
}
None => event_loop.set_control_flow(ControlFlow::Wait),
}
}
}
+574
View File
@@ -0,0 +1,574 @@
// redraw.rs — Ciclo completo de repintado: layout → paint → GPU → present.
// Esta función es la ruta caliente: mount + compute + animaciones + vello +
// pasadas GPU + present + retención de frame.
use super::super::*;
use super::helpers::{
build_selectable_layout, resolve_layout_builders, selectable_node_in,
};
use super::push_a11y_tree;
/// Ejecuta la pasada completa de redraw para la ventana primaria.
/// Se llama desde `handle_primary_window_event` cuando el evento es
/// `WindowEvent::RedrawRequested`. Recibe `state` y `handle` separados para
/// facilitar el borrow-checker (no necesita `&mut Runtime<A>` completo).
pub(super) fn handle_redraw<A: App>(
state: &mut RuntimeState<A>,
handle: &Handle<A::Msg>,
) {
// **Retención de frame entero**. Si:
// (a) hay scene retenida del frame anterior (`retained`),
// (b) `last_render` SIGUE siendo `Some` — la invariante del
// runtime es que cualquier handler que muta visualmente
// pone `last_render = None`, así que `Some` ⇒ nadie tocó
// nada que afecte la pintura,
// (c) el frame retenido NO estaba animando ni ripplando
// (si lo estaba, el ticker NECESITA avanzarlo),
// (d) no hay overlay, drag, ni long-press en curso (camino
// conservador: esos casos suelen estar acoplados a
// cambios visuales que no atraviesan `last_render`),
// (e) el viewport sigue del mismo tamaño,
// entonces `state.scene` ya tiene EXACTAMENTE lo que hay que
// mostrar. Saltamos mount + layout + paint y solo hacemos un
// render+present de la scene retenida. Cubre redraws espurios
// (expose del compositor, refocus, el último frame de una anim
// ya asentada). Si algo falla en el acquire, caemos al camino
// completo (no es un error, sólo un viewport efímero).
let cache_hit = state.last_render.is_some()
&& state.drag.is_none()
&& state.pending_long_press.is_none()
&& state.retained.as_ref().is_some_and(|r| {
!r.animating
&& !r.rippling
&& !r.has_overlay
&& (r.w, r.h) == state.surface.size()
});
if cache_hit {
match state.surface.acquire() {
Ok(frame) => {
if state
.renderer
.render(&state.hal, &state.scene, &frame, palette::css::BLACK)
.is_ok()
{
state.surface.present(frame, &state.hal);
return;
}
// render falló → cae al camino completo
}
Err(_) => { /* surface efímera → camino completo */ }
}
}
// Título dinámico (App::window_title): si cambió respecto del
// último aplicado, se lo pasamos a winit. Barato: una
// comparación de String por frame, set_title sólo en el cambio.
if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
if state.last_title.as_deref() != Some(t.as_str()) {
state.window.set_title(&t);
state.last_title = Some(t);
}
}
// Posicioná la ventana de candidatos del IME junto al caret
// (sólo con IME activo y si la app reporta el área).
if A::ime_allowed() {
if let Some((x, y, w, h)) =
A::ime_cursor_area(state.model.as_ref().expect("model"))
{
state.window.set_ime_cursor_area(
llimphi_hal::winit::dpi::PhysicalPosition::new(x as f64, y as f64),
llimphi_hal::winit::dpi::PhysicalSize::new(
w.max(1.0) as u32,
h.max(1.0) as u32,
),
);
}
}
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
// LayoutBuilder: resuelve los constructores diferidos en dos
// pasadas (coste cero si no hay ninguno). Necesita el typesetter
// para medir, así que va antes de tomar `model_ref` para el overlay.
let mut view = resolve_layout_builders::<A>(
state.model.as_ref().expect("model"),
(w as f32, h as f32),
&mut state.typesetter,
);
// Animaciones implícitas de **tamaño** (`View::animated_size`):
// reconcila el `View` tree y parcha `style.size` ANTES del
// mount/layout. Así siblings/hijos reflowean suave (la
// animación se ve en el layout cascade, no sólo en el rect del
// nodo aislado). Coste cero sin nodos `animated_size`.
let frame_now = std::time::Instant::now();
let size_animating = llimphi_compositor::reconcile_size_anim(
&mut view,
&mut state.size_anim_registry,
frame_now,
);
let model_ref = state.model.as_ref().expect("model");
let overlay_view = A::view_overlay(model_ref);
// Reusamos los árboles de layout del runtime: `clear()` +
// `mount` evita re-allocar el slotmap de taffy por frame.
state.layout.clear();
let mut mounted: Mounted<A::Msg> = mount(&mut state.layout, view);
let computed = {
let ts = &mut state.typesetter;
let tmap = &mounted.text_measures;
state
.layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout")
};
// Animaciones implícitas (`View::animated`): reconcilia el árbol
// con el estado retenido DESPUÉS del layout y ANTES del paint —
// interpola fill/radius de los nodos con `anim`. Si alguna sigue
// viva pedimos otro frame al final (ticker autodetenido).
let now = frame_now;
let anim_active = state.anim_registry.reconcile(&mut mounted, now);
// Heroes (`View::hero`): si la misma key cambió de rect entre
// frames, escribe en `transform` la afín que "vuela" del rect
// anterior al actual. Independiente del anim_registry — sólo
// toca `transform`, que el paint ya respeta. Coste cero sin
// nodos hero.
let hero_active = state.hero_registry.reconcile(&mut mounted, &computed, now);
// `size_animating` viene del reconcile previo al mount; lo
// ORrijimos al `animating` global para que se pida el
// próximo frame y el `retained.animating == true` invalide
// la cache de retención (la siguiente pasada reconstruye con
// el size interpolado).
let animating = anim_active || hero_active || size_animating;
// Mount + layout del overlay en un árbol aparte. Lo
// computamos con el mismo tamaño de viewport para que
// un scrim a percent(1.0) cubra toda la pantalla.
let overlay_built = if let Some(v) = overlay_view {
state.overlay_layout.clear();
let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
let ocomputed = {
let ts = &mut state.typesetter;
let tmap = &omounted.text_measures;
state
.overlay_layout
.compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout overlay")
};
let ohover = hit_test_hover(
&omounted,
&ocomputed,
state.cursor.x as f32,
state.cursor.y as f32,
);
Some(OverlayCache {
mounted: omounted,
computed: ocomputed,
hover_idx: ohover,
})
} else {
None
};
// Hover en el main solo si NO hay overlay — durante un
// menú abierto, el fondo no debe reaccionar al ratón.
let hover_idx = if overlay_built.is_some() {
None
} else {
hit_test_hover(
&mounted,
&computed,
state.cursor.x as f32,
state.cursor.y as f32,
)
};
// Drop hover sólo si hay drag activo con payload (un
// drag bloquea el overlay; rara combinación pero la
// resolvemos a favor del drag).
let drop_hover_idx = state
.drag
.as_ref()
.and_then(|d| d.payload.map(|_| ()))
.and_then(|_| {
hit_test_drop(
&mounted,
&computed,
state.cursor.x as f32,
state.cursor.y as f32,
)
});
// Z-order del overlay sobre contenido `gpu_paint`: si el
// árbol principal tiene painters gpu (p. ej. el video de
// media) Y hay un overlay activo, el overlay NO va en la
// escena principal (quedaría debajo del blit gpu). Se
// rasteriza aparte sobre fondo transparente y se compone con
// alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
// de siempre (overlay en la escena principal) — coste cero.
let composite_overlay =
overlay_built.is_some() && has_gpu_painter(&mounted);
state.scene.reset();
paint(
&mut state.scene,
&mounted,
&computed,
&mut state.typesetter,
hover_idx,
drop_hover_idx,
);
// Animación de salida (fade-out). 1) Capturá la subescena de
// cada nodo `exit` presente (snapshot para cuando desaparezca).
// 2) Reproducí los fantasmas de los que ya se fueron, con
// opacidad decreciente — por encima del contenido, debajo del
// overlay. Coste cero si ningún nodo usa `animated_exit`.
for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted) {
let (dur, easing) = {
let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
(a.duration, a.easing)
};
let mut sub = vello::Scene::new();
paint_range(
&mut sub,
&mounted,
&computed,
&mut state.typesetter,
None,
None,
idx,
end,
vello::kurbo::Affine::IDENTITY,
);
state.anim_registry.store_live_exit(key, sub, dur, easing);
}
state
.anim_registry
.replay_ghosts(&mut state.scene, now, w as f32, h as f32);
// Resaltado de la selección de texto activa (sobre el
// contenido, bajo el overlay). Reconstruye el layout del nodo
// seleccionado y pinta los rects de `parley::Selection` con un
// tinte translúcido (deja leer el texto debajo).
if let Some(tsel) = state.selection {
if let Some((spec, (rx, ry, rw, _rh))) =
selectable_node_in(&mounted, &computed, tsel.key)
{
let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
use vello::kurbo::{Affine, Rect};
use vello::peniko::{Color, Fill};
let hl = Color::from_rgba8(86, 148, 246, 80);
let scene = &mut state.scene;
tsel.sel.geometry_with(&layout, |bb, _line| {
let r = Rect::new(
rx as f64 + bb.x0,
ry as f64 + bb.y0,
rx as f64 + bb.x1,
ry as f64 + bb.y1,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, hl, None, &r);
});
}
}
// Ripples/InkWell: las salpicaduras vivas se pintan sobre el
// contenido (translúcidas, recortadas al nodo) y debajo del
// overlay. Si alguna sigue viva, pide otro frame al final.
let rippling =
state
.ripple_registry
.paint(&mut state.scene, &mounted, &computed, now);
if !composite_overlay {
if let Some(ov) = overlay_built.as_ref() {
paint(
&mut state.scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
ov.hover_idx,
None,
);
}
}
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
palette::css::BLACK,
) {
eprintln!("render error: {e}");
}
let (vw, vh) = frame.size();
// Capa de overlay aparte (camino composite): vello la
// rasteriza con fondo transparente en `frame.overlay_view()`.
// Se renderiza ANTES del pase gpu para que el blit del
// compositor (en `gpu_encoder`) la lea ya escrita.
if composite_overlay {
if let Some(ov) = overlay_built.as_ref() {
state.scene.reset();
paint(
&mut state.scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
ov.hover_idx,
None,
);
if let Err(e) = state.renderer.render_to_view(
&state.hal,
&state.scene,
frame.overlay_view(),
vw,
vh,
palette::css::TRANSPARENT,
) {
eprintln!("render overlay error: {e}");
}
}
}
// Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
// si algún View del main o del overlay registró un
// `gpu_painter`, ejecutamos todos sus callbacks contra un
// único `CommandEncoder`, encima de lo que vello acaba de
// pintar sobre la intermediate. Submitimos antes del
// present para que el blit al swapchain incluya las
// primitivas GPU. Si nadie usó el hook, no se crea ni
// submitea nada — coste cero.
let mut gpu_encoder = state.hal.device.create_command_encoder(
&llimphi_hal::wgpu::CommandEncoderDescriptor {
label: Some("llimphi-ui-gpu-paint"),
},
);
let viewport = frame.size();
// Backdrop blur (Bloque 11): post-pasada Gauss separable sobre
// la intermediate, restringida al rect de cada nodo
// `.backdrop_blur(sigma)`. Sucede TRAS la rasterización vello
// y ANTES de los `gpu_painter`/composite — los painters GPU
// que se solapen con el blur ven el rect ya borroneado y se
// dibujan encima nítidos. Coste cero sin nodos blur (loop
// vacío + bandera `blurred` queda false).
let backdrop_blurs =
llimphi_compositor::collect_backdrop_blurs(&mounted, &computed);
let blurred = !backdrop_blurs.is_empty();
for b in &backdrop_blurs {
state.blur_compositor.blur(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
b.rect,
b.sigma,
);
}
// `filter: …` sobre el propio subárbol (Fase 7.1232+): misma post-pasada
// que el backdrop, pero leyendo `MountedNode::filter` y restringida al rect
// del nodo. Se aplica DESPUÉS del backdrop, sobre los píxeles ya
// rasterizados (el contenido del nodo). Hoy sólo `Blur`; el resto de las
// variantes se suman por fase.
let filter_passes = llimphi_compositor::collect_filters(&mounted, &computed);
let filtered = !filter_passes.is_empty();
for p in &filter_passes {
match &p.op {
llimphi_compositor::FilterOp::Blur(sigma) => {
state.blur_compositor.blur(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
p.rect,
*sigma,
);
}
llimphi_compositor::FilterOp::ColorMatrix(m) => {
state.color_filter_compositor.apply(
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
p.rect,
*m,
);
}
// drop-shadow se pinta en vello (no es post-pasada); collect_filters
// no la emite, pero el match debe ser exhaustivo. Fase 7.1234.
llimphi_compositor::FilterOp::DropShadow(_) => {}
}
}
let mut any_gpu = blurred
| filtered
| paint_gpu(
&mounted,
&computed,
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
frame.view(),
viewport,
);
if let Some(ov) = overlay_built.as_ref() {
// En el camino composite, los painters gpu del overlay van
// sobre SU textura; si no, sobre la intermedia.
let target = if composite_overlay {
frame.overlay_view()
} else {
frame.view()
};
any_gpu |= paint_gpu(
&ov.mounted,
&ov.computed,
&state.hal.device,
&state.hal.queue,
&mut gpu_encoder,
target,
viewport,
);
}
// Capa vello "over" (Primitivo B): nodos con `paint_over` registran
// primitivas vello que deben quedar ENCIMA del pase GPU directo
// (sprites/texto AA sobre celdas instanciadas — dominium, motor
// voxel). Camino opt-in y coste cero si nadie la usa (loop barato +
// bandera false): el orden total queda [vello base] → [gpu_paint] →
// [vello over] → [overlay/menús].
//
// Mecánica de z-order correcta: rasterizamos la escena over en una
// textura scratch transparente (vello, con su propio submit — no
// toca la intermedia) y luego GRABAMOS el composite alpha de esa
// scratch sobre la intermedia DENTRO de `gpu_encoder`, después de
// los pases GPU directos. Como `gpu_encoder` se submitea al final
// (línea de abajo), el composite corre en la GPU DESPUÉS de las
// primitivas GPU → el over-layer queda encima de ellas. Va ANTES
// del composite de menús, así los menús siguen por encima del over.
let over_active = has_over_painter(&mounted)
|| overlay_built
.as_ref()
.map(|ov| has_over_painter(&ov.mounted))
.unwrap_or(false);
if over_active {
// Escena vello aparte (no pisamos `state.scene`, que el caller
// retiene/reusa). Fondo transparente: sólo lo pintado por los
// `over_painter` lleva alpha.
let mut over_scene = vello::Scene::new();
let mut any_over = paint_over(
&mut over_scene,
&mounted,
&computed,
&mut state.typesetter,
);
if let Some(ov) = overlay_built.as_ref() {
any_over |= paint_over(
&mut over_scene,
&ov.mounted,
&ov.computed,
&mut state.typesetter,
);
}
if any_over {
// Scratch transparente del tamaño del frame (mismo formato
// que la intermedia: Rgba8Unorm). Vello escribe via compute
// (STORAGE_BINDING) y el composite la lee como sampler
// (TEXTURE_BINDING). Por-frame: sólo se crea cuando hay
// over-layer activo, igual que los buffers de `GpuBatch`.
let over_tex = state.hal.device.create_texture(
&llimphi_hal::wgpu::TextureDescriptor {
label: Some("llimphi-ui-over-scratch"),
size: llimphi_hal::wgpu::Extent3d {
width: vw,
height: vh,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: llimphi_hal::wgpu::TextureDimension::D2,
format: llimphi_hal::wgpu::TextureFormat::Rgba8Unorm,
usage: llimphi_hal::wgpu::TextureUsages::STORAGE_BINDING
| llimphi_hal::wgpu::TextureUsages::TEXTURE_BINDING
| llimphi_hal::wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
},
);
let over_view =
over_tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default());
// Vello rasteriza la escena over a la scratch (su propio
// submit; limpia con TRANSPARENT). Independiente de la
// intermedia.
if let Err(e) = state.renderer.render_to_view(
&state.hal,
&over_scene,
&over_view,
vw,
vh,
palette::css::TRANSPARENT,
) {
eprintln!("render over-layer error: {e}");
}
// Composite alpha de la scratch sobre la intermedia, grabado
// en `gpu_encoder` DESPUÉS de los pases GPU directos.
state.overlay_compositor.composite(
&state.hal.device,
&mut gpu_encoder,
frame.view(),
&over_view,
);
any_gpu = true;
}
}
// Composición alpha del overlay SOBRE la intermedia (que ya
// tiene UI + video). Último pase del encoder → corre después
// del blit del video. Garantiza menús por encima del video.
if composite_overlay {
state.overlay_compositor.composite(
&state.hal.device,
&mut gpu_encoder,
frame.view(),
frame.overlay_view(),
);
any_gpu = true;
}
if any_gpu {
state
.hal
.queue
.submit(std::iter::once(gpu_encoder.finish()));
}
state.surface.present(frame, &state.hal);
// Ticker de animaciones implícitas: si quedó alguna en curso,
// pedí el próximo frame. Cuando todas se asientan, `animating`
// queda false y el loop de redraws se detiene solo (sin render
// ocioso, sin spawn_periodic por animación).
if animating || rippling {
state.window.request_redraw();
}
state.retained = Some(RetainedScene {
w,
h,
animating,
rippling,
has_overlay: overlay_built.is_some(),
});
state.last_render = Some(RenderCache {
mounted,
computed,
hover_idx,
drop_hover_idx,
overlay: overlay_built,
});
// AccessKit: tras un paint exitoso, empujamos el árbol al
// adapter. `update_if_active` se salta el closure si no hay
// tecnología asistiva escuchando — coste cero en ese caso.
push_a11y_tree::<A>(state);
// `handle` se recibe pero el redraw no lo necesita directamente;
// se pasa para mantener la firma consistente con el caller.
let _ = handle;
}
+452
View File
@@ -0,0 +1,452 @@
// secondary.rs — Gestión de ventanas OS secundarias (opt-in, multiventana).
// Path APARTE del de la primaria: comparten modelo (vive en `self.state`) y
// `Hal`/`Renderer`, pero cada secundaria lleva su surface + caches. Sin
// overlay ni foco (la config no los necesita); se puede ampliar luego.
use super::super::*;
impl<A: App> Runtime<A> {
/// Aplica un Msg al modelo (que vive en la primaria) e invalida + repinta
/// TODAS las ventanas. Es el camino de cualquier evento de una secundaria,
/// así un cambio hecho en la config se refleja al toque en el reproductor
/// (y viceversa, vía los ticks que pasan por `user_event`).
pub(super) fn dispatch_model(&mut self, msg: A::Msg) {
if let Some(prim) = self.state.as_mut() {
let model = prim.model.take().expect("model");
prim.model = Some(A::update(model, msg, &self.handle));
prim.last_render = None;
prim.window.request_redraw();
}
// OJO: NO repintamos las secundarias acá. `dispatch_model` corre en
// cada Msg (incluido el tick ~33 fps), y repintar una secundaria por
// tick serializaba dos `acquire()` de swapchain en Wayland FIFO →
// ralentización y cuelgue. Cada secundaria se repinta sola al
// interactuar con ella (`handle_secondary_event` llama
// `render_secondary` tras un cambio) y en su `RedrawRequested` del
// compositor (expose/resize). El modelo igual quedó actualizado, así
// que el próximo repintado de la secundaria refleja el cambio.
}
/// Despacha un Msg y repinta la secundaria `idx` en el acto (si sigue
/// viva). El camino de los eventos de una secundaria: como su
/// `request_redraw` no dispara `RedrawRequested` en algunos compositores,
/// la pintamos directo tras el cambio.
pub(super) fn dispatch_and_render_secondary(&mut self, idx: usize, msg: A::Msg) {
self.dispatch_model(msg);
if idx < self.secondaries.len() {
self.render_secondary(idx);
}
}
/// Crea una ventana OS secundaria (o enfoca la existente con esa key). Toma
/// el `Hal` de la primaria — no levanta un segundo device GPU.
pub(super) fn open_secondary(
&mut self,
event_loop: &llimphi_hal::winit::event_loop::ActiveEventLoop,
key: u64,
title: String,
width: u32,
height: u32,
) {
if let Some(sec) = self.secondaries.iter().find(|s| s.key == key) {
sec.window.focus_window();
return;
}
let Some(prim) = self.state.as_ref() else {
return; // no hay primaria todavía (no debería pasar)
};
let attrs = llimphi_hal::winit::window::WindowAttributes::default()
.with_title(title)
.with_inner_size(llimphi_hal::winit::dpi::LogicalSize::new(width, height));
let window = match event_loop.create_window(attrs) {
Ok(w) => Arc::new(w),
Err(e) => {
eprintln!("open_window: no pude crear la ventana: {e}");
return;
}
};
let surface = match WinitSurface::new(&prim.hal, window.clone()) {
Ok(s) => s,
Err(e) => {
eprintln!("open_window: no pude crear la surface: {e}");
return;
}
};
window.request_redraw();
self.secondaries.push(SecondaryState {
key,
window,
surface,
scene: vello::Scene::new(),
typesetter: llimphi_text::Typesetter::new(),
layout: LayoutTree::new(),
cursor: llimphi_hal::winit::dpi::PhysicalPosition::new(0.0, 0.0),
modifiers: Modifiers::default(),
last_render: None,
hovered: None,
drag: None,
last_title: None,
});
}
/// Pinta la ventana secundaria `idx` con `A::secondary_view`. Reusa el
/// `Hal`/`Renderer` de la primaria; camino simple (sin overlay ni
/// composite gpu de menús), pero soporta `gpu_paint` por si el contenido
/// lo usa.
pub(super) fn render_secondary(&mut self, idx: usize) {
let key = self.secondaries[idx].key;
let Some(prim) = self.state.as_mut() else {
return;
};
// Título dinámico de la secundaria.
if let Some(t) = A::secondary_title(prim.model.as_ref().expect("model"), key) {
let sec = &mut self.secondaries[idx];
if sec.last_title.as_deref() != Some(t.as_str()) {
sec.window.set_title(&t);
sec.last_title = Some(t);
}
}
let view = A::secondary_view(prim.model.as_ref().expect("model"), key)
.unwrap_or_else(|| View::new(Default::default()));
let hal = &prim.hal;
let renderer = &mut prim.renderer;
let sec = &mut self.secondaries[idx];
let frame = match sec.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = sec.surface.size();
sec.surface.resize(w, h);
sec.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
sec.layout.clear();
let mounted: Mounted<A::Msg> = mount(&mut sec.layout, view);
let computed = {
let ts = &mut sec.typesetter;
let tmap = &mounted.text_measures;
sec.layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(ts, tm, known, avail),
None => llimphi_layout::taffy::Size::ZERO,
}
})
.expect("layout secundario")
};
let hover_idx = hit_test_hover(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32);
let drop_hover_idx = sec
.drag
.as_ref()
.and_then(|d| d.payload)
.and_then(|_| hit_test_drop(&mounted, &computed, sec.cursor.x as f32, sec.cursor.y as f32));
sec.scene.reset();
paint(
&mut sec.scene,
&mounted,
&computed,
&mut sec.typesetter,
hover_idx,
drop_hover_idx,
);
if let Err(e) = renderer.render(hal, &sec.scene, &frame, palette::css::BLACK) {
eprintln!("render secundario error: {e}");
}
// gpu_paint del contenido de la secundaria (si lo hubiera).
let mut enc = hal
.device
.create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor {
label: Some("llimphi-ui-sec-gpu"),
});
let viewport = frame.size();
let any = paint_gpu(
&mounted,
&computed,
&hal.device,
&hal.queue,
&mut enc,
frame.view(),
viewport,
);
if any {
hal.queue.submit(std::iter::once(enc.finish()));
}
sec.surface.present(frame, hal);
let _ = (hover_idx, drop_hover_idx); // se usaron al pintar; no se cachean
sec.last_render = Some(SecRenderCache { mounted, computed });
}
/// Atiende un evento de la ventana secundaria `idx`. Subconjunto de lo que
/// hace la primaria (sin overlay/foco/IME): render, resize, cierre, hover,
/// click, drag, teclado y rueda — suficiente para un panel de config.
pub(super) fn handle_secondary_event(
&mut self,
idx: usize,
event: llimphi_hal::winit::event::WindowEvent,
) {
use llimphi_hal::winit::event::WindowEvent;
match event {
WindowEvent::CloseRequested => {
let key = self.secondaries[idx].key;
let msg = self
.state
.as_ref()
.and_then(|p| A::on_secondary_close(p.model.as_ref().expect("model"), key));
self.secondaries.remove(idx);
if let Some(msg) = msg {
self.dispatch_model(msg);
}
}
WindowEvent::Resized(size) => {
self.secondaries[idx].surface.resize(size.width, size.height);
self.render_secondary(idx);
}
WindowEvent::ScaleFactorChanged { .. } => {
self.render_secondary(idx);
}
WindowEvent::RedrawRequested => {
self.render_secondary(idx);
}
WindowEvent::ModifiersChanged(mods) => {
self.secondaries[idx].modifiers = mods.state().into();
}
WindowEvent::CursorMoved { position, .. } => {
let mut drag_msg: Option<A::Msg> = None;
let mut move_call: Option<(ClickAtFn<A::Msg>, f32, f32, f32, f32)> = None;
let mut redraw = false;
{
let sec = &mut self.secondaries[idx];
sec.cursor = position;
if let Some(drag) = sec.drag.as_mut() {
let dx = (position.x - drag.last_cursor.x) as f32;
let dy = (position.y - drag.last_cursor.y) as f32;
drag.last_cursor = position;
if dx != 0.0 || dy != 0.0 {
drag_msg = match &drag.handler {
DragHandlerKind::Delta(h) => h(DragPhase::Move, dx, dy),
DragHandlerKind::DeltaAt(h, lx0, ly0) => {
h(DragPhase::Move, dx, dy, *lx0, *ly0)
}
DragHandlerKind::Velocity(h) => {
let now = std::time::Instant::now();
drag.samples.push_back((now, dx as f64, dy as f64));
while drag.samples.len() > VELOCITY_MAX_SAMPLES {
drag.samples.pop_front();
}
h(DragPhase::Move, dx, dy, 0.0, 0.0)
}
};
}
redraw = true;
} else {
let new_hover = sec.last_render.as_ref().and_then(|c| {
hit_test_hover(&c.mounted, &c.computed, position.x as f32, position.y as f32)
});
if new_hover != sec.hovered {
sec.hovered = new_hover;
redraw = true;
}
// Movimiento posicional (on_pointer_move_at) en cada move.
move_call = sec.last_render.as_ref().and_then(|c| {
let i = hit_test_pointer_move(
&c.mounted,
&c.computed,
position.x as f32,
position.y as f32,
)?;
let node = &c.mounted.nodes[i];
let h = node.on_pointer_move_at.clone()?;
let r = c.computed.get(node.id)?;
Some((h, position.x as f32 - r.x, position.y as f32 - r.y, r.w, r.h))
});
}
}
let move_msg = move_call.and_then(|(h, lx, ly, w, hh)| h(lx, ly, w, hh));
if let Some(msg) = drag_msg.or(move_msg) {
self.dispatch_and_render_secondary(idx, msg);
} else if redraw {
self.render_secondary(idx);
}
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
} => {
type SecHit<M> = (
Option<DragFn<M>>,
Option<DragAtFn<M>>,
Option<DragVelocityFn<M>>,
Option<u64>,
Option<M>,
Option<ClickAtFn<M>>,
Option<(f32, f32, f32, f32)>,
);
let cursor = self.secondaries[idx].cursor;
let hit: Option<SecHit<A::Msg>> = {
let sec = &self.secondaries[idx];
sec.last_render.as_ref().and_then(|c| {
hit_test_click(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32).map(
|i| {
let node = &c.mounted.nodes[i];
let rect = c.computed.get(node.id).map(|r| (r.x, r.y, r.w, r.h));
(
node.drag.clone(),
node.drag_at.clone(),
node.drag_velocity.clone(),
node.drag_payload,
node.on_click.clone(),
node.on_click_at.clone(),
rect,
)
},
)
})
};
// Misma prioridad que la primaria: drag_velocity > drag_at +
// on_click_at > drag simple > on_click_at > on_click.
match hit {
Some((_, _, Some(handler_v), payload, _, _, _)) => {
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::Velocity(handler_v),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::with_capacity(VELOCITY_MAX_SAMPLES),
});
self.render_secondary(idx);
}
Some((_, Some(handler_at), _, payload, _, click_at, Some((ox, oy, rw, rh)))) => {
let lx0 = cursor.x as f32 - ox;
let ly0 = cursor.y as f32 - oy;
if let Some(h) = click_at {
if let Some(msg) = h(lx0, ly0, rw, rh) {
self.dispatch_model(msg);
}
}
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::DeltaAt(handler_at, lx0, ly0),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::new(),
});
self.render_secondary(idx);
}
Some((Some(handler), _, _, payload, _, _, _)) => {
self.secondaries[idx].drag = Some(DragState {
handler: DragHandlerKind::Delta(handler),
last_cursor: cursor,
payload,
samples: std::collections::VecDeque::new(),
});
self.render_secondary(idx);
}
Some((_, _, _, _, _, Some(handler), Some((ox, oy, rw, rh)))) => {
let lx = cursor.x as f32 - ox;
let ly = cursor.y as f32 - oy;
if let Some(msg) = handler(lx, ly, rw, rh) {
self.dispatch_and_render_secondary(idx, msg);
}
}
Some((_, _, _, _, Some(msg), _, _)) => {
self.dispatch_and_render_secondary(idx, msg);
}
_ => {}
}
}
WindowEvent::MouseInput {
state: ElementState::Released,
button: MouseButton::Left,
..
} => {
let cursor = self.secondaries[idx].cursor;
let drag = self.secondaries[idx].drag.take();
if let Some(drag) = drag {
// Drop primero (si hay payload + target), luego End.
if let Some(payload) = drag.payload {
let drop_h = self.secondaries[idx].last_render.as_ref().and_then(|c| {
hit_test_drop(&c.mounted, &c.computed, cursor.x as f32, cursor.y as f32)
.and_then(|i| c.mounted.nodes[i].on_drop.clone())
});
if let Some(h) = drop_h {
if let Some(msg) = h(payload) {
self.dispatch_model(msg);
}
}
}
let end_msg = match &drag.handler {
DragHandlerKind::Delta(h) => h(DragPhase::End, 0.0, 0.0),
DragHandlerKind::DeltaAt(h, lx0, ly0) => h(DragPhase::End, 0.0, 0.0, *lx0, *ly0),
DragHandlerKind::Velocity(h) => {
let (vx, vy) =
compute_drag_velocity(&drag.samples, std::time::Instant::now());
h(DragPhase::End, 0.0, 0.0, vx, vy)
}
};
if let Some(msg) = end_msg {
self.dispatch_model(msg);
}
self.render_secondary(idx);
}
}
WindowEvent::MouseWheel { delta, .. } => {
let wd = match delta {
MouseScrollDelta::LineDelta(x, y) => WheelDelta { x, y: -y },
MouseScrollDelta::PixelDelta(p) => WheelDelta {
x: (p.x as f32) / 20.0,
y: -(p.y as f32) / 20.0,
},
};
let cursor = self.secondaries[idx].cursor;
let chain: Vec<ScrollFn<A::Msg>> = {
let sec = &self.secondaries[idx];
sec.last_render
.as_ref()
.map(|c| {
hit_test_scroll_chain(
&c.mounted,
&c.computed,
cursor.x as f32,
cursor.y as f32,
)
.into_iter()
.filter_map(|i| c.mounted.nodes[i].on_scroll.clone())
.collect()
})
.unwrap_or_default()
};
let mut msg: Option<A::Msg> = None;
for h in &chain {
if let Some(m) = h(wd.x, wd.y) {
msg = Some(m);
break;
}
}
if let Some(msg) = msg {
self.dispatch_and_render_secondary(idx, msg);
}
}
WindowEvent::KeyboardInput { event, .. } => {
let ev = KeyEvent {
key: event.logical_key.clone(),
state: match event.state {
ElementState::Pressed => KeyState::Pressed,
ElementState::Released => KeyState::Released,
},
text: event.text.as_ref().map(|t| t.to_string()),
modifiers: self.secondaries[idx].modifiers,
repeat: event.repeat,
};
let msg = self
.state
.as_ref()
.and_then(|p| A::on_key(p.model.as_ref().expect("model"), &ev));
if let Some(msg) = msg {
self.dispatch_and_render_secondary(idx, msg);
}
}
_ => {}
}
}
}
+343 -4
View File
@@ -12,6 +12,8 @@
use std::sync::Arc;
pub mod a11y;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
@@ -207,7 +209,7 @@ pub trait App: 'static {
/// Identificador de aplicación. En Wayland se mapea al `app_id` del
/// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
/// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno.
/// p. ej. `mirada.greeter`). `None` deja que el sistema asigne uno.
fn app_id() -> Option<&'static str> {
None
}
@@ -237,6 +239,19 @@ pub enum UserEvent<Msg> {
},
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
CloseWindow { key: u64 },
/// Evento del adapter AccessKit: el lector de pantalla solicitó el árbol
/// inicial, pidió ejecutar una acción (focus, click, etc.) o se desactivó.
/// El adapter usa el `EventLoopProxy` para enviarlos al hilo del runtime.
A11y(accesskit_winit::Event),
}
/// Permite que `accesskit_winit::Adapter::with_event_loop_proxy` mande sus
/// eventos sobre nuestro `EventLoopProxy<UserEvent<Msg>>` sin que el caller
/// los rutee a mano.
impl<Msg> From<accesskit_winit::Event> for UserEvent<Msg> {
fn from(e: accesskit_winit::Event) -> Self {
UserEvent::A11y(e)
}
}
/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
@@ -256,6 +271,13 @@ enum HandleInner<Msg: Send + 'static> {
/// llamar funciones que toman `&Handle<Msg>` sin levantar un event
/// loop real (que en CI sin display tiraría).
Test,
/// Handle **lifteado**: reenvía cada `Msg` (de un sub-app hospedado) al
/// handle del host aplicándole una función de elevación `Sub -> Host`. Lo
/// crea [`Handle::lift`]; permite que el `update` de un app embebido use
/// `dispatch`/`spawn`/`spawn_periodic` con su propio `Msg` y que el
/// resultado llegue al loop del host. No maneja ventanas (open/close/quit
/// son no-op): esas son del host, no del hospedado.
Lifted(Arc<dyn Fn(Msg) + Send + Sync>),
}
impl<Msg: Send + 'static> Clone for Handle<Msg> {
@@ -264,6 +286,7 @@ impl<Msg: Send + 'static> Clone for Handle<Msg> {
inner: match &self.inner {
HandleInner::Real(p) => HandleInner::Real(p.clone()),
HandleInner::Test => HandleInner::Test,
HandleInner::Lifted(f) => HandleInner::Lifted(f.clone()),
},
}
}
@@ -288,6 +311,26 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = p.send_event(UserEvent::Quit);
}
HandleInner::Test => {}
// Un app hospedado no cierra el loop del host.
HandleInner::Lifted(_) => {}
}
}
/// Deriva un handle para un **sub-app hospedado**: el `update`/efectos del
/// sub-app usan su propio `Sub` msg, y `lift` los eleva al `Msg` del host
/// antes de despacharlos a este loop. Es la pieza que permite embeber un
/// App entero en otro (junto con [`crate::View::map`] para su `view`) sin
/// reescribirlo a patrón módulo. El sub-handle es `Clone + Send` como
/// cualquier handle. `open_window`/`close_window`/`quit` quedan no-op en él
/// (esas son del host).
pub fn lift<Sub, F>(&self, lift: F) -> Handle<Sub>
where
Sub: Send + 'static,
F: Fn(Sub) -> Msg + Send + Sync + 'static,
{
let parent = self.clone();
Handle {
inner: HandleInner::Lifted(Arc::new(move |sub: Sub| parent.dispatch(lift(sub)))),
}
}
@@ -324,6 +367,7 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = p.send_event(UserEvent::Msg(msg));
}
HandleInner::Test => {}
HandleInner::Lifted(f) => f(msg),
}
}
@@ -349,6 +393,14 @@ impl<Msg: Send + 'static> Handle<Msg> {
let _ = f();
});
}
HandleInner::Lifted(lift) => {
// Tarea one-shot del sub-app: corre en su hilo y el resultado
// se eleva al host vía la closure de lift.
let lift = lift.clone();
std::thread::spawn(move || {
lift(f());
});
}
}
}
@@ -385,6 +437,17 @@ impl<Msg: Send + 'static> Handle<Msg> {
// periodic behaviour deben usar el callback directo.
let _ = f;
}
HandleInner::Lifted(lift) => {
// Mismo loop que `Real` pero elevando al host. Si el loop del
// host se cerró, la closure de lift termina en un dispatch
// no-op (spinea hasta el exit, costo despreciable — igual que
// `Real`); aceptable para un ticker de animación/feed.
let lift = lift.clone();
std::thread::spawn(move || loop {
std::thread::sleep(period);
lift(f());
});
}
}
}
}
@@ -508,6 +571,17 @@ struct RuntimeState<A: App> {
/// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
/// un overlay activo; resuelve el z-order (menús por encima del video).
overlay_compositor: llimphi_hal::OverlayCompositor,
/// Backdrop blur post-pasada: para cada nodo con `.backdrop_blur(sigma)`,
/// el runtime aplica un Gauss separable (H+V) sobre la intermediate
/// restringido al rect del nodo, **después** de la rasterización vello y
/// **antes** de los `gpu_painter`. El compositor mantiene su scratch
/// interno; coste cero cuando no hay nodos blur.
blur_compositor: llimphi_hal::BlurCompositor,
/// Post-pasada de **matriz de color** (`filter: brightness/grayscale/…`),
/// restringida al rect del nodo, en el mismo punto que `blur_compositor`.
/// Mantiene su scratch interno; coste cero sin filtros de color. Fase
/// 7.1233.
color_filter_compositor: llimphi_hal::ColorFilterCompositor,
model: Option<A::Model>,
cursor: PhysicalPosition<f64>,
modifiers: Modifiers,
@@ -541,6 +615,84 @@ struct RuntimeState<A: App> {
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
/// Evita llamar `set_title` en cada frame cuando no cambió.
last_title: Option<String>,
/// Registro de animaciones implícitas (`View::animated`), vivo entre
/// frames. En cada redraw reconcilia el árbol y, si alguna sigue en curso,
/// el runtime pide otro frame (ticker autodetenido). Ver
/// [`llimphi_compositor::AnimRegistry`].
anim_registry: llimphi_compositor::AnimRegistry,
/// Registro de animaciones implícitas de **tamaño**
/// (`View::animated_size`, Flutter `AnimatedSize`), vivo entre frames.
/// A diferencia de [`Self::anim_registry`] que reconcilia props de
/// paint DESPUÉS del layout, este reconcilia `style.size`
/// **antes** del mount/compute, así siblings/hijos reflowean suave.
/// Ver [`llimphi_compositor::SizeAnimRegistry`].
size_anim_registry: llimphi_compositor::SizeAnimRegistry,
/// Registro de **heroes / shared-element transitions** (`View::hero`),
/// vivo entre frames. Detecta cambio de rect de una misma `key` entre
/// frames y escribe `transform` para "volar" del rect anterior al actual.
/// Ver [`llimphi_compositor::HeroRegistry`].
hero_registry: llimphi_compositor::HeroRegistry,
/// Adapter [AccessKit](https://accesskit.dev) — empuja un árbol de
/// accesibilidad al SO en cada paint para alimentar lectores de pantalla.
/// Sólo se inicializa si el SO tiene una tecnología asistiva activa; el
/// `update_if_active` evita construir el árbol cuando nadie escucha.
a11y_adapter: accesskit_winit::Adapter,
/// Identidad estable del árbol de accesibilidad entre `TreeUpdate`s. Se
/// genera una vez al crear el runtime y se reutiliza en cada update — los
/// lectores la usan para distinguir nuestra ventana de otras del SO.
a11y_tree_id: accesskit::TreeId,
/// Registro de **ripples/InkWell** (`View::ripple`), vivo entre frames. El
/// press dispara una salpicadura; cada redraw la pinta sobre el contenido y,
/// mientras alguna siga viva, pide otro frame (ticker autodetenido). Ver
/// [`llimphi_compositor::RippleRegistry`].
ripple_registry: llimphi_compositor::RippleRegistry,
/// Último tap (press izquierdo) sobre un nodo con `on_double_tap`: instante
/// + posición. El próximo press que caiga cerca y a tiempo dispara el
/// doble-tap. `None` cuando no hay un primer tap pendiente.
last_tap: Option<(std::time::Instant, PhysicalPosition<f64>)>,
/// Long-press armado (ver [`PendingLongPress`]). El runtime lo vence por
/// tiempo en `about_to_wait` y lo cancela en movimiento/release.
pending_long_press: Option<PendingLongPress<A::Msg>>,
/// **Retención de frame entero**. Tras un paint exitoso, guardamos las
/// dimensiones del viewport y los flags de animación del frame. Si en el
/// próximo `RedrawRequested` ningún sitio invalidó `last_render` (la
/// invariante existente del runtime), el modelo + view + layout son
/// idénticos al frame anterior: no hace falta rehacer mount/layout/paint,
/// alcanza con re-presentar `state.scene` tal cual quedó. Mata redraws
/// espurios (expose del compositor, refocus, ticker en el último frame de
/// una anim ya asentada). Si el frame retenido estaba animando o ripplando,
/// el ticker NECESITA avanzarlo → no hay retención (cache miss). Tampoco
/// hay retención con overlay o drag activos (camino conservador). Ver el
/// hit-check en `RedrawRequested`.
retained: Option<RetainedScene>,
/// Selección de texto activa fuera del editor (drag para resaltar, Ctrl/Cmd+C
/// para copiar). `None` = nada seleccionado. Ver [`TextSelection`].
selection: Option<TextSelection>,
}
/// Metadata del frame retenido — qué pintó la `state.scene` para validar que
/// re-presentarla sin re-pintar es seguro.
#[derive(Clone, Copy)]
struct RetainedScene {
w: u32,
h: u32,
animating: bool,
rippling: bool,
has_overlay: bool,
}
/// Selección de texto activa fuera del editor (ver [`crate::View::selectable`]).
/// Anclada a la `key` estable del nodo (no a su `NodeId`, que cambia cada
/// frame); el runtime reconstruye el `parley::Layout` del nodo bajo esa key
/// para extender la selección al arrastrar y para pintar el resaltado.
#[derive(Clone, Copy)]
struct TextSelection {
/// Key estable del nodo seleccionable (`text_select_key`).
key: u64,
/// Rango seleccionado, en coordenadas de bytes del `parley::Layout`.
sel: llimphi_text::parley::Selection,
/// `true` mientras el botón izquierdo sigue apretado (arrastrando).
dragging: bool,
}
struct RenderCache<Msg> {
@@ -565,12 +717,71 @@ struct OverlayCache<Msg> {
hover_idx: Option<usize>,
}
/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
/// o la variante que conserva la posición local del press original
/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
/// Tres sabores de handler de drag activo: el simple `(phase, dx, dy)`;
/// la variante que conserva la posición local del press original
/// `(phase, dx, dy, lx0, ly0)`; o el handler **con velocidad** que recibe
/// también `(vx, vy)` al `DragPhase::End` (medida sobre los últimos
/// [`VELOCITY_WINDOW`] de movimiento). El runtime elige uno al iniciar el
/// drag — un nodo es uno u otro.
enum DragHandlerKind<Msg> {
Delta(DragFn<Msg>),
DeltaAt(DragAtFn<Msg>, f32, f32),
Velocity(DragVelocityFn<Msg>),
}
/// Un handler de gesto "tipo click" (doble-tap / long-press) ya **resuelto**
/// contra el nodo: o un `Msg` directo, o un handler posicional con la posición
/// local `(lx, ly, w, h)` ya calculada. Se captura en el press para poder
/// dispararlo más tarde (long-press, que vence por tiempo) sin volver a tocar
/// el árbol.
enum GestureResolved<Msg> {
Direct(Msg),
At(ClickAtFn<Msg>, f32, f32, f32, f32),
}
impl<Msg: Clone> GestureResolved<Msg> {
/// Materializa el `Msg` (clona el directo o invoca el handler posicional).
fn invoke(&self) -> Option<Msg> {
match self {
GestureResolved::Direct(m) => Some(m.clone()),
GestureResolved::At(h, lx, ly, w, ht) => h(*lx, *ly, *w, *ht),
}
}
}
/// Long-press **armado**: el press cayó sobre un nodo con `on_long_press`. El
/// runtime lo dispara cuando pasa `deadline` (en `about_to_wait`), salvo que
/// antes el cursor se aleje de `origin` (pasó a drag) o se suelte el botón —
/// en ambos casos se cancela. Es la parte de "arena" del gesto: el árbitro es
/// el tiempo + el movimiento.
struct PendingLongPress<Msg> {
deadline: std::time::Instant,
origin: PhysicalPosition<f64>,
handler: GestureResolved<Msg>,
}
/// Umbral de duración para que un press se convierta en long-press.
const LONG_PRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(500);
/// Si el cursor se aleja más que esto (px físicos) del origen del press, deja
/// de ser long-press (pasó a drag/scroll) y se cancela.
const LONG_PRESS_MOVE_CANCEL: f64 = 8.0;
/// Ventana temporal máxima entre los dos taps de un doble-tap.
const DOUBLE_TAP_WINDOW: std::time::Duration = std::time::Duration::from_millis(400);
/// Distancia máxima (px físicos) entre los dos taps de un doble-tap.
const DOUBLE_TAP_DIST: f64 = 16.0;
/// ¿El press actual (`now`, `pos`) completa un doble-tap con el tap previo
/// `last`? Verdadero si hubo un tap previo dentro de [`DOUBLE_TAP_WINDOW`] y a
/// menos de [`DOUBLE_TAP_DIST`]. Función pura (testeable sin event loop).
fn double_tap_qualifies(
last: Option<(std::time::Instant, PhysicalPosition<f64>)>,
now: std::time::Instant,
pos: PhysicalPosition<f64>,
) -> bool {
last.is_some_and(|(t, p)| {
now.duration_since(t) <= DOUBLE_TAP_WINDOW
&& ((p.x - pos.x).powi(2) + (p.y - pos.y).powi(2)).sqrt() <= DOUBLE_TAP_DIST
})
}
struct DragState<Msg> {
@@ -583,6 +794,48 @@ struct DragState<Msg> {
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
/// targets sólo reaccionan cuando hay payload.
payload: Option<u64>,
/// Buffer móvil de (timestamp, dx, dy) por cada `CursorMoved` durante
/// el drag, recortado a [`VELOCITY_MAX_SAMPLES`]. Sólo se usa cuando el
/// handler es [`DragHandlerKind::Velocity`] — en los otros sabores
/// queda vacío. Al `DragPhase::End` el runtime computa la velocidad
/// sobre la ventana [`VELOCITY_WINDOW`].
samples: std::collections::VecDeque<(std::time::Instant, f64, f64)>,
}
/// Ventana temporal sobre la que se mide la velocidad de un drag al
/// soltarlo. Movimientos más viejos no cuentan — sólo importa el último
/// flick. ~100 ms es el valor que usa Android para fling.
const VELOCITY_WINDOW: std::time::Duration = std::time::Duration::from_millis(100);
/// Tope superior de muestras retenidas en el buffer móvil de velocidad —
/// con eventos típicos de 60120 Hz, ocho muestras cubren la ventana
/// holgadamente. Más allá es ruido y costo.
const VELOCITY_MAX_SAMPLES: usize = 8;
/// Velocidad (px/s) calculada sobre los últimos [`VELOCITY_WINDOW`] de
/// movimiento. Toma sólo las muestras dentro de la ventana, suma los
/// deltas y divide por el tiempo transcurrido desde la primera muestra
/// retenida hasta `now`. Función pura para testear sin event loop.
fn compute_drag_velocity(
samples: &std::collections::VecDeque<(std::time::Instant, f64, f64)>,
now: std::time::Instant,
) -> (f32, f32) {
if samples.is_empty() {
return (0.0, 0.0);
}
let cutoff = now.checked_sub(VELOCITY_WINDOW).unwrap_or(now);
let recent: Vec<&(std::time::Instant, f64, f64)> =
samples.iter().filter(|(t, _, _)| *t >= cutoff).collect();
if recent.is_empty() {
return (0.0, 0.0);
}
let t0 = recent[0].0;
let dt = now.duration_since(t0).as_secs_f32();
if dt < 0.001 {
return (0.0, 0.0);
}
let sum_dx: f64 = recent.iter().map(|(_, dx, _)| *dx).sum();
let sum_dy: f64 = recent.iter().map(|(_, _, dy)| *dy).sum();
((sum_dx as f32) / dt, (sum_dy as f32) / dt)
}
/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
@@ -602,3 +855,89 @@ pub fn run<A: App>() {
};
event_loop.run_app(&mut runtime).expect("run app");
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn lift_aplica_la_funcion_de_elevacion() {
use std::sync::{Arc, Mutex};
// `lift` aplica la función Sub->Host síncronamente en `dispatch` (el
// dispatch al padre Test es no-op, pero la elevación corre): así
// observamos que el msg del sub-app se transforma para el host.
let seen = Arc::new(Mutex::new(Vec::<i32>::new()));
let parent: Handle<i32> = Handle::for_test();
let sub: Handle<String> = {
let seen = seen.clone();
parent.lift(move |s: String| {
let n = s.len() as i32;
seen.lock().unwrap().push(n);
n
})
};
sub.dispatch("hola".to_string());
let _ = sub.clone(); // es Clone como cualquier handle
assert_eq!(*seen.lock().unwrap(), vec![4]);
}
#[test]
fn velocidad_de_drag_promedia_dentro_de_la_ventana() {
use std::collections::VecDeque;
let now = Instant::now();
// Cuatro muestras dentro de la ventana (últimos 80 ms): 4 px en x cada
// 20 ms ⇒ 16 px en 80 ms ⇒ 200 px/s.
let mut samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
samples.push_back((now - Duration::from_millis(80), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(60), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(40), 4.0, 0.0));
samples.push_back((now - Duration::from_millis(20), 4.0, 0.0));
let (vx, vy) = compute_drag_velocity(&samples, now);
assert!((vx - 200.0).abs() < 1.0, "vx={vx}");
assert!(vy.abs() < 1e-3);
// Buffer vacío → (0,0).
let empty: VecDeque<(Instant, f64, f64)> = VecDeque::new();
assert_eq!(compute_drag_velocity(&empty, now), (0.0, 0.0));
// Muestras todas más viejas que VELOCITY_WINDOW → (0,0) (no hay
// movimiento reciente para fling).
let mut old: VecDeque<(Instant, f64, f64)> = VecDeque::new();
old.push_back((now - Duration::from_millis(500), 10.0, 10.0));
assert_eq!(compute_drag_velocity(&old, now), (0.0, 0.0));
// Eje y positivo (scroll vertical típico): 5 px cada 25 ms ⇒ 200 px/s.
let mut vy_samples: VecDeque<(Instant, f64, f64)> = VecDeque::new();
vy_samples.push_back((now - Duration::from_millis(75), 0.0, 5.0));
vy_samples.push_back((now - Duration::from_millis(50), 0.0, 5.0));
vy_samples.push_back((now - Duration::from_millis(25), 0.0, 5.0));
let (_, vy) = compute_drag_velocity(&vy_samples, now);
assert!((vy - 200.0).abs() < 1.0, "vy={vy}");
}
#[test]
fn double_tap_ventana_y_distancia() {
let t0 = Instant::now();
let p = PhysicalPosition::new(100.0, 100.0);
// Sin tap previo → nunca califica.
assert!(!double_tap_qualifies(None, t0, p));
// Segundo tap a tiempo (100 ms < 400) y cerca (3px < 16) → califica.
let near = PhysicalPosition::new(102.0, 102.0);
assert!(double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(100),
near
));
// A tiempo pero lejos (>16px) → no.
let far = PhysicalPosition::new(140.0, 100.0);
assert!(!double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(100),
far
));
// Cerca pero tarde (>400 ms) → no.
assert!(!double_tap_qualifies(
Some((t0, p)),
t0 + Duration::from_millis(600),
near
));
}
}
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "llimphi-voxel"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
repository.workspace = true
description = "llimphi-voxel — capa de dinámica voxel/juego (estilo Minecraft) sobre el motor 3D general llimphi-3d: world-gen procedural (terreno por ruido fractal) y la casa de bloques/biomas/streaming. Reusable por cualquier juego con orientación voxel. NO renderiza: delega en llimphi-3d (Scene3d + VoxelRenderer)."
[dependencies]
# El motor 3D general; esta capa aporta CONTENIDO/dinámica, no render.
llimphi-3d = { path = "../llimphi-3d", version = "0.1.0" }
# Puente al formato MagicaVoxel (.vox): importar sets/personajes a VoxelGrid.
foreign-vox = { path = "../shared/foreign-vox", version = "0.1.0" }
# (de)serialización de las ediciones persistidas para la CAS (mundo→postcard) y de
# los artefactos del studio (Project: mundos/personajes con nombre).
serde = { workspace = true, features = ["derive"] }
postcard = { workspace = true }
[dev-dependencies]
# Volcado headless a PNG de los demos de mundo (mismo patrón que llimphi-3d).
llimphi-hal = { path = "../llimphi-hal" }
llimphi-raster = { path = "../llimphi-raster" }
png = { workspace = true }
pollster = { workspace = true }
# Direccionamiento por contenido (BLAKE3) de las ediciones en el demo de CAS.
blake3 = { workspace = true }
# Round-trip RON de los artefactos del studio (Project) en los tests.
ron = { workspace = true }
+169
View File
@@ -0,0 +1,169 @@
//! Demo headless de las **edades cuantizadas** del personaje: los 5 estadios
//! (bebé/niño/joven/adulto/viejo) parados en fila sobre arena, para ver la
//! progresión de proporciones (el bebé cabezón → el adulto alto). El corto arranca
//! mostrando al **niño** recién nacido.
//!
//! `cargo run -p llimphi-voxel --example ages_demo --release` → `/tmp/ages.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Actor, Age, Material};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena (aísla los cuerpos; sin relieve que distraiga).
let dim = [44u32, 16, 20];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let floor_top = 2.0; // y del suelo (sobre las 2 capas)
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
// 5 actores, uno por edad, espaciados en X. El grid se centra en el origen, así
// la coord de mundo del actor = local dim/2.
let ages = [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder];
let palettes: [([f32; 3], [f32; 3]); 5] = [
([0.90, 0.74, 0.60], [0.86, 0.40, 0.42]), // bebé
([0.88, 0.70, 0.56], [0.36, 0.62, 0.82]), // niño
([0.86, 0.68, 0.54], [0.40, 0.74, 0.46]), // joven
([0.84, 0.66, 0.52], [0.82, 0.66, 0.30]), // adulto
([0.82, 0.64, 0.50], [0.62, 0.52, 0.74]), // viejo
];
let mut actor_r = Vec::new();
for (k, (age, (skin, shirt))) in ages.iter().zip(palettes).enumerate() {
let lx = 12.0 + k as f32 * 5.0; // fila apretada centrada en el grid
let wx = lx - dim[0] as f32 / 2.0;
let wz = 0.0; // centro en z (mundo)
let mut a = Actor::new(Vec3::new(wx, floor_top - dim[1] as f32 / 2.0, wz), std::f32::consts::PI)
.with_age(*age)
.with_colors(skin, shirt, [0.20, 0.22, 0.30]);
a.look_at(None);
let (v, i) = a.mesh();
let mut r = Renderer3d::new(&hal.device, FMT);
r.set_geometry(&hal.device, &v, &i);
r.set_model(a.model());
actor_r.push(r);
}
// Cámara frontal baja y CERCA, encuadrando la fila a la altura del pecho.
let feet_y = floor_top - dim[1] as f32 / 2.0;
let camera = Camera3d::orbit(Vec3::new(0.0, feet_y + 1.0, 0.0), 0_f32.to_radians(), 8_f32.to_radians(), 17.0);
let refs: Vec<&Renderer3d> = actor_r.iter().collect();
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &camera);
write_png(&pixels, "/tmp/ages.png");
eprintln!("escrito /tmp/ages.png (bebé · niño · joven · adulto · viejo)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("ages") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), meshes);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+196
View File
@@ -0,0 +1,196 @@
//! Demo headless de la **secuencia de nacimiento** (modos de cámara): un montaje
//! 2×2 de cuatro momentos —
//! 1. la cámara cae del cielo mirando abajo (ve el huevo),
//! 2. casi tocando suelo (el huevo se raja),
//! 3. recién nacido: la cámara sale del sujeto,
//! 4. plano de seguimiento detrás del niño.
//!
//! `cargo run -p llimphi-voxel --example birth_demo --release` → `/tmp/birth.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Age, BirthSequence, Egg, Hatchling, Material};
// Cada cuadro del montaje (mitad de un lienzo 960×540 → 2×2).
const TW: u32 = 480;
const TH: u32 = 270;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena, centrado en el origen.
let dim = [48u32, 28, 48];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let feet_y = 2.0 - dim[1] as f32 / 2.0; // suelo en mundo
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
// Huevo en el centro, sobre el suelo. La secuencia hace caer la cámara sobre él.
let egg = Egg::new(Vec3::new(0.0, feet_y, 0.0), 1.4, Hatchling::human(Age::Baby));
let seq = BirthSequence::new(egg);
// Cuatro instantes clave de la secuencia.
let ts = [
seq.t_land * 0.35, // cayendo, alto
seq.t_land * 0.93, // casi en el suelo, el huevo se raja
seq.t_land + seq.t_pull * 0.5, // saliendo del sujeto
seq.duration(), // seguimiento detrás del niño
];
// Lienzo final 2×2.
let fw = TW * 2;
let fh = TH * 2;
let mut canvas = vec![0u8; (fw * fh * 4) as usize];
for (idx, &t) in ts.iter().enumerate() {
let mut egg_t = seq.egg;
egg_t.hatch = seq.hatch(t);
let camera = seq.camera(t);
let mut meshes: Vec<Renderer3d> = Vec::new();
let (ev, ei) = egg_t.mesh();
let mut er = Renderer3d::new(&hal.device, FMT);
er.set_geometry(&hal.device, &ev, &ei);
er.set_model(egg_t.model());
meshes.push(er);
// El recién nacido aparece una vez que el huevo está bien abierto.
if egg_t.hatch > 0.5 {
let baby = seq.newborn();
let (bv, bi) = baby.mesh();
let mut br = Renderer3d::new(&hal.device, FMT);
br.set_geometry(&hal.device, &bv, &bi);
br.set_model(baby.model());
meshes.push(br);
}
let refs: Vec<&Renderer3d> = meshes.iter().collect();
let mut scene = Scene3d::new();
let cam = {
let mut c = camera;
c.fovy_rad = 55_f32.to_radians();
c
};
let tile = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &cam);
// Pegar el cuadro en su celda del 2×2.
let (cx, cy) = ((idx as u32 % 2) * TW, (idx as u32 / 2) * TH);
for row in 0..TH {
let src = (row * TW * 4) as usize;
let dst = (((cy + row) * fw + cx) * 4) as usize;
canvas[dst..dst + (TW * 4) as usize].copy_from_slice(&tile[src..src + (TW * 4) as usize]);
}
}
write_png(&canvas, fw, fh, "/tmp/birth.png");
eprintln!("escrito /tmp/birth.png (caída · rajadura · nace · seguimiento)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &llimphi_3d::Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: TW, height: TH, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
// Fondo cielo (sin niebla los misses del voxel descartan a este color base).
renderer
.render_to_view(hal, &vello::Scene::new(), &view, TW, TH, Color::from_rgba8(150, 184, 224, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("birth") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (TW, TH), camera, Some(vr), meshes);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (TW * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * TH as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(TH),
},
},
wgpu::Extent3d { width: TW, height: TH, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((TW * TH * 4) as usize);
for row in 0..TH as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], w: u32, h: u32, path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+145
View File
@@ -0,0 +1,145 @@
//! Demo headless del **creador de mundos**: rinde la receta del **desierto**
//! ([`WorldRecipe::desert`]) — llano de arena, pocas montañas, pocos ríos, cactus.
//! Es el mundo de apertura del corto.
//!
//! `cargo run -p llimphi-voxel --example desert_demo --release -- [dim_xz] [seed]`
//! → `/tmp/desert.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Scene3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::WorldRecipe;
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(7);
let dy: u32 = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let recipe = WorldRecipe::desert(seed);
let grid = recipe.generate(dim);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.62, 0.42, 0.28]; // sol más bajo → relieve/sombras de las dunas y cactus
vr.atmosphere = Atmosphere {
sky_zenith: [92, 146, 208],
sky_horizon: [228, 206, 162], // horizonte arenoso/caluroso
fog_density: 0.22 / dim_xz as f32, // niebla suave: no lavar el llano
};
// Cámara baja, en 3/4, para leer el llano + los cactus recortados contra el cielo.
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * -0.18, 0.0),
38_f32.to_radians(),
14_f32.to_radians(),
dim_xz as f32 * 1.5,
);
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &camera);
write_png(&pixels, "/tmp/desert.png");
eprintln!("escrito /tmp/desert.png (desierto {dim_xz}³ seed {seed})");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("desert") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), &[]);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+165
View File
@@ -0,0 +1,165 @@
//! Demo headless del **objeto potencial**: el huevo en tres momentos — intacto,
//! rajándose, y abierto con el **bebé recién nacido** al lado. Es el corazón de la
//! apertura del corto (el huevo nace en el desierto).
//!
//! `cargo run -p llimphi-voxel --example egg_demo --release` → `/tmp/egg.png`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, Renderer3d, Scene3d, VoxelGrid, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{Age, Egg, Hatchling, Material};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
// Piso plano de arena.
let dim = [44u32, 16, 20];
let mut grid = VoxelGrid::new(dim);
for z in 0..dim[2] {
for x in 0..dim[0] {
grid.set(x, 0, z, Material::Sand.color());
grid.set(x, 1, z, Material::Sand.color());
}
}
grid.reset_dirty();
let feet_y = 2.0 - dim[1] as f32 / 2.0; // y del suelo en mundo
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.5, 0.7, 0.4];
vr.atmosphere = Atmosphere { sky_zenith: [96, 150, 210], sky_horizon: [226, 208, 168], fog_density: 0.0 };
let mut meshes: Vec<Renderer3d> = Vec::new();
let push = |meshes: &mut Vec<Renderer3d>, hal: &Hal, v: &[_], i: &[u16], model| {
let mut r = Renderer3d::new(&hal.device, FMT);
r.set_geometry(&hal.device, v, i);
r.set_model(model);
meshes.push(r);
};
// Tres huevos en fila: intacto, rajándose, abierto.
let xs = [-11.0_f32, 0.0, 11.0];
let hatches = [0.0_f32, 0.55, 1.0];
for (x, h) in xs.iter().zip(hatches) {
let mut egg = Egg::new(Vec3::new(*x, feet_y, 0.0), 1.6, Hatchling::human(Age::Baby));
egg.hatch = h;
let (v, i) = egg.mesh();
push(&mut meshes, &hal, &v, &i, egg.model());
// En el abierto, el bebé recién nacido sale, un paso al frente.
if egg.is_open() {
let mut baby = egg.newborn();
baby.pos = egg.pos + Vec3::new(0.0, 0.0, 1.4); // un paso hacia la cámara
baby.facing = std::f32::consts::PI;
let (bv, bi) = baby.mesh();
push(&mut meshes, &hal, &bv, &bi, baby.model());
}
}
let camera = Camera3d::orbit(Vec3::new(0.0, feet_y + 1.0, 0.0), 0_f32.to_radians(), 10_f32.to_radians(), 19.0);
let refs: Vec<&Renderer3d> = meshes.iter().collect();
let mut scene = Scene3d::new();
let pixels = render(&hal, &mut renderer, &mut scene, &mut vr, &refs, &camera);
write_png(&pixels, "/tmp/egg.png");
eprintln!("escrito /tmp/egg.png (intacto · rajándose · abierto + bebé)");
}
fn render(
hal: &Hal,
renderer: &mut Renderer,
scene: &mut Scene3d,
vr: &mut VoxelRenderer,
meshes: &[&Renderer3d],
camera: &Camera3d,
) -> Vec<u8> {
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = inter.create_view(&wgpu::TextureViewDescriptor::default());
renderer
.render_to_view(hal, &vello::Scene::new(), &view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("egg") });
scene.render(&hal.device, &hal.queue, &mut enc, &view, (W, H), camera, Some(vr), meshes);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
readback(hal, &inter)
}
fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
pixels
}
fn write_png(pixels: &[u8], path: &str) {
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wtr = enc.write_header().unwrap();
wtr.write_image_data(pixels).unwrap();
}
+186
View File
@@ -0,0 +1,186 @@
//! Demo headless de la mecánica núcleo de juego voxel: **mirar → romper**.
//!
//! Renderiza el terreno, tira un [`raycast`] desde la cámara hacia el centro de
//! la vista hasta el primer voxel sólido, y **cava un cráter** (vacía los voxels
//! en un radio del impacto). Cada edición sube SÓLO su sub-caja vía
//! `VoxelRenderer::sync` (no re-sube el mundo). Vuelca PNG antes/después e
//! imprime los bytes subidos vs el grid completo.
//!
//! `cargo run -p llimphi-voxel --example raycast_edit --release -- [dim_xz] [seed]`
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
use llimphi_voxel::{raycast, terrain};
const W: u32 = 880;
const H: u32 = 560;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(7);
let dy = (dim_xz * 4 / 10).max(48);
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let mut grid = terrain(dim, seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
vr.sun_dir = [0.55, 0.6, 0.3];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [202, 218, 236],
fog_density: 0.5 / dim_xz as f32,
};
let inter = make_target(&hal);
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * 0.30, 0.0),
45_f32.to_radians(),
14_f32.to_radians(),
dim_xz as f32 * 0.78,
);
// (antes)
draw(&hal, &mut renderer, &mut vr, &inter, &inter_view, &camera, "/tmp/edit_before.png");
// Rayo desde la cámara hacia el centro de la vista. La grilla está centrada
// en el origen → origen del rayo en grilla = eye_mundo + dim/2.
let dimv = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32);
let ro = camera.eye + dimv * 0.5;
let rd = (camera.target - camera.eye).normalize();
match raycast(&grid, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z], dim_xz as f32 * 3.0) {
Some(hit) => {
eprintln!("impacto en {:?} (cara {:?}, dist {:.1})", hit.cell, hit.normal, hit.dist);
// Cavar un cráter esférico alrededor del impacto.
let r = 12i32;
let [cx, cy, cz] = hit.cell;
for dz in -r..=r {
for dyy in -r..=r {
for dx in -r..=r {
if dx * dx + dyy * dyy + dz * dz <= r * r {
let (x, y, z) = (cx + dx, cy + dyy, cz + dz);
if x >= 0 && y >= 0 && z >= 0 {
grid.clear(x as u32, y as u32, z as u32);
}
}
}
}
}
let uploaded = vr.sync(&hal.queue, &mut grid);
let full = dim[0] * dim[1] * dim[2] * 4;
eprintln!(
"cráter r={r} → sync subió {} KiB ({:.3}% del grid completo de {} KiB) — incremental",
uploaded / 1024,
uploaded as f32 / full as f32 * 100.0,
full / 1024,
);
}
None => eprintln!("el rayo no pegó terreno (ajustá cámara)"),
}
// (después)
draw(&hal, &mut renderer, &mut vr, &inter, &inter_view, &camera, "/tmp/edit_after.png");
eprintln!("escrito /tmp/edit_before.png y /tmp/edit_after.png ({W}x{H})");
}
fn make_target(hal: &Hal) -> wgpu::Texture {
hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
#[allow(clippy::too_many_arguments)]
fn draw(
hal: &Hal,
renderer: &mut Renderer,
vr: &mut VoxelRenderer,
target: &wgpu::Texture,
target_view: &wgpu::TextureView,
camera: &Camera3d,
out: &str,
) {
renderer
.render_to_view(hal, &vello::Scene::new(), target_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("base");
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel") });
vr.render(&hal.device, &hal.queue, &mut enc, target_view, (W, H), camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
write_png(hal, target, out);
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
+155
View File
@@ -0,0 +1,155 @@
//! Demo headless de M6 (primera rebanada): **world-gen procedural + atmósfera**.
//!
//! Genera un paisaje voxel grande por ruido fractal ([`llimphi_3d::terrain`]) y
//! lo ray-marchea con **cielo gradiente + niebla por distancia** ([`Atmosphere`])
//! — lo que hace legible el borde lejano de un mundo grande (sin niebla, el
//! horizonte del terreno se ve como un muro recortado). Imprime además el ahorro
//! de memoria del brick pool sparse: un mundo es casi todo aire, así que el pool
//! ocupa una fracción del grid denso.
//!
//! `cargo run -p llimphi-3d --example terrain_demo --release -- [dim_xz] [seed]`
//! → escribe /tmp/m6_terrain_{a,b,c}.png (tres ángulos de órbita).
use std::fs::File;
use std::io::BufWriter;
use llimphi_3d::glam::Vec3;
use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
use llimphi_hal::{wgpu, Hal};
use llimphi_voxel::terrain;
use llimphi_raster::peniko::Color;
use llimphi_raster::{vello, Renderer};
const W: u32 = 960;
const H: u32 = 540;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
fn main() {
let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(192);
let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
let dy: u32 = (dim_xz * 4 / 10).max(48); // mundo "ancho y bajo": continente, no torre.
let dim = [dim_xz, dy, dim_xz];
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let grid = terrain(dim, seed);
let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
let (used, total) = vr.brick_usage();
let (pool, dense) = vr.memory_bytes();
eprintln!(
"terreno {}×{}×{} (seed {seed}) — brick pool {used}/{total} bricks ({:.1}%) → {} KiB vs denso {} KiB ({:.1}× menos)",
dim[0], dim[1], dim[2],
used as f32 / total as f32 * 100.0,
pool / 1024,
dense / 1024,
dense as f32 / pool.max(1) as f32,
);
// Atmósfera diurna: sol bajo (luz rasante = relieve marcado), niebla suave
// escalada al tamaño del mundo (lo lejano desvanece, lo cercano queda nítido).
vr.sun_dir = [0.55, 0.55, 0.35];
vr.atmosphere = Atmosphere {
sky_zenith: [64, 118, 196],
sky_horizon: [200, 216, 234],
fog_density: 0.5 / dim_xz as f32,
};
let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("inter"),
size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
let d = dim_xz as f32;
for (tag, yaw) in [("a", 35.0_f32), ("b", 120.0), ("c", 230.0)] {
// (1) Fondo vello (lo tapa la atmósfera del pase voxel, pero dejamos el
// mismo orden que el runtime: [vello base] → [GPU 3D]).
let base = vello::Scene::new();
renderer
.render_to_view(&hal, &base, &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
.expect("render base");
// (2) Pase voxel. Órbita mirando un poco por encima del centro para que
// entre cielo en cuadro; pitch bajo = vista de paisaje.
let camera = Camera3d::orbit(
Vec3::new(0.0, dy as f32 * 0.12, 0.0),
yaw.to_radians(),
22_f32.to_radians(),
d * 1.45,
);
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
vr.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera);
hal.queue.submit(std::iter::once(enc.finish()));
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
let out = format!("/tmp/m6_terrain_{tag}.png");
write_png(&hal, &inter, &out);
eprintln!("escrito {out} ({W}x{H}, yaw={yaw}°)");
}
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}

Some files were not shown because too many files have changed in this diff Show More