Repository navigation
feat: preview vertex pages with local headless Chrome #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
bregydoc
wants to merge
5
commits into
main
Choose a base branch
from
poc/hypergraph-browser
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0e06d21
test: schedule one live page among vertex stills
bregydoc 3be8c14
feat: capture vertex pages with local headless Chrome
bregydoc 01fd73d
feat: show a page frame on its vertex
bregydoc 181c66f
feat: add the hypergraph browser demo
bregydoc c1ec7b5
fix: deliver the first frame of a focused page
bregydoc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,329 @@ | ||
| //! Latest page image for a vertex. Picking still uses the node transform. | ||
| //! While a frame is current the icosphere mesh is removed and a camera-facing | ||
| //! quad shows the page. | ||
|
|
||
| use std::collections::HashMap; | ||
|
|
||
| use bevy::asset::RenderAssetUsages; | ||
| use bevy::prelude::*; | ||
| use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; | ||
|
|
||
| use crate::render::SceneNodeKey; | ||
|
|
||
| pub const MAX_PAGE_EDGE: u32 = 640; | ||
| const PAGE_QUAD_WIDTH: f32 = 4.0; | ||
| const PAGE_QUAD_HEIGHT: f32 = 2.25; | ||
|
|
||
| #[derive(Clone, Debug)] | ||
| pub struct PageImage { | ||
| pub width: u32, | ||
| pub height: u32, | ||
| pub rgba: Vec<u8>, | ||
| } | ||
|
|
||
| #[derive(Resource, Default)] | ||
| pub struct PageFrames { | ||
| pending: HashMap<String, PageImage>, | ||
| } | ||
|
|
||
| impl PageFrames { | ||
| /// Keeps only the newest accepted image for `id`. Oversized or malformed | ||
| /// frames are ignored so a bad capture cannot replace a good still. | ||
| pub fn submit(&mut self, id: &str, width: u32, height: u32, rgba: Vec<u8>) -> bool { | ||
| if !accepted(id, width, height, rgba.len()) { | ||
| return false; | ||
| } | ||
| self.pending.insert( | ||
| id.to_string(), | ||
| PageImage { | ||
| width, | ||
| height, | ||
| rgba, | ||
| }, | ||
| ); | ||
| true | ||
| } | ||
|
|
||
| pub fn get(&self, id: &str) -> Option<&PageImage> { | ||
| self.pending.get(id) | ||
| } | ||
|
|
||
| fn take_pending(&mut self) -> HashMap<String, PageImage> { | ||
| std::mem::take(&mut self.pending) | ||
| } | ||
| } | ||
|
|
||
| pub fn accepted(id: &str, width: u32, height: u32, rgba_len: usize) -> bool { | ||
| !id.is_empty() | ||
| && width > 0 | ||
| && height > 0 | ||
| && width <= MAX_PAGE_EDGE | ||
| && height <= MAX_PAGE_EDGE | ||
| && rgba_len == width as usize * height as usize * 4 | ||
| } | ||
|
|
||
| #[derive(Component)] | ||
| pub(crate) struct PageQuad; | ||
|
|
||
| #[derive(Component)] | ||
| struct PageTexture(Handle<Image>); | ||
|
|
||
| #[derive(Resource)] | ||
| struct PageQuadMesh(Handle<Mesh>); | ||
|
|
||
| pub(crate) fn apply_page_frames(world: &mut World) { | ||
| let pending = { | ||
| let Some(mut frames) = world.get_resource_mut::<PageFrames>() else { | ||
| return; | ||
| }; | ||
| frames.take_pending() | ||
| }; | ||
| if pending.is_empty() { | ||
| return; | ||
| } | ||
| let nodes: HashMap<String, Entity> = world | ||
| .query::<(Entity, &SceneNodeKey)>() | ||
| .iter(world) | ||
| .map(|(entity, key)| (key.0.clone(), entity)) | ||
| .collect(); | ||
| for (id, frame) in pending { | ||
| let Some(entity) = nodes.get(&id).copied() else { | ||
| continue; | ||
| }; | ||
| show_frame(world, entity, frame); | ||
| } | ||
| } | ||
|
|
||
| pub(crate) fn billboard_page_quads( | ||
| camera: Query<&GlobalTransform, With<Camera>>, | ||
| mut quads: Query<(&mut Transform, &GlobalTransform), With<PageQuad>>, | ||
| ) { | ||
| let Some(camera_transform) = camera.iter().next() else { | ||
| return; | ||
| }; | ||
| let camera_position = camera_transform.translation(); | ||
| for (mut transform, global) in &mut quads { | ||
| let origin = global.translation(); | ||
| if origin.distance_squared(camera_position) < 1e-6 { | ||
| continue; | ||
| } | ||
| // Parent rotation stays identity; local rotation is the world facing. | ||
| transform.rotation = Transform::from_translation(origin) | ||
| .looking_at(camera_position, Vec3::Y) | ||
| .rotation; | ||
| } | ||
| } | ||
|
|
||
| fn show_frame(world: &mut World, entity: Entity, frame: PageImage) { | ||
| hide_sphere(world, entity); | ||
| if let Some(quad) = find_quad(world, entity) { | ||
| replace_texture(world, quad, frame); | ||
| return; | ||
| } | ||
| let mesh = quad_mesh(world); | ||
| let image = world.resource_mut::<Assets<Image>>().add(image_from(frame)); | ||
| let material = world | ||
| .resource_mut::<Assets<StandardMaterial>>() | ||
| .add(StandardMaterial { | ||
| base_color_texture: Some(image.clone()), | ||
| unlit: true, | ||
| ..default() | ||
| }); | ||
| let child = world | ||
| .spawn(( | ||
| PageQuad, | ||
| PageTexture(image), | ||
| Mesh3d(mesh), | ||
| MeshMaterial3d(material), | ||
| Transform::default(), | ||
| )) | ||
| .id(); | ||
| world.entity_mut(entity).add_child(child); | ||
| } | ||
|
|
||
| fn hide_sphere(world: &mut World, entity: Entity) { | ||
| // Picking uses the node transform. Removing the mesh leaves that transform | ||
| // in place and stops the icosphere from drawing over the page. | ||
| world.entity_mut(entity).remove::<Mesh3d>(); | ||
| world | ||
| .entity_mut(entity) | ||
| .remove::<MeshMaterial3d<StandardMaterial>>(); | ||
| } | ||
|
|
||
| fn find_quad(world: &World, entity: Entity) -> Option<Entity> { | ||
| let children = world.get::<Children>(entity)?; | ||
| children | ||
| .iter() | ||
| .find(|&child| world.get::<PageQuad>(child).is_some()) | ||
| } | ||
|
|
||
| fn replace_texture(world: &mut World, quad: Entity, frame: PageImage) { | ||
| let Some(handle) = world | ||
| .get::<PageTexture>(quad) | ||
| .map(|texture| texture.0.clone()) | ||
| else { | ||
| return; | ||
| }; | ||
| let mut images = world.resource_mut::<Assets<Image>>(); | ||
| let Some(image) = images.get_mut(&handle) else { | ||
| return; | ||
| }; | ||
| image.texture_descriptor.size = Extent3d { | ||
| width: frame.width, | ||
| height: frame.height, | ||
| depth_or_array_layers: 1, | ||
| }; | ||
| image.data = Some(frame.rgba); | ||
| } | ||
|
|
||
| fn quad_mesh(world: &mut World) -> Handle<Mesh> { | ||
| if let Some(mesh) = world.get_resource::<PageQuadMesh>() { | ||
| return mesh.0.clone(); | ||
| } | ||
| let handle = world | ||
| .resource_mut::<Assets<Mesh>>() | ||
| .add(Rectangle::new(PAGE_QUAD_WIDTH, PAGE_QUAD_HEIGHT)); | ||
| world.insert_resource(PageQuadMesh(handle.clone())); | ||
| handle | ||
| } | ||
|
|
||
| fn image_from(frame: PageImage) -> Image { | ||
| Image::new( | ||
| Extent3d { | ||
| width: frame.width, | ||
| height: frame.height, | ||
| depth_or_array_layers: 1, | ||
| }, | ||
| TextureDimension::D2, | ||
| frame.rgba, | ||
| TextureFormat::Rgba8UnormSrgb, | ||
| RenderAssetUsages::default(), | ||
| ) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| fn rgba(value: u8) -> Vec<u8> { | ||
| vec![value, 0, 0, 255] | ||
| } | ||
|
|
||
| fn app_with_node(id: &str) -> (App, Entity) { | ||
| let mut app = App::new(); | ||
| app.init_resource::<PageFrames>() | ||
| .init_resource::<Assets<Mesh>>() | ||
| .init_resource::<Assets<StandardMaterial>>() | ||
| .init_resource::<Assets<Image>>() | ||
| .add_systems(Update, apply_page_frames); | ||
| let mesh = app | ||
| .world_mut() | ||
| .resource_mut::<Assets<Mesh>>() | ||
| .add(Rectangle::new(1.0, 1.0)); | ||
| let material = app | ||
| .world_mut() | ||
| .resource_mut::<Assets<StandardMaterial>>() | ||
| .add(StandardMaterial::default()); | ||
| let entity = app | ||
| .world_mut() | ||
| .spawn(( | ||
| SceneNodeKey(id.to_string()), | ||
| Mesh3d(mesh), | ||
| MeshMaterial3d(material), | ||
| Transform::default(), | ||
| )) | ||
| .id(); | ||
| (app, entity) | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_newer_frame_replaces_the_pending_image() { | ||
| let mut frames = PageFrames::default(); | ||
| assert!(frames.submit("a", 1, 1, rgba(1))); | ||
| assert!(frames.submit("a", 1, 1, rgba(2))); | ||
| assert_eq!(frames.get("a").unwrap().rgba, rgba(2)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_oversized_frame_is_ignored() { | ||
| let mut frames = PageFrames::default(); | ||
| assert!(frames.submit("a", 1, 1, rgba(1))); | ||
| let too_wide = vec![0; (MAX_PAGE_EDGE as usize + 1) * 4]; | ||
| assert!(!frames.submit("a", MAX_PAGE_EDGE + 1, 1, too_wide)); | ||
| assert_eq!(frames.get("a").unwrap().rgba, rgba(1)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_unknown_id_does_not_create_a_quad() { | ||
| let (mut app, _) = app_with_node("known"); | ||
| assert!( | ||
| app.world_mut() | ||
| .resource_mut::<PageFrames>() | ||
| .submit("missing", 1, 1, rgba(9)) | ||
| ); | ||
| app.update(); | ||
| assert_eq!( | ||
| app.world_mut() | ||
| .query::<&PageQuad>() | ||
| .iter(app.world()) | ||
| .count(), | ||
| 0 | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_known_vertex_hides_its_sphere_and_shows_the_latest_frame() { | ||
| let (mut app, node) = app_with_node("known"); | ||
| { | ||
| let mut frames = app.world_mut().resource_mut::<PageFrames>(); | ||
| assert!(frames.submit("known", 1, 1, rgba(1))); | ||
| assert!(frames.submit("known", 1, 1, rgba(4))); | ||
| } | ||
| app.update(); | ||
| assert!(app.world().get::<Mesh3d>(node).is_none()); | ||
| let quad = app | ||
| .world_mut() | ||
| .query::<&PageQuad>() | ||
| .iter(app.world()) | ||
| .count(); | ||
| assert_eq!(quad, 1); | ||
| let handle = app | ||
| .world_mut() | ||
| .query::<&PageTexture>() | ||
| .single(app.world()) | ||
| .unwrap() | ||
| .0 | ||
| .clone(); | ||
| let image = app | ||
| .world() | ||
| .resource::<Assets<Image>>() | ||
| .get(&handle) | ||
| .unwrap(); | ||
| assert_eq!(image.data.as_deref(), Some(rgba(4).as_slice())); | ||
| } | ||
|
|
||
| #[test] | ||
| fn removing_a_vertex_drops_its_frame() { | ||
| let (mut app, node) = app_with_node("known"); | ||
| assert!( | ||
| app.world_mut() | ||
| .resource_mut::<PageFrames>() | ||
| .submit("known", 1, 1, rgba(1)) | ||
| ); | ||
| app.update(); | ||
| app.world_mut().entity_mut(node).despawn(); | ||
| assert!( | ||
| app.world_mut() | ||
| .resource_mut::<PageFrames>() | ||
| .submit("known", 1, 1, rgba(2)) | ||
| ); | ||
| app.update(); | ||
| assert_eq!( | ||
| app.world_mut() | ||
| .query::<&PageQuad>() | ||
| .iter(app.world()) | ||
| .count(), | ||
| 0 | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: atomicstrata/hyper
Length of output: 20048
Script executed:
Repository: atomicstrata/hyper
Length of output: 156
Web query:
Bevy 0.18.1 Rectangle mesh front face +Z Transform looking_at -Z StandardMaterial default cull_modeResult:
Web query:
Bevy 0.18.1 Rectangle mesh source winding normal +Z Transform looking_at local -Z docs.rsResult:
Reverse the billboard target.
Transform::looking_atpoints local-Ztowardcamera_position, while the Bevy 0.18.1Rectanglefront face points along+Z. The quad therefore faces away from the camera and can be removed by the default back-face culling.Suggested fix
Add a unit test with a camera at
+Zand a quad at the origin. Assert thatrotation * Vec3::Zpoints toward the camera.Committable suggestion