Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c2ea9ffa | |||
| e548698fc4 | |||
| 5ae7c73703 | |||
| 38d3dddaea | |||
| 92dd0e5bfb | |||
| e2102d6057 | |||
| efe361545d | |||
| 993b7625b8 | |||
| dba855e446 | |||
| 1a205c764d | |||
| ccab39f140 | |||
| e74800d9da | |||
| 5b7ac4fd5d | |||
| 14d76bd4af | |||
| fbd3dc5ca4 |
Generated
+2071
-216
File diff suppressed because it is too large
Load Diff
+119
-114
@@ -6,16 +6,17 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
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-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion",
|
||||||
"llimphi-icons", "llimphi-compositor", "llimphi-workspace",
|
"llimphi-icons", "llimphi-compositor", "llimphi-workspace",
|
||||||
"widgets/*", "modules/*", "shared/app-bus",
|
"widgets/*", "modules/*", "shared/app-bus",
|
||||||
|
"llimphi-3d", "llimphi-voxel", "shared/foreign-vox",
|
||||||
]
|
]
|
||||||
exclude = [
|
exclude = [
|
||||||
"android",
|
"android",
|
||||||
"llimphi-gallery", "llimphi-gpu-bench",
|
"llimphi-gallery", "llimphi-gpu-bench",
|
||||||
"widgets/gallery",
|
"widgets/gallery",
|
||||||
"modules/shuma-term", "modules/plugin-host",
|
"modules/shuma-term", "modules/plugin-host", "modules/allichay",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -24,12 +25,13 @@ edition = "2021"
|
|||||||
rust-version = "1.80"
|
rust-version = "1.80"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Sergio <gerencia@jlsoltech.com>"]
|
authors = ["Sergio <gerencia@jlsoltech.com>"]
|
||||||
publish = false
|
publish = true
|
||||||
repository = "https://gitea.gioser.net/sergio/llimphi"
|
repository = "https://git.tawasuyu.net/tawasuyu/llimphi"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# === Registro de apps / menú global ===
|
# === 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 ===
|
# === Serialización ===
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -158,87 +160,90 @@ tempfile = "3"
|
|||||||
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
|
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
|
||||||
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
|
# 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).
|
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
|
||||||
wgpu = "24"
|
wgpu = "27"
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
pollster = "0.4"
|
pollster = "0.4"
|
||||||
vello = "0.5"
|
vello = "0.7"
|
||||||
taffy = "0.9"
|
taffy = "0.9"
|
||||||
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
|
# 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.
|
# 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.
|
# 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.
|
# 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.
|
# 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.
|
# Widgets reusables sobre llimphi-ui — uno por crate.
|
||||||
llimphi-widget-app-header = { path = "widgets/app-header" }
|
llimphi-widget-app-header = { path = "widgets/app-header", version = "0.1.0" }
|
||||||
llimphi-widget-banner = { path = "widgets/banner" }
|
llimphi-widget-banner = { path = "widgets/banner", version = "0.1.0" }
|
||||||
llimphi-widget-button = { path = "widgets/button" }
|
llimphi-widget-button = { path = "widgets/button", version = "0.1.0" }
|
||||||
llimphi-widget-card = { path = "widgets/card" }
|
llimphi-widget-card = { path = "widgets/card", version = "0.1.0" }
|
||||||
llimphi-clipboard = { path = "widgets/clipboard" }
|
llimphi-clipboard = { path = "widgets/clipboard", version = "0.1.0" }
|
||||||
llimphi-widget-context-menu = { path = "widgets/context-menu" }
|
llimphi-widget-context-menu = { path = "widgets/context-menu", version = "0.1.0" }
|
||||||
llimphi-widget-edit-menu = { path = "widgets/edit-menu" }
|
llimphi-widget-edit-menu = { path = "widgets/edit-menu", version = "0.1.0" }
|
||||||
llimphi-widget-menubar = { path = "widgets/menubar" }
|
llimphi-widget-menubar = { path = "widgets/menubar", version = "0.1.0" }
|
||||||
llimphi-widget-list = { path = "widgets/list" }
|
llimphi-widget-list = { path = "widgets/list", version = "0.1.0" }
|
||||||
llimphi-widget-grid = { path = "widgets/grid" }
|
llimphi-widget-grid = { path = "widgets/grid", version = "0.1.0" }
|
||||||
llimphi-widget-slider = { path = "widgets/slider" }
|
llimphi-widget-slider = { path = "widgets/slider", version = "0.1.0" }
|
||||||
llimphi-widget-scroll = { path = "widgets/scroll" }
|
llimphi-widget-scroll = { path = "widgets/scroll", version = "0.1.0" }
|
||||||
llimphi-widget-splitter = { path = "widgets/splitter" }
|
llimphi-widget-splitter = { path = "widgets/splitter", version = "0.1.0" }
|
||||||
llimphi-widget-stat-card = { path = "widgets/stat-card" }
|
llimphi-widget-stat-card = { path = "widgets/stat-card", version = "0.1.0" }
|
||||||
llimphi-widget-tabs = { path = "widgets/tabs" }
|
llimphi-widget-tabs = { path = "widgets/tabs", version = "0.1.0" }
|
||||||
llimphi-module-command-palette = { path = "modules/command-palette" }
|
llimphi-module-command-palette = { path = "modules/command-palette", version = "0.1.0" }
|
||||||
llimphi-module-diff-viewer = { path = "modules/diff-viewer" }
|
llimphi-module-diff-viewer = { path = "modules/diff-viewer", version = "0.1.0" }
|
||||||
llimphi-module-fif = { path = "modules/fif" }
|
llimphi-module-fif = { path = "modules/fif", version = "0.1.0" }
|
||||||
llimphi-module-file-picker = { path = "modules/file-picker" }
|
llimphi-module-file-picker = { path = "modules/file-picker", version = "0.1.0" }
|
||||||
llimphi-module-bookmarks = { path = "modules/bookmarks" }
|
llimphi-module-bookmarks = { path = "modules/bookmarks", version = "0.1.0" }
|
||||||
llimphi-module-mini-map = { path = "modules/mini-map" }
|
llimphi-module-mini-map = { path = "modules/mini-map", version = "0.1.0" }
|
||||||
llimphi-module-shuma-term = { path = "modules/shuma-term" }
|
llimphi-module-shuma-term = { path = "modules/shuma-term", version = "0.1.0" }
|
||||||
llimphi-module-symbol-outline = { path = "modules/symbol-outline" }
|
llimphi-module-symbol-outline = { path = "modules/symbol-outline", version = "0.1.0" }
|
||||||
llimphi-plugin-host = { path = "modules/plugin-host" }
|
llimphi-plugin-host = { path = "modules/plugin-host", version = "0.1.0" }
|
||||||
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher" }
|
llimphi-widget-theme-switcher = { path = "widgets/theme-switcher", version = "0.1.0" }
|
||||||
llimphi-widget-text-area = { path = "widgets/text-area" }
|
llimphi-widget-text-area = { path = "widgets/text-area", version = "0.1.0" }
|
||||||
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core" }
|
llimphi-widget-text-editor-core = { path = "widgets/text-editor-core", version = "0.1.0" }
|
||||||
llimphi-widget-text-editor = { path = "widgets/text-editor" }
|
llimphi-widget-text-editor = { path = "widgets/text-editor", version = "0.1.0" }
|
||||||
llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp" }
|
llimphi-widget-text-editor-lsp = { path = "widgets/text-editor-lsp", version = "0.1.0" }
|
||||||
llimphi-widget-text-input = { path = "widgets/text-input" }
|
llimphi-widget-text-input = { path = "widgets/text-input", version = "0.1.0" }
|
||||||
llimphi-widget-tiled = { path = "widgets/tiled" }
|
llimphi-widget-tiled = { path = "widgets/tiled", version = "0.1.0" }
|
||||||
llimphi-widget-nodegraph = { path = "widgets/nodegraph" }
|
llimphi-widget-nodegraph = { path = "widgets/nodegraph", version = "0.1.0" }
|
||||||
llimphi-widget-tree = { path = "widgets/tree" }
|
llimphi-widget-tree = { path = "widgets/tree", version = "0.1.0" }
|
||||||
llimphi-widget-navigator = { path = "widgets/navigator" }
|
llimphi-widget-navigator = { path = "widgets/navigator", version = "0.1.0" }
|
||||||
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
|
# 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,
|
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
|
||||||
# modal, empty, status-bar, shortcuts-help, splash).
|
# modal, empty, status-bar, shortcuts-help, splash).
|
||||||
llimphi-widget-tooltip = { path = "widgets/tooltip" }
|
llimphi-widget-tooltip = { path = "widgets/tooltip", version = "0.1.0" }
|
||||||
llimphi-widget-spinner = { path = "widgets/spinner" }
|
llimphi-widget-spinner = { path = "widgets/spinner", version = "0.1.0" }
|
||||||
llimphi-widget-progress = { path = "widgets/progress" }
|
llimphi-widget-progress = { path = "widgets/progress", version = "0.1.0" }
|
||||||
llimphi-widget-toast = { path = "widgets/toast" }
|
llimphi-widget-toast = { path = "widgets/toast", version = "0.1.0" }
|
||||||
llimphi-widget-modal = { path = "widgets/modal" }
|
llimphi-widget-modal = { path = "widgets/modal", version = "0.1.0" }
|
||||||
llimphi-widget-empty = { path = "widgets/empty" }
|
llimphi-widget-empty = { path = "widgets/empty", version = "0.1.0" }
|
||||||
llimphi-widget-status-bar = { path = "widgets/status-bar" }
|
llimphi-widget-status-bar = { path = "widgets/status-bar", version = "0.1.0" }
|
||||||
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help" }
|
llimphi-widget-shortcuts-help = { path = "widgets/shortcuts-help", version = "0.1.0" }
|
||||||
llimphi-widget-timeline = { path = "widgets/timeline" }
|
llimphi-widget-timeline = { path = "widgets/timeline", version = "0.1.0" }
|
||||||
llimphi-widget-splash = { path = "widgets/splash" }
|
llimphi-widget-splash = { path = "widgets/splash", version = "0.1.0" }
|
||||||
# Controles de formulario y signaling (switch, segmented, breadcrumb,
|
# Controles de formulario y signaling (switch, segmented, breadcrumb,
|
||||||
# badge, avatar, skeleton, field).
|
# badge, avatar, skeleton, field).
|
||||||
llimphi-widget-switch = { path = "widgets/switch" }
|
llimphi-widget-switch = { path = "widgets/switch", version = "0.1.0" }
|
||||||
llimphi-widget-segmented = { path = "widgets/segmented" }
|
llimphi-widget-segmented = { path = "widgets/segmented", version = "0.1.0" }
|
||||||
llimphi-widget-dock-rail = { path = "widgets/dock-rail" }
|
llimphi-widget-dock-rail = { path = "widgets/dock-rail", version = "0.1.0" }
|
||||||
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb" }
|
llimphi-widget-breadcrumb = { path = "widgets/breadcrumb", version = "0.1.0" }
|
||||||
llimphi-widget-badge = { path = "widgets/badge" }
|
llimphi-widget-badge = { path = "widgets/badge", version = "0.1.0" }
|
||||||
llimphi-widget-avatar = { path = "widgets/avatar" }
|
llimphi-widget-avatar = { path = "widgets/avatar", version = "0.1.0" }
|
||||||
llimphi-widget-skeleton = { path = "widgets/skeleton" }
|
llimphi-widget-skeleton = { path = "widgets/skeleton", version = "0.1.0" }
|
||||||
llimphi-widget-field = { path = "widgets/field" }
|
llimphi-widget-field = { path = "widgets/field", version = "0.1.0" }
|
||||||
# Firma visual transversal (gradient sutil + hairline accent).
|
# Firma visual transversal (gradient sutil + hairline accent).
|
||||||
llimphi-widget-panel = { path = "widgets/panel" }
|
llimphi-widget-panel = { path = "widgets/panel", version = "0.1.0" }
|
||||||
llimphi-widget-panes = { path = "widgets/panes" }
|
llimphi-widget-panes = { path = "widgets/panes", version = "0.1.0" }
|
||||||
llimphi-workspace = { path = "llimphi-workspace" }
|
llimphi-workspace = { path = "llimphi-workspace", version = "0.1.0" }
|
||||||
# Abstracción Selector — host (paths) + wawa (khipus).
|
# 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 ===
|
# === Filesystem helpers ===
|
||||||
directories = "5"
|
directories = "5"
|
||||||
@@ -297,61 +302,61 @@ ttf-parser = "0.25"
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Intra-workspace deps de nahual (referenciadas por workspace = true)
|
# Intra-workspace deps de nahual (referenciadas por workspace = true)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
nahual-text-viewer-llimphi = { path = "02_ruway/nahual/nahual-text-viewer-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" }
|
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" }
|
nahual-thumb-core = { path = "02_ruway/nahual/nahual-thumb-core", version = "0.1.0" }
|
||||||
nahual-gallery-llimphi = { path = "02_ruway/nahual/nahual-gallery-llimphi" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
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" }
|
nahual-geo-core = { path = "02_ruway/nahual/nahual-geo-core", version = "0.1.0" }
|
||||||
nahual-viewer-core = { path = "02_ruway/nahual/nahual-viewer-core" }
|
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" }
|
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)
|
# Intra-workspace deps de pineal (módulo de gráficos)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
pineal-core = { path = "00_unanchay/pineal/pineal-core" }
|
pineal-core = { path = "00_unanchay/pineal/pineal-core", version = "0.1.0" }
|
||||||
pineal-render = { path = "00_unanchay/pineal/pineal-render" }
|
pineal-render = { path = "00_unanchay/pineal/pineal-render", version = "0.1.0" }
|
||||||
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian" }
|
pineal-cartesian = { path = "00_unanchay/pineal/pineal-cartesian", version = "0.1.0" }
|
||||||
pineal-stream = { path = "00_unanchay/pineal/pineal-stream" }
|
pineal-stream = { path = "00_unanchay/pineal/pineal-stream", version = "0.1.0" }
|
||||||
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh" }
|
pineal-mesh = { path = "00_unanchay/pineal/pineal-mesh", version = "0.1.0" }
|
||||||
pineal-financial = { path = "00_unanchay/pineal/pineal-financial" }
|
pineal-financial = { path = "00_unanchay/pineal/pineal-financial", version = "0.1.0" }
|
||||||
pineal-polar = { path = "00_unanchay/pineal/pineal-polar" }
|
pineal-polar = { path = "00_unanchay/pineal/pineal-polar", version = "0.1.0" }
|
||||||
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap" }
|
pineal-heatmap = { path = "00_unanchay/pineal/pineal-heatmap", version = "0.1.0" }
|
||||||
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap" }
|
pineal-treemap = { path = "00_unanchay/pineal/pineal-treemap", version = "0.1.0" }
|
||||||
pineal-flow = { path = "00_unanchay/pineal/pineal-flow" }
|
pineal-flow = { path = "00_unanchay/pineal/pineal-flow", version = "0.1.0" }
|
||||||
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor" }
|
pineal-phosphor = { path = "00_unanchay/pineal/pineal-phosphor", version = "0.1.0" }
|
||||||
pineal-export = { path = "00_unanchay/pineal/pineal-export" }
|
pineal-export = { path = "00_unanchay/pineal/pineal-export", version = "0.1.0" }
|
||||||
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin" }
|
pineal-hexbin = { path = "00_unanchay/pineal/pineal-hexbin", version = "0.1.0" }
|
||||||
pineal-contour = { path = "00_unanchay/pineal/pineal-contour" }
|
pineal-contour = { path = "00_unanchay/pineal/pineal-contour", version = "0.1.0" }
|
||||||
pineal-bars = { path = "00_unanchay/pineal/pineal-bars" }
|
pineal-bars = { path = "00_unanchay/pineal/pineal-bars", version = "0.1.0" }
|
||||||
pineal = { path = "00_unanchay/pineal/pineal-umbrella" }
|
pineal = { path = "00_unanchay/pineal/pineal-umbrella", version = "0.1.0" }
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
|
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
iniy-core = { path = "01_yachay/iniy/iniy-core" }
|
iniy-core = { path = "01_yachay/iniy/iniy-core", version = "0.1.0" }
|
||||||
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest" }
|
iniy-ingest = { path = "01_yachay/iniy/iniy-ingest", version = "0.1.0" }
|
||||||
iniy-extract = { path = "01_yachay/iniy/iniy-extract" }
|
iniy-extract = { path = "01_yachay/iniy/iniy-extract", version = "0.1.0" }
|
||||||
iniy-nli = { path = "01_yachay/iniy/iniy-nli" }
|
iniy-nli = { path = "01_yachay/iniy/iniy-nli", version = "0.1.0" }
|
||||||
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm" }
|
iniy-nli-llm = { path = "01_yachay/iniy/iniy-nli-llm", version = "0.1.0" }
|
||||||
iniy-graph = { path = "01_yachay/iniy/iniy-graph" }
|
iniy-graph = { path = "01_yachay/iniy/iniy-graph", version = "0.1.0" }
|
||||||
iniy-store = { path = "01_yachay/iniy/iniy-store" }
|
iniy-store = { path = "01_yachay/iniy/iniy-store", version = "0.1.0" }
|
||||||
|
|
||||||
# === auto: declarados por crates internos faltantes ===
|
# === auto: declarados por crates internos faltantes ===
|
||||||
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords" }
|
cosmos-coords = { path = "01_yachay/cosmos/cosmos-coords", version = "0.1.0" }
|
||||||
cosmos-core = { path = "01_yachay/cosmos/cosmos-core" }
|
cosmos-core = { path = "01_yachay/cosmos/cosmos-core", version = "0.1.0" }
|
||||||
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris" }
|
cosmos-ephemeris = { path = "01_yachay/cosmos/cosmos-ephemeris", version = "0.1.0" }
|
||||||
cosmos-time = { path = "01_yachay/cosmos/cosmos-time" }
|
cosmos-time = { path = "01_yachay/cosmos/cosmos-time", version = "0.1.0" }
|
||||||
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs" }
|
cosmos-wcs = { path = "01_yachay/cosmos/cosmos-wcs", version = "0.1.0" }
|
||||||
|
|
||||||
# === auto: externas de eternal ===
|
# === auto: externas de eternal ===
|
||||||
celestial-eop-data = { version = "0.1"}
|
celestial-eop-data = { version = "0.1"}
|
||||||
|
|||||||
@@ -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!
|
||||||
@@ -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
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
# llimphi
|
# 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">
|
<p align="center">
|
||||||
<img src="docs/counter.gif" alt="counter example — the full Elm loop in ~124 LOC" width="480">
|
<img src="docs/counter.gif" alt="counter example — the full Elm loop in ~124 LOC" width="480">
|
||||||
<br>
|
<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>
|
</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).
|
**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.**
|
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
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://gitea.gioser.net/sergio/llimphi.git
|
git clone https://git.tawasuyu.net/tawasuyu/llimphi.git
|
||||||
cd llimphi
|
cd llimphi
|
||||||
cargo run -p llimphi-ui --example counter # ~124 LOC: the full Elm loop on screen
|
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
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-ui = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
|
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
|
||||||
llimphi-theme = { git = "https://gitea.gioser.net/sergio/llimphi.git" }
|
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/llimphi.git" }
|
||||||
# widgets are one crate each — pull only what you use:
|
# 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
|
## Compatibility
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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."
|
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
|
# 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"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[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
|
# Activamos el feature de NativeActivity en winit para que linkee con la
|
||||||
# clase NativeActivity del NDK y reciba eventos de surface/input desde la
|
# clase NativeActivity del NDK y reciba eventos de surface/input desde la
|
||||||
# Activity Java/Kotlin generada por android-activity.
|
# Activity Java/Kotlin generada por android-activity.
|
||||||
|
|||||||
@@ -5,14 +5,15 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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."
|
description = "Tier 1.5 Android: vello + llimphi-raster pintando una chacana animada como smoke test del stack completo."
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../../llimphi-hal" }
|
llimphi-hal = { path = "../../llimphi-hal", version = "0.1.0" }
|
||||||
llimphi-raster = { path = "../../llimphi-raster" }
|
llimphi-raster = { path = "../../llimphi-raster", version = "0.1.0" }
|
||||||
winit = { workspace = true, features = ["android-native-activity"] }
|
winit = { workspace = true, features = ["android-native-activity"] }
|
||||||
wgpu.workspace = true
|
wgpu.workspace = true
|
||||||
vello.workspace = true
|
vello.workspace = true
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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."
|
description = "Tier 1.75 Android: parley + vello + llimphi-text rasterizando texto multi-script con fallback CJK/Arabic via fontique."
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib"]
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../../llimphi-hal" }
|
llimphi-hal = { path = "../../llimphi-hal", version = "0.1.0" }
|
||||||
llimphi-raster = { path = "../../llimphi-raster" }
|
llimphi-raster = { path = "../../llimphi-raster", version = "0.1.0" }
|
||||||
llimphi-text = { path = "../../llimphi-text" }
|
llimphi-text = { path = "../../llimphi-text", version = "0.1.0" }
|
||||||
winit = { workspace = true, features = ["android-native-activity"] }
|
winit = { workspace = true, features = ["android-native-activity"] }
|
||||||
wgpu.workspace = true
|
wgpu.workspace = true
|
||||||
vello.workspace = true
|
vello.workspace = true
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
@@ -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" }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
"#;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -5,12 +5,31 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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)."
|
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]
|
[dependencies]
|
||||||
llimphi-layout = { path = "../llimphi-layout" }
|
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
|
||||||
llimphi-text = { path = "../llimphi-text" }
|
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
|
||||||
vello = { workspace = true }
|
vello = { workspace = true }
|
||||||
# Sólo para los tipos de la firma de GpuPaintFn (Device/Queue/Encoder/View).
|
# 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 NO depende de winit — el compositor sigue libre de windowing.
|
||||||
wgpu = { workspace = true }
|
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" }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! Filmstrip headless de la **familia `filter`** (Fases 7.1232–7.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, ®las);
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 (82–100%) ─────────────────────────────────────────
|
||||||
|
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 (58–80%).
|
||||||
|
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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
+1476
-87
File diff suppressed because it is too large
Load Diff
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1350
-5
File diff suppressed because it is too large
Load Diff
@@ -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");
|
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]
|
#[test]
|
||||||
fn line_height_mayor_reserva_mas_alto() {
|
fn line_height_mayor_reserva_mas_alto() {
|
||||||
let texto = "una línea de texto que envuelve en dos o tres renglones según \
|
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,
|
italic: false,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
line_height: lh,
|
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 known = TSize { width: Some(180.0_f32), height: None };
|
||||||
let avail = TSize {
|
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})"
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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`."
|
description = "llimphi-gallery — demo único que prueba el kit transversal de elegancia. Binario standalone; `cargo run -p llimphi-gallery --release`."
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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."
|
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]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../llimphi-hal" }
|
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
|
||||||
llimphi-raster = { path = "../llimphi-raster" }
|
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
|
||||||
vello = { workspace = true }
|
vello = { workspace = true }
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
png = { workspace = true }
|
png = { workspace = true }
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-hal"
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ impl ApplicationHandler for App {
|
|||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: frame.view(),
|
view: frame.view(),
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(LEAD_GRAY),
|
load: wgpu::LoadOp::Clear(LEAD_GRAY),
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
|
|||||||
+771
-20
@@ -145,10 +145,13 @@ impl Hal {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let (instance, adapter) = match primary.request_adapter(&opts).await {
|
let (instance, adapter) = match primary.request_adapter(&opts).await {
|
||||||
Some(a) => (primary, a),
|
Ok(a) => (primary, a),
|
||||||
None => {
|
Err(_) => {
|
||||||
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
|
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)
|
(all, a)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -158,15 +161,14 @@ impl Hal {
|
|||||||
// (texturas/buffers grandes) preservando los conteos mínimos.
|
// (texturas/buffers grandes) preservando los conteos mínimos.
|
||||||
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
|
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
.request_device(
|
.request_device(&wgpu::DeviceDescriptor {
|
||||||
&wgpu::DeviceDescriptor {
|
|
||||||
label: Some("llimphi-hal-device"),
|
label: Some("llimphi-hal-device"),
|
||||||
required_features: wgpu::Features::empty(),
|
required_features: wgpu::Features::empty(),
|
||||||
required_limits: limits,
|
required_limits: limits,
|
||||||
memory_hints: wgpu::MemoryHints::Performance,
|
memory_hints: wgpu::MemoryHints::Performance,
|
||||||
},
|
experimental_features: wgpu::ExperimentalFeatures::default(),
|
||||||
None,
|
trace: wgpu::Trace::Off,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
|
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -220,8 +222,8 @@ impl Hal {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let (instance, adapter, wgpu_surface) = match prim_adapter {
|
let (instance, adapter, wgpu_surface) = match prim_adapter {
|
||||||
Some(a) => (primary, a, prim_surface),
|
Ok(a) => (primary, a, prim_surface),
|
||||||
None => {
|
Err(_) => {
|
||||||
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
|
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
|
||||||
let surface = unsafe { all.create_surface_unsafe(make_target()) }
|
let surface = unsafe { all.create_surface_unsafe(make_target()) }
|
||||||
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
|
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
|
||||||
@@ -232,21 +234,20 @@ impl Hal {
|
|||||||
compatible_surface: Some(&surface),
|
compatible_surface: Some(&surface),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.ok_or(HalError::NoAdapter)?;
|
.map_err(|_| HalError::NoAdapter)?;
|
||||||
(all, a, surface)
|
(all, a, surface)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
|
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
.request_device(
|
.request_device(&wgpu::DeviceDescriptor {
|
||||||
&wgpu::DeviceDescriptor {
|
|
||||||
label: Some("llimphi-hal-device"),
|
label: Some("llimphi-hal-device"),
|
||||||
required_features: wgpu::Features::empty(),
|
required_features: wgpu::Features::empty(),
|
||||||
required_limits: limits,
|
required_limits: limits,
|
||||||
memory_hints: wgpu::MemoryHints::Performance,
|
memory_hints: wgpu::MemoryHints::Performance,
|
||||||
},
|
experimental_features: wgpu::ExperimentalFeatures::default(),
|
||||||
None,
|
trace: wgpu::Trace::Off,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
|
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
|
||||||
let hal = Self {
|
let hal = Self {
|
||||||
@@ -403,11 +404,31 @@ impl RawSurface {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let alpha_mode = caps
|
// Para una layer surface (wlr-layer-shell) la transparencia es
|
||||||
.alpha_modes
|
// crítica: la usamos para popovers/menús que pintan un panel chico y
|
||||||
.first()
|
// 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()
|
.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 {
|
let config = wgpu::SurfaceConfiguration {
|
||||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||||
format,
|
format,
|
||||||
@@ -719,6 +740,7 @@ impl OverlayCompositor {
|
|||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: target,
|
view: target,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Load,
|
load: wgpu::LoadOp::Load,
|
||||||
store: wgpu::StoreOp::Store,
|
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 {
|
impl Surface for WinitSurface {
|
||||||
fn size(&self) -> (u32, u32) {
|
fn size(&self) -> (u32, u32) {
|
||||||
(self.config.width, self.config.height)
|
(self.config.width, self.config.height)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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]
|
[dependencies]
|
||||||
llimphi-ui = { workspace = true }
|
llimphi-ui = { workspace = true }
|
||||||
|
|||||||
@@ -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
|
//! 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
|
//! con su nombre debajo. Sirve para eyeballear de un vistazo que el set
|
||||||
|
|||||||
@@ -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
|
//! 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**.
|
//! 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::llimphi_raster::peniko::Color;
|
||||||
use llimphi_ui::View;
|
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`.
|
/// con el `id` del `AppEntry` en `app-bus`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AppIcon {
|
pub enum AppIcon {
|
||||||
|
|||||||
@@ -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
|
//! 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
|
//! 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
|
//! no "marca registrada". Cada uno debe ser reconocible al primer
|
||||||
//! vistazo aún en 12×12.
|
//! vistazo aún en 12×12.
|
||||||
//! - **Set acotado**: suficientes para cubrir el grueso de acciones y
|
//! - **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
|
//! más, lo agrega aquí (no en su propio crate) — la consistencia
|
||||||
//! visual importa más que el aislamiento.
|
//! visual importa más que el aislamiento.
|
||||||
//!
|
//!
|
||||||
@@ -115,6 +115,15 @@ pub enum Icon {
|
|||||||
FileText,
|
FileText,
|
||||||
Link,
|
Link,
|
||||||
Font,
|
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 {
|
impl Icon {
|
||||||
@@ -169,6 +178,10 @@ impl Icon {
|
|||||||
Icon::FileText => "file_text",
|
Icon::FileText => "file_text",
|
||||||
Icon::Link => "link",
|
Icon::Link => "link",
|
||||||
Icon::Font => "font",
|
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::FileText => path_file_text(),
|
||||||
Icon::Link => path_link(),
|
Icon::Link => path_link(),
|
||||||
Icon::Font => path_font(),
|
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
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-layout"
|
name = "llimphi-layout"
|
||||||
|
description = "Layout engine for llimphi (Flexbox + CSS Grid via taffy)."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
taffy = { workspace = true }
|
taffy = { workspace = true }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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."
|
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]
|
[dependencies]
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ mod tests {
|
|||||||
fn lerp_color_endpoints() {
|
fn lerp_color_endpoints() {
|
||||||
let a = Color::from_rgba8(0, 0, 0, 0);
|
let a = Color::from_rgba8(0, 0, 0, 0);
|
||||||
let b = Color::from_rgba8(255, 255, 255, 255);
|
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;
|
let [r, g, bl, al] = mid.components;
|
||||||
assert!((r - 0.5).abs() < 1e-3);
|
assert!((r - 0.5).abs() < 1e-3);
|
||||||
assert!((g - 0.5).abs() < 1e-3);
|
assert!((g - 0.5).abs() < 1e-3);
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-raster"
|
name = "llimphi-raster"
|
||||||
|
description = "2D GPU rasterizer for llimphi over vello, with an opt-in CPU+GPU hybrid renderer."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../llimphi-hal" }
|
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
|
||||||
vello = { workspace = true }
|
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 }
|
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]]
|
[[example]]
|
||||||
name = "render_node"
|
name = "render_node"
|
||||||
path = "examples/render_node.rs"
|
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),
|
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
);
|
);
|
||||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
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;
|
let dt = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
if frame >= WARMUP {
|
if frame >= WARMUP {
|
||||||
samples.push(dt);
|
samples.push(dt);
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ fn bench_vello(
|
|||||||
.expect("vello render");
|
.expect("vello render");
|
||||||
// Bloquear hasta que la GPU termine este frame. Sin esto medimos
|
// Bloquear hasta que la GPU termine este frame. Sin esto medimos
|
||||||
// sólo el submit + queue building, no el trabajo real.
|
// 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;
|
let dt = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
if frame >= WARMUP_FRAMES {
|
if frame >= WARMUP_FRAMES {
|
||||||
samples.push(dt);
|
samples.push(dt);
|
||||||
@@ -233,6 +233,7 @@ fn bench_directo(
|
|||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: target,
|
view: target,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
@@ -248,7 +249,7 @@ fn bench_directo(
|
|||||||
pass.draw(0..6, 0..points.len() as u32);
|
pass.draw(0..6, 0..points.len() as u32);
|
||||||
}
|
}
|
||||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
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;
|
let dt = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
if frame >= WARMUP_FRAMES {
|
if frame >= WARMUP_FRAMES {
|
||||||
samples.push(dt);
|
samples.push(dt);
|
||||||
|
|||||||
+433
-14
@@ -1,7 +1,7 @@
|
|||||||
//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu").
|
//! Backend GPU directo (Fases 2 + 3 del SDD §"GPU directo wgpu").
|
||||||
//!
|
//!
|
||||||
//! Tres pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
|
//! Cuatro pipelines `wgpu` cacheadas en [`GpuPipelines`] (lines / tris /
|
||||||
//! rects) + un acumulador [`GpuBatch`] que las apps usan por frame para
|
//! rects / discs) + un acumulador [`GpuBatch`] que las apps usan por frame para
|
||||||
//! emitir centenares de miles a millones de primitivos en una draw call
|
//! emitir centenares de miles a millones de primitivos en una draw call
|
||||||
//! por tipo, sin pasar por vello.
|
//! por tipo, sin pasar por vello.
|
||||||
//!
|
//!
|
||||||
@@ -10,8 +10,11 @@
|
|||||||
//! - Vertex format triángulos: `[x: f32, y: f32, rgba: u32]` (12 B/vert).
|
//! - 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 líneas: `[x0, y0, x1, y1, rgba]` (20 B/seg).
|
||||||
//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect).
|
//! - Instance format rects: `[x, y, w, h, rgba]` (20 B/rect).
|
||||||
//! - Sin texturas. Sin AA por shader — quien necesite AA fino sigue por
|
//! - Instance format discos: `[cx, cy, r, stroke, rgba]` (20 B/disco).
|
||||||
//! vello. Para puntos densos el "popping" no se nota.
|
//! - 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.
|
//! - Blending alfa habilitado: el alpha del color es respetado.
|
||||||
//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
|
//! - El viewport `(width, height)` se pasa al flush y va en un uniform —
|
||||||
//! los shaders convierten pixel → NDC ahí.
|
//! los shaders convierten pixel → NDC ahí.
|
||||||
@@ -25,10 +28,36 @@
|
|||||||
//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
|
//! mismo frame. Sin reuso entre frames — Fase 4 (`GpuSceneCanvas`)
|
||||||
//! introducirá el `GpuBuffers` persistente que dobla capacidad si
|
//! introducirá el `GpuBuffers` persistente que dobla capacidad si
|
||||||
//! aparece la necesidad.
|
//! 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 llimphi_hal::wgpu;
|
||||||
use vello::peniko::Color;
|
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).
|
/// Pipelines cacheadas. Crear uno por proceso (o por surface format).
|
||||||
///
|
///
|
||||||
/// Para uso típico via [`GpuBatch`] los campos no se tocan directo. La
|
/// 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 lines: wgpu::RenderPipeline,
|
||||||
pub tris: wgpu::RenderPipeline,
|
pub tris: wgpu::RenderPipeline,
|
||||||
pub rects: 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,
|
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 {
|
impl GpuPipelines {
|
||||||
@@ -112,7 +155,10 @@ impl GpuPipelines {
|
|||||||
},
|
},
|
||||||
primitive: tri_primitive(),
|
primitive: tri_primitive(),
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: MSAA_SAMPLES,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("fs"),
|
entry_point: Some("fs"),
|
||||||
@@ -155,7 +201,10 @@ impl GpuPipelines {
|
|||||||
},
|
},
|
||||||
primitive: tri_primitive(),
|
primitive: tri_primitive(),
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: MSAA_SAMPLES,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("fs"),
|
entry_point: Some("fs"),
|
||||||
@@ -195,7 +244,10 @@ impl GpuPipelines {
|
|||||||
},
|
},
|
||||||
primitive: tri_primitive(),
|
primitive: tri_primitive(),
|
||||||
depth_stencil: None,
|
depth_stencil: None,
|
||||||
multisample: wgpu::MultisampleState::default(),
|
multisample: wgpu::MultisampleState {
|
||||||
|
count: MSAA_SAMPLES,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
fragment: Some(wgpu::FragmentState {
|
fragment: Some(wgpu::FragmentState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("fs"),
|
entry_point: Some("fs"),
|
||||||
@@ -206,11 +258,142 @@ impl GpuPipelines {
|
|||||||
cache: None,
|
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 {
|
Self {
|
||||||
lines,
|
lines,
|
||||||
tris,
|
tris,
|
||||||
rects,
|
rects,
|
||||||
|
discs,
|
||||||
bind_layout,
|
bind_layout,
|
||||||
|
composite,
|
||||||
|
composite_bgl,
|
||||||
|
composite_sampler,
|
||||||
|
color_format,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,10 +416,12 @@ pub struct GpuBatch<'a> {
|
|||||||
line_verts: Vec<u8>,
|
line_verts: Vec<u8>,
|
||||||
tri_verts: Vec<u8>,
|
tri_verts: Vec<u8>,
|
||||||
rect_insts: Vec<u8>,
|
rect_insts: Vec<u8>,
|
||||||
|
disc_insts: Vec<u8>,
|
||||||
line_width: f32,
|
line_width: f32,
|
||||||
line_count: u32,
|
line_count: u32,
|
||||||
tri_vert_count: u32,
|
tri_vert_count: u32,
|
||||||
rect_count: u32,
|
rect_count: u32,
|
||||||
|
disc_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> GpuBatch<'a> {
|
impl<'a> GpuBatch<'a> {
|
||||||
@@ -246,10 +431,12 @@ impl<'a> GpuBatch<'a> {
|
|||||||
line_verts: Vec::new(),
|
line_verts: Vec::new(),
|
||||||
tri_verts: Vec::new(),
|
tri_verts: Vec::new(),
|
||||||
rect_insts: Vec::new(),
|
rect_insts: Vec::new(),
|
||||||
|
disc_insts: Vec::new(),
|
||||||
line_width: 1.0,
|
line_width: 1.0,
|
||||||
line_count: 0,
|
line_count: 0,
|
||||||
tri_vert_count: 0,
|
tri_vert_count: 0,
|
||||||
rect_count: 0,
|
rect_count: 0,
|
||||||
|
disc_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +513,37 @@ impl<'a> GpuBatch<'a> {
|
|||||||
self.rect_count += 1;
|
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).
|
/// Cuenta total de primitivas pendientes (útil para benches).
|
||||||
pub fn primitive_count(&self) -> u32 {
|
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
|
/// Despacha las primitivas acumuladas como 1 draw call por tipo
|
||||||
@@ -348,7 +563,8 @@ impl<'a> GpuBatch<'a> {
|
|||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
load_op: wgpu::LoadOp<wgpu::Color>,
|
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 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -407,14 +623,71 @@ impl<'a> GpuBatch<'a> {
|
|||||||
queue.write_buffer(&b, 0, &self.rect_insts);
|
queue.write_buffer(&b, 0, &self.rect_insts);
|
||||||
b
|
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 {
|
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some("llimphi-raster-gpu-pass"),
|
label: Some("llimphi-raster-gpu-pass"),
|
||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view,
|
view: &msaa_view,
|
||||||
resolve_target: None,
|
resolve_target: Some(&resolve_view),
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: load_op,
|
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
},
|
},
|
||||||
})],
|
})],
|
||||||
@@ -424,13 +697,18 @@ impl<'a> GpuBatch<'a> {
|
|||||||
});
|
});
|
||||||
pass.set_bind_group(0, &bind_group, &[]);
|
pass.set_bind_group(0, &bind_group, &[]);
|
||||||
|
|
||||||
// Orden de draws: rects (fondo) → tris → lines (encima). Match
|
// Orden de draws: rects (fondo) → discos → tris → lines (encima).
|
||||||
// de la convención usual "fill abajo, stroke arriba".
|
// Match de la convención usual "fill abajo, stroke arriba".
|
||||||
if let Some(buf) = rects_buf.as_ref() {
|
if let Some(buf) = rects_buf.as_ref() {
|
||||||
pass.set_pipeline(&self.pipelines.rects);
|
pass.set_pipeline(&self.pipelines.rects);
|
||||||
pass.set_vertex_buffer(0, buf.slice(..));
|
pass.set_vertex_buffer(0, buf.slice(..));
|
||||||
pass.draw(0..6, 0..self.rect_count);
|
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() {
|
if let Some(buf) = tris_buf.as_ref() {
|
||||||
pass.set_pipeline(&self.pipelines.tris);
|
pass.set_pipeline(&self.pipelines.tris);
|
||||||
pass.set_vertex_buffer(0, buf.slice(..));
|
pass.set_vertex_buffer(0, buf.slice(..));
|
||||||
@@ -442,6 +720,51 @@ impl<'a> GpuBatch<'a> {
|
|||||||
pass.draw(0..6, 0..self.line_count);
|
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.
|
/// Empaqueta un `peniko::Color` a u32 little-endian RGBA8.
|
||||||
@@ -546,8 +869,104 @@ fn vs_lines(
|
|||||||
return out;
|
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
|
@fragment
|
||||||
fn fs(in: V2F) -> @location(0) vec4<f32> {
|
fn fs(in: V2F) -> @location(0) vec4<f32> {
|
||||||
return in.color;
|
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);
|
||||||
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ use llimphi_hal::{Frame, Hal};
|
|||||||
pub use vello;
|
pub use vello;
|
||||||
pub use vello::kurbo;
|
pub use vello::kurbo;
|
||||||
pub use vello::peniko;
|
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 mod gpu;
|
||||||
pub use gpu::{GpuBatch, GpuPipelines};
|
pub use gpu::{GpuBatch, GpuPipelines};
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ fn batch_with_rects_lines_tris_does_not_panic() {
|
|||||||
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||||
);
|
);
|
||||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
hal.queue.submit(std::iter::once(encoder.finish()));
|
||||||
hal.device.poll(wgpu::Maintain::Wait);
|
hal.device.poll(wgpu::PollType::wait_indefinitely());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -124,5 +124,5 @@ fn empty_batch_flush_is_no_op() {
|
|||||||
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||||
);
|
);
|
||||||
hal.queue.submit(std::iter::once(encoder.finish()));
|
hal.queue.submit(std::iter::once(encoder.finish()));
|
||||||
hal.device.poll(wgpu::Maintain::Wait);
|
hal.device.poll(wgpu::PollType::wait_indefinitely());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-surface"
|
name = "llimphi-surface"
|
||||||
|
description = "Surface/compositing glue for the llimphi UI framework."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../llimphi-hal" }
|
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
|
||||||
llimphi-ui = { path = "../llimphi-ui" }
|
llimphi-ui = { path = "../llimphi-ui", version = "0.1.0" }
|
||||||
parking_lot = { workspace = true }
|
parking_lot = { workspace = true }
|
||||||
|
|||||||
+63
-12
@@ -150,10 +150,13 @@ impl ExternalSurface {
|
|||||||
..Default::default()
|
..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 {
|
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("llimphi-surface-uniforms"),
|
label: Some("llimphi-surface-uniforms"),
|
||||||
size: 32,
|
size: 48,
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
mapped_at_creation: false,
|
mapped_at_creation: false,
|
||||||
});
|
});
|
||||||
@@ -237,19 +240,54 @@ impl ExternalSurface {
|
|||||||
dst_view: &wgpu::TextureView,
|
dst_view: &wgpu::TextureView,
|
||||||
rect: PaintRect,
|
rect: PaintRect,
|
||||||
viewport: (u32, u32),
|
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 inner = self.inner.lock();
|
||||||
let uniforms = [
|
let uniforms = [
|
||||||
rect.x,
|
dst[0],
|
||||||
rect.y,
|
dst[1],
|
||||||
rect.w,
|
dst[2],
|
||||||
rect.h,
|
dst[3],
|
||||||
viewport.0 as f32,
|
viewport.0 as f32,
|
||||||
viewport.1 as f32,
|
viewport.1 as f32,
|
||||||
0.0,
|
0.0,
|
||||||
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() {
|
for (i, v) in uniforms.iter().enumerate() {
|
||||||
bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
|
bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
|
||||||
}
|
}
|
||||||
@@ -260,6 +298,7 @@ impl ExternalSurface {
|
|||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view: dst_view,
|
view: dst_view,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
load: wgpu::LoadOp::Load,
|
load: wgpu::LoadOp::Load,
|
||||||
store: wgpu::StoreOp::Store,
|
store: wgpu::StoreOp::Store,
|
||||||
@@ -271,6 +310,16 @@ impl ExternalSurface {
|
|||||||
});
|
});
|
||||||
pass.set_pipeline(&inner.pipeline);
|
pass.set_pipeline(&inner.pipeline);
|
||||||
pass.set_bind_group(0, &inner.bind_group, &[]);
|
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);
|
pass.draw(0..6, 0..1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,8 +405,9 @@ fn make_texture_and_bg(
|
|||||||
|
|
||||||
const WGSL: &str = r#"
|
const WGSL: &str = r#"
|
||||||
struct Uniforms {
|
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, _, _
|
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;
|
@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>(1.0, 1.0),
|
||||||
vec2<f32>(0.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 px = u.rect.x + q.x * u.rect.z;
|
||||||
let py = u.rect.y + uv.y * u.rect.w;
|
let py = u.rect.y + q.y * u.rect.w;
|
||||||
|
|
||||||
// NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
|
// NDC: x ∈ [-1, 1] sin flip, y flipeado (en pantalla y-down).
|
||||||
let ndc = vec2<f32>(
|
let ndc = vec2<f32>(
|
||||||
@@ -393,7 +444,7 @@ fn vs(@builtin(vertex_index) vid: u32) -> V2F {
|
|||||||
|
|
||||||
var out: V2F;
|
var out: V2F;
|
||||||
out.pos = vec4<f32>(ndc, 0.0, 1.0);
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-text"
|
name = "llimphi-text"
|
||||||
|
description = "Text shaping for llimphi over parley (Inter default, DejaVu Sans symbol fallback)."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
# vello directo (no llimphi-raster): el motor de texto sólo necesita
|
# vello directo (no llimphi-raster): el motor de texto sólo necesita
|
||||||
# Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni
|
# Scene/peniko/kurbo para construir y pintar layouts — nada del Renderer ni
|
||||||
|
|||||||
@@ -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.
@@ -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
@@ -21,8 +21,128 @@ pub struct Typesetter {
|
|||||||
/// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
|
/// brush genérico de parley no puede ser `()` y `RunBrush` a la vez en
|
||||||
/// el mismo `LayoutContext`, así que mantenemos uno por sabor.
|
/// el mismo `LayoutContext`, así que mantenemos uno por sabor.
|
||||||
runs_cx: parley::LayoutContext<RunBrush>,
|
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 {
|
impl Default for Typesetter {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -40,14 +160,85 @@ impl Default for Typesetter {
|
|||||||
/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
|
/// Licencia: Bitstream Vera + Arev (libre, redistribuible).
|
||||||
const DEJAVU_SANS: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
|
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 {
|
impl Typesetter {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut font_cx = parley::FontContext::new();
|
let mut font_cx = parley::FontContext::new();
|
||||||
|
Self::install_ui_font(&mut font_cx);
|
||||||
Self::install_symbol_fallback(&mut font_cx);
|
Self::install_symbol_fallback(&mut font_cx);
|
||||||
|
Self::install_monospace(&mut font_cx);
|
||||||
Self {
|
Self {
|
||||||
font_cx,
|
font_cx,
|
||||||
layout_cx: parley::LayoutContext::new(),
|
layout_cx: parley::LayoutContext::new(),
|
||||||
runs_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
|
/// 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 {
|
pub fn font_context_mut(&mut self) -> &mut parley::FontContext {
|
||||||
|
self.cache.clear();
|
||||||
&mut self.font_cx
|
&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`,
|
/// Construye y resuelve un `parley::Layout`. Aplica `font_size`,
|
||||||
/// `line_height` (multiplicador del font_size), `max_width` (line
|
/// `line_height` (multiplicador del font_size), `max_width` (line
|
||||||
/// break), y `alignment`. `italic`=true selecciona la variante
|
/// break), `alignment` y `weight` (peso de fuente CSS: 400 normal,
|
||||||
/// italic/oblique de la fuente activa (vía `parley::FontStyle`).
|
/// 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(
|
pub fn layout(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -87,12 +313,77 @@ impl Typesetter {
|
|||||||
line_height: f32,
|
line_height: f32,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
font_family: Option<&str>,
|
font_family: Option<&str>,
|
||||||
|
weight: f32,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
letter_spacing: f32,
|
||||||
|
word_spacing: f32,
|
||||||
) -> parley::Layout<()> {
|
) -> 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 =
|
let mut builder =
|
||||||
self.layout_cx
|
self.layout_cx
|
||||||
.ranged_builder(&mut self.font_cx, text, 1.0, true);
|
.ranged_builder(&mut self.font_cx, text, 1.0, true);
|
||||||
builder.push_default(parley::StyleProperty::FontSize(size_px));
|
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 {
|
if italic {
|
||||||
builder.push_default(parley::StyleProperty::FontStyle(
|
builder.push_default(parley::StyleProperty::FontStyle(
|
||||||
parley::FontStyle::Italic,
|
parley::FontStyle::Italic,
|
||||||
@@ -105,6 +396,30 @@ impl Typesetter {
|
|||||||
parley::FontStack::Source(std::borrow::Cow::Borrowed(ff)),
|
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);
|
let mut layout = builder.build(text);
|
||||||
layout.break_all_lines(max_width);
|
layout.break_all_lines(max_width);
|
||||||
layout.align(
|
layout.align(
|
||||||
@@ -112,15 +427,96 @@ impl Typesetter {
|
|||||||
alignment.into(),
|
alignment.into(),
|
||||||
parley::AlignmentOptions::default(),
|
parley::AlignmentOptions::default(),
|
||||||
);
|
);
|
||||||
|
self.cache.put(key, layout.clone());
|
||||||
layout
|
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:
|
/// Construye un layout **multicolor** en una sola pasada de shaping:
|
||||||
/// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
|
/// `default_color` cubre todo el texto y cada `(start_byte, end_byte,
|
||||||
/// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
|
/// color)` lo sobreescribe en su rango (offsets en **bytes**, no chars —
|
||||||
/// la convención de parley). Pensado para syntax highlighting: shapear
|
/// 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
|
/// 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.
|
/// por token. Sin wrap (`max_width = None`); el caller posiciona la línea.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn layout_runs(
|
pub fn layout_runs(
|
||||||
&mut self,
|
&mut self,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -129,13 +525,29 @@ impl Typesetter {
|
|||||||
runs: &[(usize, usize, Color)],
|
runs: &[(usize, usize, Color)],
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
line_height: f32,
|
line_height: f32,
|
||||||
|
weight: f32,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
) -> parley::Layout<RunBrush> {
|
) -> parley::Layout<RunBrush> {
|
||||||
let mut builder = self
|
let mut builder = self
|
||||||
.runs_cx
|
.runs_cx
|
||||||
.ranged_builder(&mut self.font_cx, text, 1.0, true);
|
.ranged_builder(&mut self.font_cx, text, 1.0, true);
|
||||||
builder.push_default(parley::StyleProperty::FontSize(size_px));
|
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)));
|
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();
|
let len = text.len();
|
||||||
for &(start, end, color) in runs {
|
for &(start, end, color) in runs {
|
||||||
if start < end && end <= len {
|
if start < end && end <= len {
|
||||||
@@ -147,6 +559,118 @@ impl Typesetter {
|
|||||||
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
|
layout.align(None, alignment.into(), parley::AlignmentOptions::default());
|
||||||
layout
|
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
|
/// 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.
|
/// Alineación horizontal del bloque dentro de su ancho máximo.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Alignment {
|
pub enum Alignment {
|
||||||
@@ -175,9 +740,9 @@ impl From<Alignment> for parley::Alignment {
|
|||||||
fn from(a: Alignment) -> Self {
|
fn from(a: Alignment) -> Self {
|
||||||
match a {
|
match a {
|
||||||
Alignment::Start => parley::Alignment::Start,
|
Alignment::Start => parley::Alignment::Start,
|
||||||
Alignment::Center => parley::Alignment::Middle,
|
Alignment::Center => parley::Alignment::Center,
|
||||||
Alignment::End => parley::Alignment::End,
|
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.line_height,
|
||||||
block.italic,
|
block.italic,
|
||||||
block.font_family.as_deref(),
|
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,
|
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
|
/// Pinta un layout **multicolor** ([`Typesetter::layout_runs`]): cada
|
||||||
/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
|
/// `glyph_run` usa el color de su propio brush ([`RunBrush`]) en vez de un
|
||||||
/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
|
/// color uniforme. `origin` es la esquina superior-izquierda del bloque.
|
||||||
@@ -319,7 +931,22 @@ pub fn draw_layout_runs(
|
|||||||
layout: &parley::Layout<RunBrush>,
|
layout: &parley::Layout<RunBrush>,
|
||||||
origin: (f64, f64),
|
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 line in layout.lines() {
|
||||||
for item in line.items() {
|
for item in line.items() {
|
||||||
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
|
if let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item {
|
||||||
@@ -340,6 +967,7 @@ pub fn draw_layout_runs(
|
|||||||
y: g.y,
|
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);
|
let layout = layout_block(ts, block);
|
||||||
draw_layout(scene, &layout, block.color, block.origin);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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)`."
|
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]
|
[dependencies]
|
||||||
# Reexporta peniko::Color para que las apps consuman sin pull-in directo.
|
# 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
@@ -20,6 +20,37 @@ pub use llimphi_raster::peniko::Color;
|
|||||||
|
|
||||||
use std::time::Duration;
|
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
|
// Tokens transversales — motion, alpha, radius
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -32,16 +63,21 @@ use std::time::Duration;
|
|||||||
// future variante por preset lo requiera.
|
// future variante por preset lo requiera.
|
||||||
|
|
||||||
/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
|
/// Duraciones canónicas (segundo nivel: rítmico, no nervioso, no
|
||||||
/// soporífero). Los widgets eligen `FAST` para microinteracciones
|
/// soporífero). Los widgets eligen `MICRO` para tintes de hover/focus
|
||||||
/// (hover, focus), `NORMAL` para transiciones principales (toast entrar,
|
/// que sólo necesitan suavizar el "salto", `FAST` para microinteracciones
|
||||||
/// modal abrir) y `SLOW` para énfasis o entradas dramáticas (splash de
|
/// completas (chip que pulsa), `NORMAL` para transiciones principales
|
||||||
/// boot).
|
/// (toast entrar, modal abrir), `SLOW` para énfasis o entradas dramáticas
|
||||||
|
/// (splash de boot, hero shared-element).
|
||||||
pub mod motion {
|
pub mod motion {
|
||||||
use super::Duration;
|
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 FAST: Duration = Duration::from_millis(80);
|
||||||
pub const NORMAL: Duration = Duration::from_millis(160);
|
pub const NORMAL: Duration = Duration::from_millis(160);
|
||||||
pub const SLOW: Duration = Duration::from_millis(320);
|
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.
|
/// Easing estándar — cubic-out. Energía inicial, asentamiento suave.
|
||||||
/// La gran mayoría de transiciones de salida / aparición.
|
/// 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
|
/// Lineal — no es elegante pero a veces es lo correcto (barra de
|
||||||
/// progreso, valores numéricos crudos).
|
/// progreso, valores numéricos crudos).
|
||||||
#[inline]
|
#[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 0–255, 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 (0–255) para capas semánticas. Usar siempre
|
/// Valores de opacidad alfa (0–255) para capas semánticas. Usar siempre
|
||||||
/// que se quiera *transparencia coherente*. El widget que improvisa su
|
/// que se quiera *transparencia coherente*. El widget que improvisa su
|
||||||
/// propio alpha rompe la firma visual.
|
/// 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`:
|
/// Tema claro — contraste revisado para WCAG AA sobre `bg_app`:
|
||||||
/// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
|
/// `fg_text` ~12:1, `fg_muted` ~5.4:1 (texto secundario legible),
|
||||||
/// `fg_destructive` y `accent` oscurecidos para superar 4.5:1 sobre
|
/// `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
|
/// Skin **Windows XP "Luna"** — escritorio azul-gris claro, selección y
|
||||||
/// (Dark → Light → Aurora → Sunset → Dark…). El theme-switcher
|
/// acento en el azul XP (#316AC5), chrome celeste. Para la vista `windows-xp`.
|
||||||
/// los consume vía [`Theme::next_after`]. `print()` queda fuera de la
|
pub const fn xp_blue() -> Self {
|
||||||
/// rotación a propósito — es un modo deliberado (imprimir), no un
|
Self {
|
||||||
/// gusto estético que se cicle por accidente.
|
name: "WinXP",
|
||||||
pub fn all() -> Vec<Self> {
|
bg_app: Color::from_rgba8(236, 240, 249, 255),
|
||||||
vec![Self::dark(), Self::light(), Self::aurora(), Self::sunset()]
|
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> {
|
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
|
/// Próximo preset en la rotación de [`Theme::all`]. Si `current` no
|
||||||
@@ -358,4 +643,31 @@ mod tests {
|
|||||||
fn dark_is_the_default() {
|
fn dark_is_the_default() {
|
||||||
assert_eq!(Theme::default().name, "Dark");
|
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
@@ -1,19 +1,39 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llimphi-ui"
|
name = "llimphi-ui"
|
||||||
|
description = "Native Rust UI framework: retained-mode View<Msg> Elm loop over vello + wgpu + taffy + parley."
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
llimphi-hal = { path = "../llimphi-hal" }
|
llimphi-hal = { path = "../llimphi-hal", version = "0.1.0" }
|
||||||
llimphi-layout = { path = "../llimphi-layout" }
|
llimphi-layout = { path = "../llimphi-layout", version = "0.1.0" }
|
||||||
llimphi-raster = { path = "../llimphi-raster" }
|
llimphi-raster = { path = "../llimphi-raster", version = "0.1.0" }
|
||||||
llimphi-text = { path = "../llimphi-text" }
|
llimphi-text = { path = "../llimphi-text", version = "0.1.0" }
|
||||||
# El compositor declarativo (winit-free): View, mount, paint, hit-test.
|
# 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 }
|
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]]
|
[[example]]
|
||||||
name = "counter"
|
name = "counter"
|
||||||
@@ -26,3 +46,11 @@ path = "examples/editor.rs"
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "gpu_paint_demo"
|
name = "gpu_paint_demo"
|
||||||
path = "examples/gpu_paint_demo.rs"
|
path = "examples/gpu_paint_demo.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "gestos"
|
||||||
|
path = "examples/gestos.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "selectable_text"
|
||||||
|
path = "examples/selectable_text.rs"
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -177,6 +177,7 @@ fn draw_points(
|
|||||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||||
view,
|
view,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
|
depth_slice: None,
|
||||||
ops: wgpu::Operations {
|
ops: wgpu::Operations {
|
||||||
// Load preserva el fondo vello ya pintado en este frame.
|
// Load preserva el fondo vello ya pintado en este frame.
|
||||||
load: wgpu::LoadOp::Load,
|
load: wgpu::LoadOp::Load,
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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
@@ -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 => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub mod a11y;
|
||||||
|
|
||||||
use llimphi_hal::winit::application::ApplicationHandler;
|
use llimphi_hal::winit::application::ApplicationHandler;
|
||||||
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
|
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
|
||||||
use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
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
|
/// Identificador de aplicación. En Wayland se mapea al `app_id` del
|
||||||
/// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
|
/// 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> {
|
fn app_id() -> Option<&'static str> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -237,6 +239,19 @@ pub enum UserEvent<Msg> {
|
|||||||
},
|
},
|
||||||
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
|
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
|
||||||
CloseWindow { key: u64 },
|
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
|
/// 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
|
/// llamar funciones que toman `&Handle<Msg>` sin levantar un event
|
||||||
/// loop real (que en CI sin display tiraría).
|
/// loop real (que en CI sin display tiraría).
|
||||||
Test,
|
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> {
|
impl<Msg: Send + 'static> Clone for Handle<Msg> {
|
||||||
@@ -264,6 +286,7 @@ impl<Msg: Send + 'static> Clone for Handle<Msg> {
|
|||||||
inner: match &self.inner {
|
inner: match &self.inner {
|
||||||
HandleInner::Real(p) => HandleInner::Real(p.clone()),
|
HandleInner::Real(p) => HandleInner::Real(p.clone()),
|
||||||
HandleInner::Test => HandleInner::Test,
|
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);
|
let _ = p.send_event(UserEvent::Quit);
|
||||||
}
|
}
|
||||||
HandleInner::Test => {}
|
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));
|
let _ = p.send_event(UserEvent::Msg(msg));
|
||||||
}
|
}
|
||||||
HandleInner::Test => {}
|
HandleInner::Test => {}
|
||||||
|
HandleInner::Lifted(f) => f(msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +393,14 @@ impl<Msg: Send + 'static> Handle<Msg> {
|
|||||||
let _ = f();
|
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.
|
// periodic behaviour deben usar el callback directo.
|
||||||
let _ = f;
|
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
|
/// 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).
|
/// un overlay activo; resuelve el z-order (menús por encima del video).
|
||||||
overlay_compositor: llimphi_hal::OverlayCompositor,
|
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>,
|
model: Option<A::Model>,
|
||||||
cursor: PhysicalPosition<f64>,
|
cursor: PhysicalPosition<f64>,
|
||||||
modifiers: Modifiers,
|
modifiers: Modifiers,
|
||||||
@@ -541,6 +615,84 @@ struct RuntimeState<A: App> {
|
|||||||
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
|
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
|
||||||
/// Evita llamar `set_title` en cada frame cuando no cambió.
|
/// Evita llamar `set_title` en cada frame cuando no cambió.
|
||||||
last_title: Option<String>,
|
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> {
|
struct RenderCache<Msg> {
|
||||||
@@ -565,12 +717,71 @@ struct OverlayCache<Msg> {
|
|||||||
hover_idx: Option<usize>,
|
hover_idx: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
|
/// Tres sabores de handler de drag activo: el simple `(phase, dx, dy)`;
|
||||||
/// o la variante que conserva la posición local del press original
|
/// la variante que conserva la posición local del press original
|
||||||
/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
|
/// `(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> {
|
enum DragHandlerKind<Msg> {
|
||||||
Delta(DragFn<Msg>),
|
Delta(DragFn<Msg>),
|
||||||
DeltaAt(DragAtFn<Msg>, f32, f32),
|
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> {
|
struct DragState<Msg> {
|
||||||
@@ -583,6 +794,48 @@ struct DragState<Msg> {
|
|||||||
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
|
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
|
||||||
/// targets sólo reaccionan cuando hay payload.
|
/// targets sólo reaccionan cuando hay payload.
|
||||||
payload: Option<u64>,
|
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 60–120 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
|
/// 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");
|
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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user