diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 198e6e637..3623cf20f 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -47,7 +47,7 @@ use crate::frame_geometry::{ tilted_screen_cb, CursorPlacement, CursorPlanInput, FrameGeometryInput, ShadowCaster, SpriteShape, }; -use crate::scene::{Scene, SceneBackground, WallpaperMotion}; +use crate::scene::{Scene, SceneBackground, SceneCursorSprite, WallpaperMotion}; const LAYER_WGSL: &str = include_str!("vk_shaders/layer.wgsl"); const BLUR_WGSL: &str = include_str!("vk_shaders/blur.wgsl"); @@ -1388,13 +1388,22 @@ impl Compositor { /// Champ de distance du sprite `path` (binding 2 du mode 15) et sa forme, calcules au /// premier appel. Parite `compositor_windows::cursor_sdf`. - fn cursor_sdf(&self, path: &str) -> Result<(wgpu::Texture, SpriteShape)> { + fn cursor_sdf( + &self, + sprite: &SceneCursorSprite, + ) -> Result<(wgpu::Texture, SpriteShape)> { + let path = sprite.model_sdf_path.as_deref().unwrap_or(&sprite.path); if let Some(hit) = self.sdf_cache.borrow().get(path) { return Ok(hit.clone()); } - let sdf = crate::cursor_sdf::CursorSdf::load(path)?; - let texels = sdf.f16_bytes(); - let size = wgpu::Extent3d { width: sdf.width, height: sdf.height, depth_or_array_layers: 1 }; + let (width, height, texels, shape) = if sprite.model_sdf_path.is_some() { + let sdf = crate::cursor_sdf::CursorVolumeSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + } else { + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + }; + let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 }; let tex = self.gpu.device.create_texture(&wgpu::TextureDescriptor { label: Some("cursor-sdf"), size, @@ -1415,12 +1424,12 @@ impl Compositor { &texels, wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(sdf.width * 2), - rows_per_image: Some(sdf.height), + bytes_per_row: Some(width * 2), + rows_per_image: Some(height), }, size, ); - let entry = (tex, sdf.shape); + let entry = (tex, shape); self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); Ok(entry) } @@ -2908,9 +2917,15 @@ impl Compositor { // Curseur modelise (mode 15) : ce meme sprite extrude, `plan_cursor` en a tire la // pose. Sprite au binding 1 (texY), champ au binding 2 (texU). Parite Windows/macOS. if let Some(pose) = plan.model { - match self.cursor_sdf(&sprite.path) { - Ok((sdf, shape)) => { + let model_image = match sprite.model_color_path.as_deref() { + Some(color_path) => self.cached_image(color_path).ok(), + None => Some((tex.clone(), iw, ih)), + }; + if let Some((model_tex, _, _)) = model_image { + match self.cursor_sdf(sprite) { + Ok((sdf, shape)) => { let shape = crate::frame_geometry::model_shape(sprite, shape); + let model_view = model_tex.create_view(&wgpu::TextureViewDescriptor::default()); let sdf_view = sdf.create_view(&wgpu::TextureViewDescriptor::default()); for placement in placements { let Some(cb) = cursor_model_cb( @@ -2924,18 +2939,19 @@ impl Compositor { continue; }; let (buf, bind) = - self.make_bind(&cb, Some((&view, &sdf_view, &view)), &dummy); + self.make_bind(&cb, Some((&model_view, &sdf_view, &model_view)), &dummy); bufs.push(buf); binds.push(bind); } return (!binds.is_empty()).then_some(CursorDraw { _bufs: bufs, - _tex: vec![(tex, view), (sdf, sdf_view)], + _tex: vec![(model_tex, model_view), (sdf, sdf_view)], binds, impacts, }); } - Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + } } } diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index b00a288ee..6f5951c7f 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -998,30 +998,39 @@ impl Compositor { /// Champ de distance du sprite `path` (texture(4) du mode 15) et sa forme, calculés au /// premier appel. Parité `compositor_windows::cursor_sdf`. R16Float : filtrable sur tous /// les GPU Apple, contrairement au R32Float. - fn cursor_sdf(&self, path: &str) -> Result<(metal::Texture, crate::frame_geometry::SpriteShape)> { + fn cursor_sdf( + &self, + sprite: &crate::scene::SceneCursorSprite, + ) -> Result<(metal::Texture, crate::frame_geometry::SpriteShape)> { + let path = sprite.model_sdf_path.as_deref().unwrap_or(&sprite.path); if let Some(hit) = self.sdf_cache.borrow().get(path) { return Ok(hit.clone()); } - let sdf = crate::cursor_sdf::CursorSdf::load(path)?; - let texels = sdf.f16_bytes(); + let (width, height, texels, shape) = if sprite.model_sdf_path.is_some() { + let sdf = crate::cursor_sdf::CursorVolumeSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + } else { + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + }; let tex = make_texture( &self.gpu.device, metal::MTLPixelFormat::R16Float, - sdf.width, - sdf.height, + width, + height, metal::MTLStorageMode::Shared, metal::MTLTextureUsage::ShaderRead, ); tex.replace_region( metal::MTLRegion { origin: metal::MTLOrigin { x: 0, y: 0, z: 0 }, - size: metal::MTLSize { width: sdf.width as u64, height: sdf.height as u64, depth: 1 }, + size: metal::MTLSize { width: width as u64, height: height as u64, depth: 1 }, }, 0, texels.as_ptr() as *const std::ffi::c_void, - (sdf.width * 2) as u64, + (width * 2) as u64, ); - let entry = (tex, sdf.shape); + let entry = (tex, shape); self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); Ok(entry) } @@ -2081,19 +2090,25 @@ impl Compositor { let (tex, iw, ih) = self.cached_image(sprite.path.as_str())?; // Sans champ de distance, repli sur le sprite plat plutôt qu'aucun curseur. Parité Linux. if let Some(pose) = model { - match self.cursor_sdf(sprite.path.as_str()) { - Ok((sdf, shape)) => { + let model_tex = match sprite.model_color_path.as_deref() { + Some(color_path) => self.cached_image(color_path).ok().map(|(tex, _, _)| tex), + None => Some(tex.clone()), + }; + if let Some(model_tex) = model_tex { + match self.cursor_sdf(sprite) { + Ok((sdf, shape)) => { let shape = crate::frame_geometry::model_shape(sprite, shape); if let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, ) { - enc.set_fragment_texture(2, Some(&tex)); + enc.set_fragment_texture(2, Some(&model_tex)); enc.set_fragment_texture(4, Some(&sdf)); self.draw_solid(enc, &cb); } return Ok(()); } - Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + Err(e) => eprintln!("[curseur] champ de \"{}\" : {e:#}", sprite.path), + } } } let (rw, rh) = (self.render_w as f32, self.render_h as f32); diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 59431e383..4cb820450 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -1255,15 +1255,24 @@ impl Compositor { } /// Champ de distance du sprite `path` (t4 du mode 15) et sa forme, calculés au premier appel. - unsafe fn cursor_sdf(&self, path: &str) -> Result<(ID3D11ShaderResourceView, SpriteShape)> { + unsafe fn cursor_sdf( + &self, + sprite: &SceneCursorSprite, + ) -> Result<(ID3D11ShaderResourceView, SpriteShape)> { + let path = sprite.model_sdf_path.as_deref().unwrap_or(&sprite.path); if let Some(hit) = self.sdf_cache.borrow().get(path) { return Ok(hit.clone()); } - let sdf = crate::cursor_sdf::CursorSdf::load(path)?; - let texels = sdf.f16_bytes(); + let (width, height, texels, shape) = if sprite.model_sdf_path.is_some() { + let sdf = crate::cursor_sdf::CursorVolumeSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + } else { + let sdf = crate::cursor_sdf::CursorSdf::load(path)?; + (sdf.width, sdf.height, sdf.f16_bytes(), sdf.shape) + }; let td = D3D11_TEXTURE2D_DESC { - Width: sdf.width, - Height: sdf.height, + Width: width, + Height: height, MipLevels: 1, ArraySize: 1, Format: DXGI_FORMAT_R16_FLOAT, @@ -1275,14 +1284,14 @@ impl Compositor { }; let init = D3D11_SUBRESOURCE_DATA { pSysMem: texels.as_ptr() as *const c_void, - SysMemPitch: sdf.width * 2, + SysMemPitch: width * 2, SysMemSlicePitch: 0, }; let mut tex: Option = None; self.dev.CreateTexture2D(&td, Some(&init), Some(&mut tex))?; let mut srv: Option = None; self.dev.CreateShaderResourceView(&tex.unwrap(), None, Some(&mut srv))?; - let entry = (srv.unwrap(), sdf.shape); + let entry = (srv.unwrap(), shape); self.sdf_cache.borrow_mut().insert(path.to_string(), entry.clone()); Ok(entry) } @@ -1696,21 +1705,27 @@ impl Compositor { // Sans champ de distance, repli sur le sprite plat plutôt que sur le curseur math. // Parité Linux. if let Some(pose) = model { - match self.cursor_sdf(path) { - Ok((sdf, shape)) => { + let model_srv = match sprite.model_color_path.as_deref() { + Some(color_path) => self.cached_image(color_path).ok().map(|(srv, _, _)| srv), + None => Some(srv.clone()), + }; + if let Some(model_srv) = model_srv { + match self.cursor_sdf(sprite) { + Ok((sdf, shape)) => { let shape = crate::frame_geometry::model_shape(sprite, shape); if let Some(cb) = crate::frame_geometry::cursor_model_cb( placement, size_px, pose, shape, a, clip, ) { self.upload_cb(&cb); - self.ctx.PSSetShaderResources(2, Some(&[Some(srv)])); + self.ctx.PSSetShaderResources(2, Some(&[Some(model_srv)])); self.ctx.PSSetShaderResources(4, Some(&[Some(sdf)])); self.ctx.Draw(4, 0); self.ctx.PSSetShaderResources(4, Some(&[None])); } return Ok(()); } - Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), + Err(e) => eprintln!("[curseur] champ de \"{path}\" : {e:#}"), + } } } let ar = iw as f32 / ih as f32; diff --git a/crates/compositor/src/cursor_sdf.rs b/crates/compositor/src/cursor_sdf.rs index 161793a40..5c3888c8b 100644 --- a/crates/compositor/src/cursor_sdf.rs +++ b/crates/compositor/src/cursor_sdf.rs @@ -19,6 +19,7 @@ //! les trois backends (D3D11, Metal, wgpu), contrairement au R32F. use anyhow::{anyhow, Result}; +use serde::Deserialize; use crate::frame_geometry::SpriteShape; @@ -115,6 +116,7 @@ impl CursorSdf { max_height: 0.0, thick: crate::frame_geometry::MODEL_THICK, sculpt: 0, + volume: [0.0; 4], }, } } @@ -143,6 +145,98 @@ impl CursorSdf { } } +/// Volume distance field exported from an editable Blender cursor mesh. Slices are packed into a +/// 2D atlas so all three render backends can keep using their existing filterable R16F binding. +pub struct CursorVolumeSdf { + pub width: u32, + pub height: u32, + pub texels: Vec, + pub shape: SpriteShape, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CursorVolumeMetadata { + width: u32, + height: u32, + depth: u32, + tiles_x: u32, + tiles_y: u32, + distance_range: f32, + size: [f32; 2], + hotspot: [f32; 2], + top: f32, + thick: f32, + max_height: f32, +} + +impl CursorVolumeSdf { + /// Loads the grayscale SDF atlas and its sibling JSON dimensions / cursor bounds. + pub fn load(path: &str) -> Result { + let path_buf = std::path::Path::new(path); + let metadata_path = path_buf.with_extension("json"); + let metadata: CursorVolumeMetadata = serde_json::from_slice( + &std::fs::read(&metadata_path) + .map_err(|e| anyhow!("metadata du volume {} : {e}", metadata_path.display()))?, + ) + .map_err(|e| anyhow!("metadata du volume {} : {e}", metadata_path.display()))?; + if metadata.width == 0 + || metadata.height == 0 + || metadata.depth == 0 + || metadata.tiles_x == 0 + || metadata.tiles_y == 0 + || metadata.depth > metadata.tiles_x * metadata.tiles_y + || !metadata.distance_range.is_finite() + || metadata.distance_range <= 0.0 + || metadata.size.iter().any(|v| !v.is_finite() || *v <= 0.0) + || metadata.hotspot.iter().any(|v| !v.is_finite()) + || !metadata.thick.is_finite() + || !metadata.max_height.is_finite() + { + return Err(anyhow!("dimensions ou bornes invalides dans {}", metadata_path.display())); + } + let atlas = image::open(path) + .map_err(|e| anyhow!("atlas SDF {path} : {e}"))? + .to_luma8(); + let atlas_width = metadata.width * metadata.tiles_x; + let atlas_height = metadata.height * metadata.tiles_y; + if atlas.dimensions() != (atlas_width, atlas_height) { + return Err(anyhow!( + "dimensions de l'atlas SDF {path} : {:?}, attendues {atlas_width}x{atlas_height}", + atlas.dimensions() + )); + } + let mut texels = Vec::with_capacity((atlas_width * atlas_height) as usize); + for pixel in atlas.as_raw() { + texels.push(((*pixel as f32 / 255.0) * 2.0 - 1.0) * metadata.distance_range); + } + Ok(CursorVolumeSdf { + width: atlas_width, + height: atlas_height, + texels, + shape: SpriteShape { + size: metadata.size, + hotspot: metadata.hotspot, + top: metadata.top, + max_height: metadata.max_height, + thick: metadata.thick, + sculpt: 11, + volume: [ + metadata.tiles_x as f32, + metadata.tiles_y as f32, + metadata.depth as f32, + metadata.distance_range, + ], + }, + }) + } + + /// R16F byte representation consumed by D3D11, Metal and wgpu. + pub fn f16_bytes(&self) -> Vec { + self.texels.iter().flat_map(|&d| f16_bits(d).to_le_bytes()).collect() + } +} + /// Demi-flottant IEEE 754 de `v`, tronqué. Les distances n'ont ni NaN ni infini ; sous 2⁻¹⁵ elles /// valent zéro, et c'est sans effet à cette échelle. fn f16_bits(v: f32) -> u16 { diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 7c367a9a1..d4e4046ef 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -55,10 +55,10 @@ pub struct LayerCB { pub src_prev: [f32; 4], pub dst_prev: [f32; 4], pub mb: [f32; 4], // mb[0] = nombre de taps de motion blur - /// Mode 8 : le plan à la frame PRÉCÉDENTE, coins TL, TR (`trail_a`) puis BR, BL (`trail_b`) - /// en px locaux comme `fx`/`src_prev`, et `trail_mb` = `[taps, force, 0, 0]` de son flou de - /// mouvement, ceux du mode 0 (`mb.xy`). `trail_mb` nul ailleurs : aucun tap, rien n'est lu. - /// Mode 18 incliné : les mêmes coins d'avant, en fractions de la sortie (`screen_trail_cb`). + /// Mode 15 : `trail_a` = `[id, épaisseur, hauteur max, 0]`, `trail_b` = atlas volume + /// `[colonnes, lignes, tranches, plage SDF]`. Mode 8 : coins TL/TR (`trail_a`) puis BR/BL + /// (`trail_b`) du plan précédent ; `trail_mb` = `[taps, force, 0, 0]` de son flou de mouvement. + /// Mode 18 incliné : les coins d'avant en fractions de la sortie (`screen_trail_cb`). pub trail_a: [f32; 4], pub trail_b: [f32; 4], pub trail_mb: [f32; 4], @@ -3837,6 +3837,9 @@ pub struct SpriteShape { pub thick: f32, /// Le curseur sculpté que dessine le shader (`sculpt`), 0 pour le sprite extrudé. pub sculpt: u32, + /// `[colonnes, lignes, tranches, plage signée]` d'un atlas de volume Blender, zéro pour les + /// curseurs dont la forme reste définie par le sprite ou par les anciens prototypes shader. + pub volume: [f32; 4], } impl SpriteShape { @@ -3991,6 +3994,9 @@ pub fn cursor_pose( /// (`sculpt::sculpted_shape`), sinon le sprite extrudé (`sdf_shape`, du champ de son PNG) au /// hotspot de la scène. Commun aux trois backends. pub fn model_shape(sprite: &crate::scene::SceneCursorSprite, sdf_shape: SpriteShape) -> SpriteShape { + if sdf_shape.volume[0] > 0.0 { + return sdf_shape; + } sprite .sculpt .as_deref() @@ -4154,9 +4160,9 @@ impl ModelView { } } -/// `LayerCB` du curseur modélisé (mode 15) posé en `placement`, pour les trois backends : le -/// sprite `shape` extrudé. `None` quand il n'y a rien à dessiner (placement droit, caméra -/// dégénérée). Voir l'en-tête de cette section pour l'emploi des emplacements. +/// `LayerCB` du curseur modélisé (mode 15) posé en `placement`, pour les trois backends. Le SDF +/// décrit le maillage importé ou la silhouette du sprite extrudé. `None` quand le placement droit +/// ou la caméra dégénérée ne permet pas de le dessiner. pub fn cursor_model_cb( placement: CursorPlacement, size_px: f32, @@ -4190,6 +4196,8 @@ pub fn cursor_model_cb( mb: [view.half[0], view.half[1], view.offset[0], view.offset[1]], radius_px: shape.size[0] / shape.size[1], trail_a: [shape.sculpt as f32, shape.thick, shape.max_height, 0.0], + // Mode 15: imported SDF atlas layout, depth, and signed-distance decode range. + trail_b: shape.volume, ..Default::default() }) } @@ -7835,6 +7843,8 @@ mod tests { hotspot_x: hx, hotspot_y: hy, sculpt: None, + model_sdf_path: None, + model_color_path: None, }, ); } @@ -8067,6 +8077,8 @@ mod tests { hotspot_x: 0.1947, hotspot_y: 0.0656, sculpt: Some("studio-ink/arrow".into()), + model_sdf_path: None, + model_color_path: None, }; themed.cursor.cursor_sprites.insert("arrow".into(), themed_arrow.clone()); assert_eq!( diff --git a/crates/compositor/src/scene.rs b/crates/compositor/src/scene.rs index de24278fd..27e5cd26b 100644 --- a/crates/compositor/src/scene.rs +++ b/crates/compositor/src/scene.rs @@ -591,10 +591,16 @@ pub struct SceneCursorSprite { /// que le curseur était agrandi — le bug que ce champ corrige. pub hotspot_x: f32, pub hotspot_y: f32, - /// Le curseur sculpté de cet état en 3D, `"/<état>"` (`sculpt::sculpted_shape`) : les - /// shaders le modèlent au lieu d'extruder le sprite, qui reste l'art en 2D. + /// Identifiant `"/<état>"` du modèle 3D dédié. Les anciens noms sans atlas retombent + /// sur les prototypes des shaders ; `path` reste l'image 2D. #[serde(default)] pub sculpt: Option, + /// Atlas SDF produit depuis le maillage Blender pour le modèle 3D (PNG gris + sidecar JSON). + #[serde(default)] + pub model_sdf_path: Option, + /// Atlas des couleurs de matériaux cuits depuis le maillage Blender. + #[serde(default)] + pub model_color_path: Option, } #[derive(Debug, Clone, Copy, Deserialize)] diff --git a/crates/compositor/src/sculpt.rs b/crates/compositor/src/sculpt.rs index de9c4e65a..59a760b5e 100644 --- a/crates/compositor/src/sculpt.rs +++ b/crates/compositor/src/sculpt.rs @@ -69,6 +69,7 @@ pub fn sculpted_shape(name: &str) -> Option { max_height: (z_high - zref) * s, thick: (zref - Z_LOW) * s, sculpt: 1 + 2 * theme as u32 + u32::from(!arrow), + volume: [0.0; 4], }) } diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 30fbf1bd5..ec12a040b 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -1040,11 +1040,62 @@ float sd_sprite2(float2 p) return out2 > 0.0 ? sqrt(out2 + e * e) : d; } +// Un volume Blender est empaquete en tranches carrées dans la texture 2D déjà liée en t4. +// L'interpolation entre deux tranches se fait ici, sans filtrer à travers les bords des tuiles. +float2 volume_atlas_uv(float2 xy, int slice, uint atlas_w, uint atlas_h) +{ + float tiles_x = max(trail_b.x, 1.0); + float tile_w = (float)atlas_w / tiles_x; + float tile_h = (float)atlas_h / max(trail_b.y, 1.0); + float tile_x = fmod((float)slice, tiles_x); + float tile_y = floor((float)slice / tiles_x); + float2 pixel = float2(tile_x * tile_w + 0.5 + xy.x * (tile_w - 1.0), + tile_y * tile_h + 0.5 + xy.y * (tile_h - 1.0)); + return pixel / float2(atlas_w, atlas_h); +} + +float3 volume_local_point(float3 p) +{ + return float3(p.xy, p.z / max(color.b, 1e-3)); +} + +float volume_distance(float3 p) +{ + uint atlas_w, atlas_h; + texSdf.GetDimensions(atlas_w, atlas_h); + float3 model_p = volume_local_point(p); + float3 lo = float3(color.rg, -trail_a.y); + float3 extent = float3(sprite_size(), trail_a.y + trail_a.z); + float3 uv = saturate((model_p - lo) / max(extent, 1e-6)); + float z = uv.z * (trail_b.z - 1.0); + int z0 = (int)floor(z); + int z1 = min(z0 + 1, (int)trail_b.z - 1); + float d0 = texSdf.SampleLevel(samp, volume_atlas_uv(uv.xy, z0, atlas_w, atlas_h), 0.0); + float d1 = texSdf.SampleLevel(samp, volume_atlas_uv(uv.xy, z1, atlas_w, atlas_h), 0.0); + return lerp(d0, d1, frac(z)) * min(color.b, 1.0); +} + +float model_normal_step() +{ + if (sculpt_id() <= 10) + { + return 0.002; + } + uint atlas_w, atlas_h; + texSdf.GetDimensions(atlas_w, atlas_h); + float2 tile = float2(atlas_w / max(trail_b.x, 1.0), atlas_h / max(trail_b.y, 1.0)); + return 0.75 * max(sprite_size().x / tile.x, sprite_size().y / tile.y); +} + // Le modèle en `p` : distance signée et id de matière (0 pour un sprite). Sculpté, sa forme // (`sculpt_eval`, dont `occ`) ; sinon le contour rentré du chanfrein, épaisseur rentrée du // chanfrein, puis regonflé : les arêtes du dessus et du dessous sont arrondies de MODEL_BEVEL. float2 model_eval(float3 p, bool occ) { + if (sculpt_id() > 10) + { + return float2(volume_distance(p), 0.0); + } if (sculpt_id() > 0) { return sculpt_eval(p, occ); @@ -1118,6 +1169,22 @@ float3 model_albedo(float2 p) return texImg.SampleLevel(samp, (q - color.rg) / sprite_size(), 0.0).rgb; } +float3 model_volume_color(float3 p) +{ + uint atlas_w, atlas_h; + texImg.GetDimensions(atlas_w, atlas_h); + float3 model_p = volume_local_point(p); + float3 lo = float3(color.rg, -trail_a.y); + float3 extent = float3(sprite_size(), trail_a.y + trail_a.z); + float3 uv = saturate((model_p - lo) / max(extent, 1e-6)); + float z = uv.z * (trail_b.z - 1.0); + int z0 = (int)floor(z); + int z1 = min(z0 + 1, (int)trail_b.z - 1); + float3 c0 = texImg.SampleLevel(samp, volume_atlas_uv(uv.xy, z0, atlas_w, atlas_h), 0.0).rgb; + float3 c1 = texImg.SampleLevel(samp, volume_atlas_uv(uv.xy, z1, atlas_w, atlas_h), 0.0).rgb; + return lerp(c0, c1, frac(z)); +} + struct SculptMat { float3 alb; // albédo, linéaire @@ -1268,7 +1335,11 @@ float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, float sh // Sur un sprite, reflets et brillance sur les arrondis seulement : le dessus plat d'un sprite // sombre virerait au gris sous la lampe. float gloss = 1.0; - if (id > 0) + if (id > 10) + { + m = s_mat(pow(model_volume_color(q), 2.2), 0.36, 0.28, 0.12, 0.24); + } + else if (id > 0) { // La matière d'un voxel se lit juste sous la surface : son flanc lit sa propre cellule. float3 p = sculpt_point(q) - float3(n.x, -n.y, n.z) * 0.01; @@ -1400,7 +1471,7 @@ float4 cursor_model(float2 local) } else if (stage == STAGE_NORMAL) { - pos = q + 0.002 * model_tetra(k); + pos = q + model_normal_step() * model_tetra(k); } else if (stage == STAGE_AO) { diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 6c019c3ec..f3cd75920 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -987,8 +987,60 @@ static float sd_sprite2(float2 p, constant Layer &layer, texture2d 0.0 ? sqrt(out2 + e * e) : d; } +static float2 volume_atlas_uv(float2 xy, int slice, uint atlas_w, uint atlas_h, + constant Layer &layer) +{ + float tiles_x = max(layer.trail_b.x, 1.0); + float tile_w = float(atlas_w) / tiles_x; + float tile_h = float(atlas_h) / max(layer.trail_b.y, 1.0); + float tile_x = fmod(float(slice), tiles_x); + float tile_y = floor(float(slice) / tiles_x); + float2 pixel = float2(tile_x * tile_w + 0.5 + xy.x * (tile_w - 1.0), + tile_y * tile_h + 0.5 + xy.y * (tile_h - 1.0)); + return pixel / float2(float(atlas_w), float(atlas_h)); +} + +static float3 volume_local_point(float3 p, constant Layer &layer) +{ + return float3(p.xy, p.z / max(layer.color.b, 1e-3)); +} + +static float volume_distance(float3 p, constant Layer &layer, + texture2d texSdf) +{ + uint atlas_w = texSdf.get_width(); + uint atlas_h = texSdf.get_height(); + float3 point = volume_local_point(p, layer); + float3 lo = float3(layer.color.rg, -layer.trail_a.y); + float3 extent = float3(sprite_size(layer), layer.trail_a.y + layer.trail_a.z); + float3 uv = clamp((point - lo) / max(extent, float3(1e-6)), 0.0, 1.0); + float z = uv.z * (layer.trail_b.z - 1.0); + int z0 = int(floor(z)); + int z1 = min(z0 + 1, int(layer.trail_b.z) - 1); + float d0 = texSdf.sample(samp, volume_atlas_uv(uv.xy, z0, atlas_w, atlas_h, layer), level(0.0)).r; + float d1 = texSdf.sample(samp, volume_atlas_uv(uv.xy, z1, atlas_w, atlas_h, layer), level(0.0)).r; + return mix(d0, d1, fract(z)) * min(layer.color.b, 1.0); +} + +static float model_normal_step(constant Layer &layer, + texture2d texSdf) +{ + if (sculpt_id(layer) <= 10) + { + return 0.002; + } + float2 tile = float2(float(texSdf.get_width()) / max(layer.trail_b.x, 1.0), + float(texSdf.get_height()) / max(layer.trail_b.y, 1.0)); + float2 size = sprite_size(layer); + return 0.75 * max(size.x / tile.x, size.y / tile.y); +} + static float2 model_eval(float3 p, bool occ, constant Layer &layer, texture2d texSdf) { + if (sculpt_id(layer) > 10) + { + return float2(volume_distance(p, layer, texSdf), 0.0); + } if (sculpt_id(layer) > 0) { return sculpt_eval(p, occ, layer); @@ -1054,6 +1106,23 @@ static float3 model_albedo(float2 p, constant Layer &layer, texture2d texImg) +{ + uint atlas_w = texImg.get_width(); + uint atlas_h = texImg.get_height(); + float3 point = volume_local_point(p, layer); + float3 lo = float3(layer.color.rg, -layer.trail_a.y); + float3 extent = float3(sprite_size(layer), layer.trail_a.y + layer.trail_a.z); + float3 uv = clamp((point - lo) / max(extent, float3(1e-6)), 0.0, 1.0); + float z = uv.z * (layer.trail_b.z - 1.0); + int z0 = int(floor(z)); + int z1 = min(z0 + 1, int(layer.trail_b.z) - 1); + float3 c0 = texImg.sample(samp, volume_atlas_uv(uv.xy, z0, atlas_w, atlas_h, layer), level(0.0)).rgb; + float3 c1 = texImg.sample(samp, volume_atlas_uv(uv.xy, z1, atlas_w, atlas_h, layer), level(0.0)).rgb; + return mix(c0, c1, fract(z)); +} + struct SculptMat { float3 alb; @@ -1184,7 +1253,11 @@ static float3 model_shade(float3 q, float3 n, float3 rd, float3 L, float fall, f } SculptMat m; float gloss = 1.0; - if (id > 0) + if (id > 10) + { + m = s_mat(pow(model_volume_color(q, layer, texImg), float3(2.2)), 0.36, 0.28, 0.12, 0.24); + } + else if (id > 0) { float3 p = sculpt_point(q, layer) - float3(n.x, -n.y, n.z) * 0.01; m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2); @@ -1300,7 +1373,7 @@ static float4 cursor_model(float2 local, constant Layer &layer, } else if (stage == STAGE_NORMAL) { - pos = q + 0.002 * model_tetra(k); + pos = q + model_normal_step(layer, texSdf) * model_tetra(k); } else if (stage == STAGE_AO) { diff --git a/crates/compositor/src/vk_shaders/layer.wgsl b/crates/compositor/src/vk_shaders/layer.wgsl index 2eb46a9b3..1dc0e677f 100644 --- a/crates/compositor/src/vk_shaders/layer.wgsl +++ b/crates/compositor/src/vk_shaders/layer.wgsl @@ -963,7 +963,51 @@ fn sd_sprite2(p: vec2) -> f32 { return select(d, sqrt(out2 + e * e), out2 > 0.0); } +fn volume_atlas_uv(xy: vec2, slice: i32, atlas_size: vec2) -> vec2 { + let tiles_x = max(layer.trail_b.x, 1.0); + let tile_w = f32(atlas_size.x) / tiles_x; + let tile_h = f32(atlas_size.y) / max(layer.trail_b.y, 1.0); + let slice_f = f32(slice); + let tile_x = slice_f - floor(slice_f / tiles_x) * tiles_x; + let tile_y = floor(slice_f / tiles_x); + let pixel = vec2(tile_x * tile_w + 0.5 + xy.x * (tile_w - 1.0), + tile_y * tile_h + 0.5 + xy.y * (tile_h - 1.0)); + return pixel / vec2(atlas_size); +} + +fn volume_local_point(p: vec3) -> vec3 { + return vec3(p.xy, p.z / max(layer.color.b, 1e-3)); +} + +fn volume_distance(p: vec3) -> f32 { + let atlas_size = textureDimensions(texU); + let point = volume_local_point(p); + let lo = vec3(layer.color.rg, -layer.trail_a.y); + let extent = vec3(sprite_size(), layer.trail_a.y + layer.trail_a.z); + let uv = clamp((point - lo) / max(extent, vec3(1e-6)), vec3(0.0), vec3(1.0)); + let z = uv.z * (layer.trail_b.z - 1.0); + let z0 = i32(floor(z)); + let z1 = min(z0 + 1, i32(layer.trail_b.z) - 1); + let d0 = textureSampleLevel(texU, samp, volume_atlas_uv(uv.xy, z0, atlas_size), 0.0).r; + let d1 = textureSampleLevel(texU, samp, volume_atlas_uv(uv.xy, z1, atlas_size), 0.0).r; + return mix(d0, d1, fract(z)) * min(layer.color.b, 1.0); +} + +fn model_normal_step() -> f32 { + if sculpt_id() <= 10 { + return 0.002; + } + let atlas_size = textureDimensions(texU); + let tile = vec2(f32(atlas_size.x) / max(layer.trail_b.x, 1.0), + f32(atlas_size.y) / max(layer.trail_b.y, 1.0)); + let size = sprite_size(); + return 0.75 * max(size.x / tile.x, size.y / tile.y); +} + fn model_eval(p: vec3, occ: bool) -> vec2 { + if sculpt_id() > 10 { + return vec2(volume_distance(p), 0.0); + } if sculpt_id() > 0 { return sculpt_eval(p, occ); } @@ -1019,6 +1063,20 @@ fn model_albedo(p: vec2) -> vec3 { return textureSampleLevel(texY, samp, (q - layer.color.rg) / sprite_size(), 0.0).rgb; } +fn model_volume_color(p: vec3) -> vec3 { + let atlas_size = textureDimensions(texY); + let point = volume_local_point(p); + let lo = vec3(layer.color.rg, -layer.trail_a.y); + let extent = vec3(sprite_size(), layer.trail_a.y + layer.trail_a.z); + let uv = clamp((point - lo) / max(extent, vec3(1e-6)), vec3(0.0), vec3(1.0)); + let z = uv.z * (layer.trail_b.z - 1.0); + let z0 = i32(floor(z)); + let z1 = min(z0 + 1, i32(layer.trail_b.z) - 1); + let c0 = textureSampleLevel(texY, samp, volume_atlas_uv(uv.xy, z0, atlas_size), 0.0).rgb; + let c1 = textureSampleLevel(texY, samp, volume_atlas_uv(uv.xy, z1, atlas_size), 0.0).rgb; + return mix(c0, c1, fract(z)); +} + struct SculptMat { alb: vec3, rough: f32, @@ -1147,7 +1205,9 @@ fn model_shade(q: vec3, n: vec3, rd: vec3, L: vec3, fall: f3 } var m: SculptMat; var gloss = 1.0; - if id > 0 { + if id > 10 { + m = SculptMat(pow(model_volume_color(q), vec3(2.2)), 0.36, 0.28, 0.12, 0.24); + } else if id > 0 { let p = sculpt_point(q) - vec3(n.x, -n.y, n.z) * 0.01; m = sculpt_material(mat, p, (id - 1) / 2, (id - 1) % 2); } else { @@ -1250,7 +1310,7 @@ fn cursor_model(local: vec2) -> vec4 { if stage == STAGE_MARCH { pos = ro + rd * t; } else if stage == STAGE_NORMAL { - pos = q + 0.002 * model_tetra(k); + pos = q + model_normal_step() * model_tetra(k); } else if stage == STAGE_AO { pos = q + (0.01 + 0.0175 * f32(k)) * SCULPT_SCALE * n; } else if stage == STAGE_SELF { diff --git a/crates/compositor/tests/cursor_model_render.rs b/crates/compositor/tests/cursor_model_render.rs index 86d28bf70..0de683b14 100644 --- a/crates/compositor/tests/cursor_model_render.rs +++ b/crates/compositor/tests/cursor_model_render.rs @@ -165,7 +165,7 @@ fn cursors_dir() -> String { /// (`CURSOR_THEMES`, `src/lib/cursor/cursorThemes.ts`, sur 32). const SCULPTED: [(&str, [f32; 2], [f32; 2]); 5] = [ ("studio-ink", [6.2304, 2.0992], [12.848, 2.0704]), - ("prism-glow", [6.3456, 2.0672], [11.968, 2.0352]), + ("prism-glow", [6.3456, 2.0672], [14.6432, 8.336]), ("pop-coral", [10.4768, 2.1792], [12.3456, 2.0]), ("pixel-candy", [7.3664, 2.0], [13.376, 1.9264]), ("star-sprout", [4.7232, 2.1152], [13.1712, 2.0384]), diff --git a/design/cursors/README.md b/design/cursors/README.md index b3a21cd3c..f4e5ccc82 100644 --- a/design/cursors/README.md +++ b/design/cursors/README.md @@ -1,8 +1,13 @@ # Original OpenScreen cursor themes -These five themes replace the removed Sweezy packs. The artwork was created for -OpenScreen as transparent raster images. The PNGs in this directory are the -original high-resolution masters; there is no SVG conversion step. +These five themes replace the removed Sweezy packs. Their arrow and hand are +editable Blender meshes in `original-cursor-models.blend`; the rendered PNGs in +each theme directory are the high-resolution masters. There is no SVG conversion +step. + +The Blender file opens on an overview scene with every arrow and hand visible. +Use the numbered theme scenes in the scene selector to edit a model; the overview +contains inspection copies and is not the scene used for mesh imports or baking. | Theme | Intent | | --- | --- | @@ -13,16 +18,39 @@ original high-resolution masters; there is no SVG conversion step. | Star Sprout | An original tiny character for people who liked mascot packs | Each `source.png` contains an arrow on the left and a hand on the right. Run -`node scripts/generate-original-cursor-themes.mjs` to crop, remove low-alpha -fringe pixels, and resize them into transparent 128 × 128 PNGs under -`public/cursors//`. It prints the normalized tip hotspots; copy them into -`src/lib/cursor/cursorThemes.ts` after changing a master. - -With the 3D option on, the compositor draws each theme's arrow and hand as a -model sculpted in its shaders (`crates/compositor/src/sculpt.rs`), not from these -PNGs. The theme picker and the flat cursor keep using the PNGs. Text, resize, -move and other states remain state-accurate through the built-in art and -receive the shared 3D extrusion. +`blender --background --python scripts/model-original-cursors.py` to rebuild the +editable scenes, transparent masters, projected hotspots, and low-resolution +SDF/color volumes. The volume atlases are baked from evaluated Blender meshes; +the compositor samples them in its existing raymarcher, so the same cursor pose, +lighting, and shadows apply on Windows, macOS, and Linux. Each model stays under +2,000 triangles before baking. + +The cursor has two separate render modes. The 2D option and theme picker use the +transparent PNGs. The 3D option uses the mesh-derived SDF and material atlases; +other cursor states continue to use the shared extrusion of their PNG. + +To replace a model with a mesh, import an `.obj`, `.stl`, or `.ply` in Blender and +run the bake command against the saved scene: + +```powershell +blender design/cursors/original-cursor-models.blend --background ` + --python scripts/model-original-cursors.py -- ` + --import-mesh C:/models/my-cursor.obj --theme studio-ink --state arrow +``` + +The imported mesh is placed relative to the model root and uses its Blender +materials for the 3D mode. Keep the mesh around the existing cursor origin: +`+Z` points up, `-Y` faces the camera, and the root's `hotspot_local` property is +the point that must remain under the OS cursor. This command saves the updated +`.blend` and rewrites that model's SDF/color atlases. To rebake geometry edited +directly in the `.blend`, run `blender design/cursors/original-cursor-models.blend +--background --python scripts/model-original-cursors.py -- --bake-current`. + +Then run `node scripts/generate-original-cursor-themes.mjs` to crop, remove +low-alpha fringe pixels, and resize the transparent 2D sprites to 128 × 128 under +`public/cursors//`. It also copies the SDF/color volumes and JSON metadata +for the 3D mode. The generator prints normalized 2D hotspot values; copy those +into `src/lib/cursor/cursorThemes.ts` after changing a rendered sprite. `contact-sheet.png` shows the sprites enlarged on a light background; `dark-32px.png` shows them at their 32-pixel reference size on a dark background. diff --git a/design/cursors/contact-sheet.png b/design/cursors/contact-sheet.png index 860315a9f..272d9450b 100644 Binary files a/design/cursors/contact-sheet.png and b/design/cursors/contact-sheet.png differ diff --git a/design/cursors/dark-32px.png b/design/cursors/dark-32px.png index e44b6f0d5..6d87cc9b9 100644 Binary files a/design/cursors/dark-32px.png and b/design/cursors/dark-32px.png differ diff --git a/design/cursors/original-cursor-models.blend b/design/cursors/original-cursor-models.blend new file mode 100644 index 000000000..2bd7bb82f Binary files /dev/null and b/design/cursors/original-cursor-models.blend differ diff --git a/design/cursors/pixel-candy/arrow-color.png b/design/cursors/pixel-candy/arrow-color.png new file mode 100644 index 000000000..2e2d61b93 Binary files /dev/null and b/design/cursors/pixel-candy/arrow-color.png differ diff --git a/design/cursors/pixel-candy/arrow-sdf.json b/design/cursors/pixel-candy/arrow-sdf.json new file mode 100644 index 000000000..26f4961fc --- /dev/null +++ b/design/cursors/pixel-candy/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.734030950851246, 1.0], + "hotspot": [0.04006883452499369, 0.06369627670092512], + "top": 0.0, + "thick": 0.07581672018841357, + "maxHeight": 0.07381843701887433 +} diff --git a/design/cursors/pixel-candy/arrow-sdf.png b/design/cursors/pixel-candy/arrow-sdf.png new file mode 100644 index 000000000..d6c82dea7 Binary files /dev/null and b/design/cursors/pixel-candy/arrow-sdf.png differ diff --git a/design/cursors/pixel-candy/hotspots.json b/design/cursors/pixel-candy/hotspots.json new file mode 100644 index 000000000..37a7300a6 --- /dev/null +++ b/design/cursors/pixel-candy/hotspots.json @@ -0,0 +1,10 @@ +{ + "arrow": [ + 282.48, + 253.44 + ], + "pointer": [ + 1330.54, + 155.74 + ] +} diff --git a/design/cursors/pixel-candy/pointer-color.png b/design/cursors/pixel-candy/pointer-color.png new file mode 100644 index 000000000..88c459d19 Binary files /dev/null and b/design/cursors/pixel-candy/pointer-color.png differ diff --git a/design/cursors/pixel-candy/pointer-sdf.json b/design/cursors/pixel-candy/pointer-sdf.json new file mode 100644 index 000000000..5a713087e --- /dev/null +++ b/design/cursors/pixel-candy/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9327730188136261, 1.0], + "hotspot": [0.38544599176239186, 0.050504995651785504], + "top": 0.0, + "thick": 0.029411764705882353, + "maxHeight": 0.12022351132899409 +} diff --git a/design/cursors/pixel-candy/pointer-sdf.png b/design/cursors/pixel-candy/pointer-sdf.png new file mode 100644 index 000000000..9ff1cd552 Binary files /dev/null and b/design/cursors/pixel-candy/pointer-sdf.png differ diff --git a/design/cursors/pixel-candy/source.png b/design/cursors/pixel-candy/source.png index 79b5c859d..0f0e0f7ca 100644 Binary files a/design/cursors/pixel-candy/source.png and b/design/cursors/pixel-candy/source.png differ diff --git a/design/cursors/pop-coral/arrow-color.png b/design/cursors/pop-coral/arrow-color.png new file mode 100644 index 000000000..c5e8664a5 Binary files /dev/null and b/design/cursors/pop-coral/arrow-color.png differ diff --git a/design/cursors/pop-coral/arrow-sdf.json b/design/cursors/pop-coral/arrow-sdf.json new file mode 100644 index 000000000..f75212946 --- /dev/null +++ b/design/cursors/pop-coral/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7185188746637131, 1.0], + "hotspot": [0.040701131649228305, 0.02747064307943106], + "top": 0.0, + "thick": 0.1405290229781066, + "maxHeight": 0.0664581862991136 +} diff --git a/design/cursors/pop-coral/arrow-sdf.png b/design/cursors/pop-coral/arrow-sdf.png new file mode 100644 index 000000000..9b10fbc0d Binary files /dev/null and b/design/cursors/pop-coral/arrow-sdf.png differ diff --git a/design/cursors/pop-coral/hotspots.json b/design/cursors/pop-coral/hotspots.json new file mode 100644 index 000000000..37a7300a6 --- /dev/null +++ b/design/cursors/pop-coral/hotspots.json @@ -0,0 +1,10 @@ +{ + "arrow": [ + 282.48, + 253.44 + ], + "pointer": [ + 1330.54, + 155.74 + ] +} diff --git a/design/cursors/pop-coral/pointer-color.png b/design/cursors/pop-coral/pointer-color.png new file mode 100644 index 000000000..332f97708 Binary files /dev/null and b/design/cursors/pop-coral/pointer-color.png differ diff --git a/design/cursors/pop-coral/pointer-sdf.json b/design/cursors/pop-coral/pointer-sdf.json new file mode 100644 index 000000000..de60bd3da --- /dev/null +++ b/design/cursors/pop-coral/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9386528198789045, 1.0], + "hotspot": [0.4150400053111568, 0.7552755949031005], + "top": 0.0, + "thick": 0.029411764705882353, + "maxHeight": 0.24023877839599173 +} diff --git a/design/cursors/pop-coral/pointer-sdf.png b/design/cursors/pop-coral/pointer-sdf.png new file mode 100644 index 000000000..5bdfde50d Binary files /dev/null and b/design/cursors/pop-coral/pointer-sdf.png differ diff --git a/design/cursors/pop-coral/source.png b/design/cursors/pop-coral/source.png index 74a642e90..f118548e2 100644 Binary files a/design/cursors/pop-coral/source.png and b/design/cursors/pop-coral/source.png differ diff --git a/design/cursors/prism-glow/arrow-color.png b/design/cursors/prism-glow/arrow-color.png new file mode 100644 index 000000000..4633c04f4 Binary files /dev/null and b/design/cursors/prism-glow/arrow-color.png differ diff --git a/design/cursors/prism-glow/arrow-sdf.json b/design/cursors/prism-glow/arrow-sdf.json new file mode 100644 index 000000000..bba446864 --- /dev/null +++ b/design/cursors/prism-glow/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7184577753737912, 1.0], + "hotspot": [0.040720327501619115, 0.02716068074425252], + "top": 0.0, + "thick": 0.14289131847599995, + "maxHeight": 0.10763340078405259 +} diff --git a/design/cursors/prism-glow/arrow-sdf.png b/design/cursors/prism-glow/arrow-sdf.png new file mode 100644 index 000000000..2add3bc22 Binary files /dev/null and b/design/cursors/prism-glow/arrow-sdf.png differ diff --git a/design/cursors/prism-glow/hotspots.json b/design/cursors/prism-glow/hotspots.json new file mode 100644 index 000000000..57565ab0e --- /dev/null +++ b/design/cursors/prism-glow/hotspots.json @@ -0,0 +1,10 @@ +{ + "arrow": [ + 282.48, + 253.44 + ], + "pointer": [ + 1296.36, + 230.68 + ] +} diff --git a/design/cursors/prism-glow/pointer-color.png b/design/cursors/prism-glow/pointer-color.png new file mode 100644 index 000000000..cfebeae03 Binary files /dev/null and b/design/cursors/prism-glow/pointer-color.png differ diff --git a/design/cursors/prism-glow/pointer-sdf.json b/design/cursors/prism-glow/pointer-sdf.json new file mode 100644 index 000000000..0389fc905 --- /dev/null +++ b/design/cursors/prism-glow/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9638013470344713, 1.0], + "hotspot": [0.4370998321974549, 0.2325580885392326], + "top": 0.0, + "thick": 0.029411750808077647, + "maxHeight": 0.36130049970486233 +} diff --git a/design/cursors/prism-glow/pointer-sdf.png b/design/cursors/prism-glow/pointer-sdf.png new file mode 100644 index 000000000..5cbae1426 Binary files /dev/null and b/design/cursors/prism-glow/pointer-sdf.png differ diff --git a/design/cursors/prism-glow/source.png b/design/cursors/prism-glow/source.png index e604db40b..4acc99008 100644 Binary files a/design/cursors/prism-glow/source.png and b/design/cursors/prism-glow/source.png differ diff --git a/design/cursors/prism-hand/README.md b/design/cursors/prism-hand/README.md new file mode 100644 index 000000000..9e8e4afa8 --- /dev/null +++ b/design/cursors/prism-hand/README.md @@ -0,0 +1,39 @@ +# Prism Glow — standalone hand study + +Open `prism-glow-hand.blend`. The selected object is the complete, editable hand. +This study reconstructs only the Prism Glow hand from the second row of +`../3d-concept.png`. Its silhouette and principal crown vertices were placed +from reference-image landmarks. The hand has a crown, a narrow girdle and a +faceted back; it is a closed volume, not an image on a plane. The cursor-scale +OBJ from this file feeds both the 2D sprite render and the 3D SDF bake in the app. + +- 168 vertices / 332 triangles; zero non-manifold edges. +- Flat polygon normals retain the cut-crystal facets. +- Seven tinted transmissive materials with a small emission contribution, lit by a Cycles studio setup. The reference PNG is packed into the Blender file. +- `prism-hand-render.png`: actual Blender render. +- `prism-glow-hand-runtime.png`: frame rendered by the Windows D3D11 compositor, + with the mesh's shadow on a synthetic screen. +- `prism-glow-hand.obj` + `.mtl`: mesh interchange export at the app's cursor + scale. OBJ does not preserve Blender's transmission shader; the app bakes the + face colors into its material atlas. +- `mesh-info.json`: topology statistics from the generated mesh. + +Rebuild from the repository root: + +```powershell +& 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' --background --python scripts/model-prism-hand.py +``` + +To update the application's Blender scene and bake both cursor modes, first +generate the editable theme scenes, then run: + +```powershell +& 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' --background --python scripts/model-original-cursors.py +& 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' --background --python scripts/export-prism-hand-cursor.py +& 'C:\Program Files\Blender Foundation\Blender 5.2\blender.exe' design/cursors/original-cursor-models.blend --background --python scripts/model-original-cursors.py -- --import-mesh design/cursors/prism-hand/prism-glow-hand.obj --theme prism-glow --state pointer +node scripts/generate-original-cursor-themes.mjs +``` + +The 2D sprite is rendered in Blender's cursor scene. The 3D mode uses the +resulting 64 x 64 x 48 SDF and color atlases; the app does not use Cycles +refraction at runtime. diff --git a/design/cursors/prism-hand/mesh-info.json b/design/cursors/prism-hand/mesh-info.json new file mode 100644 index 000000000..e4cc7c962 --- /dev/null +++ b/design/cursors/prism-hand/mesh-info.json @@ -0,0 +1,5 @@ +{ + "vertices": 168, + "triangles": 332, + "non_manifold_edges": 0 +} diff --git a/design/cursors/prism-hand/prism-glow-hand-runtime.png b/design/cursors/prism-hand/prism-glow-hand-runtime.png new file mode 100644 index 000000000..385a95d08 Binary files /dev/null and b/design/cursors/prism-hand/prism-glow-hand-runtime.png differ diff --git a/design/cursors/prism-hand/prism-glow-hand.blend b/design/cursors/prism-hand/prism-glow-hand.blend new file mode 100644 index 000000000..398dab8d0 Binary files /dev/null and b/design/cursors/prism-hand/prism-glow-hand.blend differ diff --git a/design/cursors/prism-hand/prism-glow-hand.mtl b/design/cursors/prism-hand/prism-glow-hand.mtl new file mode 100644 index 000000000..83f799580 --- /dev/null +++ b/design/cursors/prism-hand/prism-glow-hand.mtl @@ -0,0 +1,72 @@ +# Blender 5.2.1 LTS MTL File: 'prism-glow-hand.blend' +# www.blender.org + +newmtl Amethyst +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.430000 0.035000 0.880000 +Ks 0.500000 0.500000 0.500000 +Ke 0.077400 0.006300 0.158400 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Cyan +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.005000 0.700000 0.950000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000900 0.126000 0.171000 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Deep_blue +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.003000 0.013000 0.190000 +Ks 0.500000 0.500000 0.500000 +Ke 0.000540 0.002340 0.034200 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Electric_blue +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.008000 0.170000 0.950000 +Ks 0.500000 0.500000 0.500000 +Ke 0.001440 0.030600 0.171000 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Ice +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.180000 0.790000 1.000000 +Ks 0.500000 0.500000 0.500000 +Ke 0.032400 0.142200 0.180000 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Sapphire +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.008000 0.035000 0.620000 +Ks 0.500000 0.500000 0.500000 +Ke 0.001440 0.006300 0.111600 +Ni 1.650000 +d 1.000000 +illum 2 + +newmtl Violet +Ns 950.625000 +Ka 1.000000 1.000000 1.000000 +Kd 0.200000 0.012000 0.720000 +Ks 0.500000 0.500000 0.500000 +Ke 0.036000 0.002160 0.129600 +Ni 1.650000 +d 1.000000 +illum 2 diff --git a/design/cursors/prism-hand/prism-glow-hand.obj b/design/cursors/prism-hand/prism-glow-hand.obj new file mode 100644 index 000000000..856310953 --- /dev/null +++ b/design/cursors/prism-hand/prism-glow-hand.obj @@ -0,0 +1,734 @@ +# Blender 5.2.1 LTS +# www.blender.org +mtllib prism-glow-hand.mtl +o Prism_Glow_-_Hand_(cursor_scale) +v -0.435000 0.691250 0.090000 +v -0.435000 1.411250 0.090000 +v -0.337500 1.531250 0.090000 +v -0.172500 1.576250 0.090000 +v 0.007500 1.561250 0.090000 +v 0.127500 1.463750 0.090000 +v 0.157500 0.998750 0.090000 +v 0.247500 1.006250 0.090000 +v 0.367500 0.938750 0.090000 +v 0.420000 0.871250 0.090000 +v 0.495000 0.893750 0.090000 +v 0.615000 0.833750 0.090000 +v 0.667500 0.743750 0.090000 +v 0.742500 0.766250 0.090000 +v 0.877500 0.698750 0.090000 +v 0.937500 0.623750 0.090000 +v 0.930000 0.248750 0.090000 +v 0.810000 -0.036250 0.090000 +v 0.607500 -0.336250 0.090000 +v -0.292500 -0.373750 0.090000 +v -0.405000 -0.313750 0.090000 +v -0.652500 0.091250 0.090000 +v -0.907500 0.473750 0.090000 +v -0.937500 0.766250 0.090000 +v -0.780000 0.901250 0.090000 +v -0.630000 0.893750 0.090000 +v -0.517500 0.788750 0.090000 +v -0.315000 1.396250 0.219244 +v -0.165000 1.441250 0.295988 +v -0.007500 1.403750 0.238430 +v -0.097500 1.276250 0.284477 +v -0.315000 0.961250 0.219244 +v -0.165000 0.901250 0.295988 +v 0.037500 0.758750 0.215407 +v -0.315000 0.608750 0.306041 +v -0.127500 0.541250 0.333419 +v -0.270000 0.308750 0.344199 +v 0.315000 0.143750 0.333067 +v -0.045000 -0.081250 0.323020 +v -0.217500 -0.246250 0.273730 +v 0.202500 -0.253750 0.271462 +v 0.517500 -0.253750 0.240441 +v 0.705000 0.188750 0.247046 +v 0.562500 0.383750 0.285303 +v 0.285000 0.556250 0.317078 +v 0.180000 0.886250 0.237094 +v 0.277500 0.796250 0.262044 +v 0.480000 0.751250 0.252505 +v 0.480000 0.556250 0.287702 +v 0.712500 0.638750 0.225754 +v 0.810000 0.473750 0.208325 +v -0.787500 0.766250 0.197553 +v -0.667500 0.706250 0.231042 +v -0.765000 0.518750 0.225289 +v -0.510000 0.451250 0.296254 +v -0.502500 0.233750 0.305666 +v -0.352500 -0.081250 0.301581 +v -0.450225 0.699650 -0.018750 +v -0.450225 1.444850 -0.018750 +v -0.349313 1.569050 -0.018750 +v -0.178538 1.615625 -0.018750 +v 0.007763 1.600100 -0.018750 +v 0.131963 1.499187 -0.018750 +v 0.163013 1.017913 -0.018750 +v 0.256162 1.025675 -0.018750 +v 0.380363 0.955813 -0.018750 +v 0.434700 0.885950 -0.018750 +v 0.512325 0.909238 -0.018750 +v 0.636525 0.847138 -0.018750 +v 0.690863 0.753988 -0.018750 +v 0.768487 0.777275 -0.018750 +v 0.908213 0.707412 -0.018750 +v 0.970313 0.629788 -0.018750 +v 0.962550 0.241662 -0.018750 +v 0.838350 -0.053313 -0.018750 +v 0.628762 -0.363813 -0.018750 +v -0.302737 -0.402625 -0.018750 +v -0.419175 -0.340525 -0.018750 +v -0.675337 0.078650 -0.018750 +v -0.939263 0.474537 -0.018750 +v -0.970313 0.777275 -0.018750 +v -0.807300 0.917000 -0.018750 +v -0.652050 0.909238 -0.018750 +v -0.535613 0.800563 -0.018750 +v -0.421950 0.684050 -0.210000 +v -0.421950 1.382450 -0.210000 +v -0.327375 1.498850 -0.210000 +v -0.167325 1.542500 -0.210000 +v 0.007275 1.527950 -0.210000 +v 0.123675 1.433375 -0.210000 +v 0.152775 0.982325 -0.210000 +v 0.240075 0.989600 -0.210000 +v 0.356475 0.924125 -0.210000 +v 0.407400 0.858650 -0.210000 +v 0.480150 0.880475 -0.210000 +v 0.596550 0.822275 -0.210000 +v 0.647475 0.734975 -0.210000 +v 0.720225 0.756800 -0.210000 +v 0.851175 0.691325 -0.210000 +v 0.909375 0.618575 -0.210000 +v 0.902100 0.254825 -0.210000 +v 0.785700 -0.021625 -0.210000 +v 0.589275 -0.312625 -0.210000 +v -0.283725 -0.349000 -0.210000 +v -0.392850 -0.290800 -0.210000 +v -0.632925 0.102050 -0.210000 +v -0.880275 0.473075 -0.210000 +v -0.909375 0.756800 -0.210000 +v -0.756600 0.887750 -0.210000 +v -0.611100 0.880475 -0.210000 +v -0.501975 0.778625 -0.210000 +v -0.361050 0.650450 -0.345000 +v -0.361050 1.248050 -0.345000 +v -0.280125 1.347650 -0.345000 +v -0.143175 1.385000 -0.345000 +v 0.006225 1.372550 -0.345000 +v 0.105825 1.291625 -0.345000 +v 0.130725 0.905675 -0.345000 +v 0.205425 0.911900 -0.345000 +v 0.305025 0.855875 -0.345000 +v 0.348600 0.799850 -0.345000 +v 0.410850 0.818525 -0.345000 +v 0.510450 0.768725 -0.345000 +v 0.554025 0.694025 -0.345000 +v 0.616275 0.712700 -0.345000 +v 0.728325 0.656675 -0.345000 +v 0.778125 0.594425 -0.345000 +v 0.771900 0.283175 -0.345000 +v 0.672300 0.046625 -0.345000 +v 0.504225 -0.202375 -0.345000 +v -0.242775 -0.233500 -0.345000 +v -0.336150 -0.183700 -0.345000 +v -0.541575 0.152450 -0.345000 +v -0.753225 0.469925 -0.345000 +v -0.778125 0.712700 -0.345000 +v -0.647400 0.824750 -0.345000 +v -0.522900 0.818525 -0.345000 +v -0.429525 0.731375 -0.345000 +v -0.261450 1.235600 -0.367500 +v -0.136950 1.272950 -0.367500 +v -0.006225 1.241825 -0.367500 +v -0.080925 1.136000 -0.367500 +v -0.261450 0.874550 -0.367500 +v -0.136950 0.824750 -0.367500 +v 0.031125 0.706475 -0.367500 +v -0.261450 0.581975 -0.367500 +v -0.105825 0.525950 -0.367500 +v -0.224100 0.332975 -0.367500 +v 0.261450 0.196025 -0.367500 +v -0.037350 0.009275 -0.367500 +v -0.180525 -0.127675 -0.367500 +v 0.168075 -0.133900 -0.367500 +v 0.429525 -0.133900 -0.367500 +v 0.585150 0.233375 -0.367500 +v 0.466875 0.395225 -0.367500 +v 0.236550 0.538400 -0.367500 +v 0.149400 0.812300 -0.367500 +v 0.230325 0.737600 -0.367500 +v 0.398400 0.700250 -0.367500 +v 0.398400 0.538400 -0.367500 +v 0.591375 0.606875 -0.367500 +v 0.672300 0.469925 -0.367500 +v -0.653625 0.712700 -0.367500 +v -0.554025 0.662900 -0.367500 +v -0.634950 0.507275 -0.367500 +v -0.423300 0.451250 -0.367500 +v -0.417075 0.270725 -0.367500 +v -0.292575 0.009275 -0.367500 +vn -0.4819 0.5622 0.6721 +vn -0.2266 0.0881 0.9700 +vn -0.5823 0.0381 0.8121 +vn -0.6753 -0.0693 0.7343 +vn -0.5707 -0.3805 0.7277 +vn 0.0311 0.6218 0.7826 +vn 0.0709 0.5905 0.8039 +vn 0.4470 0.4789 0.7555 +vn 0.4912 0.4156 0.7655 +vn 0.4769 0.4824 0.7348 +vn -0.2430 0.0967 0.9652 +vn -0.2599 0.0328 0.9651 +vn -0.6224 -0.3803 0.6841 +vn -0.2214 0.8119 0.5402 +vn -0.5051 0.5514 0.6639 +vn -0.3905 -0.2221 0.8934 +vn -0.2873 -0.0000 0.9578 +vn -0.4200 0.1033 0.9016 +vn -0.1116 0.0913 0.9895 +vn -0.1047 0.1100 0.9884 +vn -0.0692 -0.0939 0.9932 +vn -0.0679 -0.2198 0.9732 +vn -0.6079 0.4939 0.6217 +vn -0.7328 -0.0000 0.6804 +vn -0.5042 -0.1802 0.8446 +vn -0.7313 0.3941 0.5567 +vn -0.5430 0.6384 0.5454 +vn -0.1344 0.1052 0.9853 +vn -0.1749 0.0366 0.9839 +vn -0.1383 -0.0786 0.9873 +vn -0.6626 -0.3243 0.6751 +vn -0.6713 -0.4102 0.6173 +vn -0.4939 -0.5216 0.6957 +vn -0.3667 -0.6875 0.6268 +vn 0.4717 0.5805 0.6637 +vn 0.7294 0.0471 0.6824 +vn -0.6054 0.5848 0.5399 +vn 0.5805 0.4515 0.6776 +vn 0.2076 0.8220 0.5303 +vn 0.0957 0.2253 0.9696 +vn -0.0656 0.7877 0.6125 +vn 0.3579 0.6363 0.6834 +vn 0.3795 0.6052 0.6998 +vn -0.1989 0.0558 0.9784 +vn -0.2182 0.2116 0.9527 +vn 0.3260 0.6521 0.6845 +vn 0.4938 0.3950 0.7747 +vn 0.5149 0.2166 0.8294 +vn 0.6888 -0.0138 0.7248 +vn 0.5882 -0.1078 0.8015 +vn 0.5982 -0.2519 0.7608 +vn -0.2204 0.7348 0.6414 +vn -0.1052 0.7694 0.6300 +vn 0.2250 0.0941 0.9698 +vn 0.2759 0.0617 0.9592 +vn 0.2891 0.0235 0.9570 +vn 0.5877 -0.2604 0.7660 +vn 0.6464 -0.4363 0.6260 +vn -0.2229 0.7431 0.6310 +vn 0.3449 0.6897 0.6367 +vn 0.6190 0.3611 0.6975 +vn 0.6520 0.1347 0.7461 +vn 0.1466 0.1757 0.9735 +vn 0.1487 0.0574 0.9872 +vn 0.1435 0.0487 0.9885 +vn 0.2187 -0.0315 0.9753 +vn 0.2262 -0.1103 0.9678 +vn 0.0964 -0.1790 0.9791 +vn 0.0495 -0.8630 0.5029 +vn 0.0696 0.8355 0.5450 +vn 0.3961 0.6095 0.6868 +vn 0.3593 0.0821 0.9296 +vn 0.5452 -0.0836 0.8341 +vn 0.5526 -0.0740 0.8302 +vn 0.4825 0.2039 0.8519 +vn 0.4488 0.1385 0.8828 +vn 0.0181 0.4662 0.8845 +vn 0.0380 0.0415 0.9984 +vn 0.0274 0.0296 0.9992 +vn 0.0044 -0.0517 0.9987 +vn 0.0822 -0.1753 0.9811 +vn 0.0001 -0.2863 0.9581 +vn -0.0115 -0.8193 0.5733 +vn 0.0365 -0.8765 0.4800 +vn -0.9903 -0.0000 0.1386 +vn -0.7427 0.6034 0.2904 +vn -0.2473 0.9066 0.3420 +vn 0.0782 0.9388 0.3356 +vn 0.6074 0.7476 0.2685 +vn 0.9960 0.0643 0.0618 +vn -0.0818 0.9822 0.1689 +vn -0.0819 0.9822 0.1689 +vn 0.4812 0.8555 0.1911 +vn 0.7755 0.6032 0.1864 +vn -0.2862 0.9539 0.0903 +vn 0.4386 0.8773 0.1948 +vn 0.8412 0.4907 0.2269 +vn -0.2872 0.9574 0.0284 +vn 0.4387 0.8775 0.1938 +vn 0.7538 0.6031 0.2609 +vn 0.9575 -0.0191 0.2878 +vn 0.8825 -0.3716 0.2884 +vn 0.7930 -0.5353 0.2907 +vn 0.0403 -0.9667 0.2529 +vn -0.4533 -0.8500 0.2684 +vn -0.8298 -0.5071 0.2330 +vn -0.8093 -0.5395 0.2325 +vn -0.9555 -0.0980 0.2784 +vn -0.6278 0.7324 0.2637 +vn 0.0495 0.9901 0.1310 +vn 0.6819 0.7306 -0.0342 +vn 0.7621 0.6449 -0.0569 +vn 0.7622 0.6449 -0.0569 +vn -0.9892 -0.0000 -0.1463 +vn -0.7391 0.6005 -0.3052 +vn -0.2456 0.9006 -0.3587 +vn 0.0777 0.9327 -0.3521 +vn 0.6049 0.7445 -0.2824 +vn 0.9958 0.0642 -0.0653 +vn -0.0817 0.9806 -0.1781 +vn 0.4802 0.8537 -0.2014 +vn 0.7740 0.6020 -0.1964 +vn -0.2860 0.9535 -0.0953 +vn 0.4377 0.8754 -0.2053 +vn 0.8388 0.4893 -0.2389 +vn -0.2872 0.9574 -0.0300 +vn 0.4378 0.8756 -0.2042 +vn 0.7509 0.6007 -0.2745 +vn 0.9530 -0.0191 -0.3025 +vn 0.8783 -0.3698 -0.3031 +vn 0.7892 -0.5327 -0.3055 +vn 0.0401 -0.9631 -0.2661 +vn -0.4515 -0.8465 -0.2822 +vn -0.8272 -0.5055 -0.2453 +vn -0.8068 -0.5378 -0.2447 +vn -0.9512 -0.0976 -0.2926 +vn -0.6253 0.7295 -0.2773 +vn 0.0495 0.9892 -0.1382 +vn 0.6819 0.7306 0.0361 +vn 0.7620 0.6448 0.0601 +vn -0.9115 -0.0000 -0.4112 +vn -0.5549 0.4508 -0.6992 +vn -0.5549 0.4509 -0.6992 +vn -0.1707 0.6260 -0.7609 +vn 0.0546 0.6546 -0.7540 +vn 0.4691 0.5774 -0.6683 +vn 0.9786 0.0631 -0.1957 +vn -0.0727 0.8724 -0.4834 +vn 0.4153 0.7383 -0.5315 +vn 0.6735 0.5238 -0.5215 +vn -0.2758 0.9194 -0.2803 +vn 0.3767 0.7533 -0.5391 +vn 0.6908 0.4029 -0.6004 +vn -0.2861 0.9538 -0.0912 +vn -0.2862 0.9538 -0.0912 +vn 0.3773 0.7545 -0.5370 +vn 0.5889 0.4711 -0.6568 +vn 0.5888 0.4711 -0.6568 +vn 0.7182 -0.0144 -0.6957 +vn 0.6614 -0.2785 -0.6964 +vn 0.5922 -0.3998 -0.6996 +vn 0.0318 -0.7642 -0.6442 +vn -0.3502 -0.6566 -0.6680 +vn -0.6754 -0.4127 -0.6111 +vn -0.6592 -0.4395 -0.6101 +vn -0.7271 -0.0746 -0.6825 +vn -0.4884 0.5698 -0.6609 +vn 0.0459 0.9190 -0.3917 +vn 0.6782 0.7266 0.1096 +vn 0.7508 0.6353 0.1806 +vn -0.1741 0.2032 -0.9635 +vn -0.0000 -0.0000 -1.0000 +vn -0.1778 -0.0162 -0.9839 +vn -0.1812 -0.0186 -0.9833 +vn -0.1544 -0.1029 -0.9826 +vn 0.0098 0.1963 -0.9805 +vn 0.0650 0.1300 -0.9894 +vn 0.1122 0.1202 -0.9864 +vn 0.1217 0.1030 -0.9872 +vn 0.1202 0.0743 -0.9900 +vn -0.0956 -0.0880 -0.9915 +vn -0.0528 0.1938 -0.9796 +vn -0.0563 0.1875 -0.9806 +vn -0.1983 0.1611 -0.9668 +vn -0.2204 -0.0000 -0.9754 +vn -0.0340 -0.0849 -0.9958 +vn -0.1642 0.0842 -0.9828 +vn -0.1198 0.1483 -0.9817 +vn -0.1233 -0.0587 -0.9906 +vn -0.1375 -0.0840 -0.9869 +vn -0.1105 -0.0904 -0.9897 +vn -0.0849 -0.1592 -0.9836 +vn 0.1271 0.1565 -0.9795 +vn 0.1916 0.0124 -0.9814 +vn -0.1764 0.1971 -0.9644 +vn 0.1330 0.1035 -0.9857 +vn 0.0547 0.2459 -0.9677 +vn -0.0192 0.2306 -0.9729 +vn 0.0947 0.1684 -0.9812 +vn 0.1095 0.1186 -0.9869 +vn 0.0930 0.1861 -0.9781 +vn 0.1256 0.1005 -0.9870 +vn 0.1241 0.0733 -0.9896 +vn 0.2128 -0.0043 -0.9771 +vn 0.1323 -0.0487 -0.9900 +vn 0.1343 -0.0565 -0.9893 +vn -0.0668 0.2225 -0.9726 +vn -0.0774 0.2180 -0.9729 +vn 0.1339 -0.0567 -0.9894 +vn 0.1816 -0.1225 -0.9757 +vn -0.0577 0.1924 -0.9796 +vn 0.0886 0.1771 -0.9802 +vn 0.1459 0.0851 -0.9856 +vn 0.1431 -0.0000 -0.9897 +vn -0.0000 -0.3122 -0.9500 +vn 0.0165 0.1977 -0.9801 +vn 0.0395 0.1658 -0.9854 +vn 0.0600 -0.0423 -0.9973 +vn 0.0885 -0.0159 -0.9959 +vn 0.0585 0.0831 -0.9948 +vn -0.0037 -0.2059 -0.9786 +vn 0.0110 -0.2632 -0.9647 +s 0 +usemtl Sapphire +f 52//1 25//1 24//1 +f 33//19 35//19 36//19 +f 39//22 57//22 40//22 +f 3//23 2//23 28//23 +f 6//36 30//36 7//36 +f 10//39 47//39 48//39 +f 9//43 46//43 47//43 +f 16//49 51//49 17//49 +f 43//50 17//50 51//50 +f 43//67 38//67 42//67 +f 29//70 5//70 4//70 +f 33//74 7//74 31//74 +f 4//88 5//88 62//88 +f 4//88 62//88 61//88 +f 9//94 10//94 67//94 +f 11//96 12//96 69//96 +f 13//98 71//98 70//98 +f 15//100 16//100 73//100 +f 17//102 75//102 74//102 +f 20//105 21//105 78//105 +f 24//109 82//109 81//109 +f 59//115 87//115 86//115 +f 60//116 88//116 87//116 +f 63//119 91//119 90//119 +f 65//121 66//121 93//121 +f 67//123 95//123 94//123 +f 70//126 71//126 98//126 +f 71//127 72//127 99//127 +f 72//128 73//128 100//128 +f 72//128 100//128 99//128 +f 73//129 101//129 100//129 +f 74//130 75//130 102//130 +f 78//134 79//134 106//134 +f 80//136 81//136 108//136 +f 81//137 109//137 108//137 +f 82//138 83//138 110//138 +f 83//139 84//139 111//139 +f 83//139 111//139 110//139 +f 93//150 94//150 121//150 +f 100//159 128//159 127//159 +f 107//166 135//166 134//166 +f 108//167 136//167 135//167 +f 109//168 110//168 137//168 +f 111//170 85//170 112//170 +f 137//177 164//177 163//177 +f 164//179 138//179 112//179 +f 165//172 166//172 167//172 +f 113//185 139//185 143//185 +f 132//191 168//191 151//191 +f 132//192 151//192 131//192 +f 126//201 161//201 125//201 +f 159//212 122//212 123//212 +f 156//172 160//172 155//172 +f 149//172 154//172 153//172 +f 140//217 116//217 141//217 +f 145//172 147//172 144//172 +f 147//172 156//172 149//172 +f 112//185 113//185 143//185 +usemtl Electric_blue +f 53//2 52//2 54//2 +f 54//5 23//5 22//5 +f 52//6 26//6 25//6 +f 53//7 26//7 52//7 +f 1//10 53//10 55//10 +f 54//11 55//11 53//11 +f 55//12 54//12 56//12 +f 4//14 3//14 29//14 +f 28//15 29//15 3//15 +f 32//17 31//17 28//17 +f 31//18 32//18 33//18 +f 57//21 39//21 37//21 +f 33//26 1//26 35//26 +f 55//27 35//27 1//27 +f 21//32 57//32 22//32 +f 40//34 21//34 20//34 +f 30//35 6//35 5//35 +f 50//46 15//46 14//46 +f 15//47 50//47 16//47 +f 44//54 50//54 49//54 +f 48//59 11//59 10//59 +f 11//60 48//60 12//60 +f 12//61 48//61 13//61 +f 49//64 45//64 44//64 +f 41//68 42//68 38//68 +f 42//69 41//69 19//69 +f 36//76 34//76 33//76 +f 37//79 38//79 36//79 +f 41//84 20//84 19//84 +f 1//85 2//85 59//85 +f 3//87 4//87 61//87 +f 3//87 61//87 60//87 +f 7//92 65//92 64//92 +f 11//96 69//96 68//96 +f 12//97 70//97 69//97 +f 14//99 72//99 71//99 +f 18//103 19//103 76//103 +f 18//103 76//103 75//103 +f 19//104 20//104 77//104 +f 25//110 83//110 82//110 +f 63//119 64//119 91//119 +f 64//120 92//120 91//120 +f 65//121 93//121 92//121 +f 67//123 68//123 95//123 +f 68//124 69//124 96//124 +f 71//127 99//127 98//127 +f 77//133 105//133 104//133 +f 79//135 107//135 106//135 +f 84//140 85//140 111//140 +f 85//141 113//141 112//141 +f 89//146 90//146 117//146 +f 89//146 117//146 116//146 +f 92//149 93//149 120//149 +f 95//152 96//152 123//152 +f 99//157 100//157 127//157 +f 102//161 103//161 130//161 +f 104//163 105//163 132//163 +f 105//164 133//164 132//164 +f 166//172 165//172 164//172 +f 142//172 143//172 139//172 +f 146//172 144//172 147//172 +f 168//190 132//190 133//190 +f 145//195 118//195 157//195 +f 158//196 120//196 121//196 +f 159//172 156//172 158//172 +f 158//172 145//172 157//172 +f 145//172 158//172 156//172 +f 162//204 127//204 128//204 +f 154//206 128//206 129//206 +f 124//207 125//207 161//207 +f 122//211 159//211 121//211 +f 116//216 140//216 115//216 +f 141//172 142//172 140//172 +f 144//220 118//220 145//220 +f 147//172 145//172 156//172 +f 149//172 148//172 147//172 +f 149//172 150//172 148//172 +f 150//172 149//172 152//172 +usemtl Cyan +f 32//25 1//25 33//25 +f 37//30 56//30 57//30 +f 7//37 34//37 46//37 +f 9//38 47//38 10//38 +f 8//42 46//42 9//42 +f 5//71 29//71 30//71 +f 31//72 30//72 29//72 +f 2//86 3//86 60//86 +f 6//90 64//90 63//90 +f 21//106 79//106 78//106 +f 25//110 26//110 83//110 +f 26//111 84//111 83//111 +f 58//114 86//114 85//114 +f 60//116 61//116 88//116 +f 61//117 89//117 88//117 +f 66//122 67//122 94//122 +f 76//132 77//132 104//132 +f 77//133 78//133 105//133 +f 78//134 106//134 105//134 +f 80//136 108//136 107//136 +f 82//138 110//138 109//138 +f 86//143 114//143 113//143 +f 90//147 91//147 118//147 +f 91//148 119//148 118//148 +f 93//150 121//150 120//150 +f 104//163 132//163 131//163 +f 106//165 107//165 134//165 +f 110//169 111//169 138//169 +f 136//171 163//171 135//171 +f 163//172 164//172 165//172 +f 165//174 134//174 135//174 +f 137//176 163//176 136//176 +f 114//182 115//182 140//182 +f 140//183 139//183 114//183 +f 150//172 168//172 148//172 +f 168//189 133//189 167//189 +f 119//198 157//198 118//198 +f 157//199 119//199 120//199 +f 155//172 149//172 156//172 +f 152//215 153//215 130//215 +f 142//218 141//218 118//218 +f 152//172 151//172 150//172 +f 131//222 152//222 130//222 +usemtl Ice +f 26//8 53//8 27//8 +f 27//9 53//9 1//9 +f 22//13 56//13 54//13 +f 37//20 36//20 35//20 +f 28//24 2//24 32//24 +f 57//33 21//33 40//33 +f 47//45 34//45 45//45 +f 30//73 31//73 7//73 +f 7//75 33//75 34//75 +f 34//77 36//77 45//77 +f 38//81 39//81 41//81 +f 20//83 41//83 40//83 +f 2//24 1//24 32//24 +f 1//85 59//85 58//85 +f 2//86 60//86 59//86 +f 5//89 6//89 63//89 +f 5//89 63//89 62//89 +f 7//91 8//91 65//91 +f 9//94 67//94 66//94 +f 20//105 78//105 77//105 +f 22//107 80//107 79//107 +f 23//108 24//108 81//108 +f 23//108 81//108 80//108 +f 24//109 25//109 82//109 +f 61//117 62//117 89//117 +f 62//118 63//118 90//118 +f 62//118 90//118 89//118 +f 64//120 65//120 92//120 +f 66//122 94//122 93//122 +f 76//132 104//132 103//132 +f 87//144 88//144 115//144 +f 87//144 115//144 114//144 +f 88//145 89//145 116//145 +f 90//147 118//147 117//147 +f 92//149 120//149 119//149 +f 94//151 95//151 122//151 +f 103//162 131//162 130//162 +f 107//166 108//166 135//166 +f 108//167 109//167 136//167 +f 111//170 112//170 138//170 +f 134//175 165//175 133//175 +f 164//178 137//178 138//178 +f 164//180 112//180 166//180 +f 167//181 133//181 165//181 +f 143//172 142//172 144//172 +f 147//172 148//172 146//172 +f 113//184 114//184 139//184 +f 112//187 144//187 146//187 +f 146//188 166//188 112//188 +f 166//172 146//172 148//172 +f 117//193 141//193 116//193 +f 141//194 117//194 118//194 +f 160//172 156//172 159//172 +f 149//172 155//172 154//172 +f 153//172 152//172 149//172 +f 118//219 144//219 142//219 +f 152//221 131//221 151//221 +usemtl Violet +f 29//16 28//16 31//16 +f 45//40 48//40 47//40 +f 46//41 8//41 7//41 +f 34//44 47//44 46//44 +f 51//48 16//48 50//48 +f 49//53 50//53 13//53 +f 51//56 44//56 43//56 +f 13//62 48//62 49//62 +f 45//63 49//63 48//63 +f 38//65 44//65 45//65 +f 45//78 36//78 38//78 +f 39//80 38//80 37//80 +f 40//82 41//82 39//82 +f 6//90 7//90 64//90 +f 8//93 9//93 66//93 +f 8//93 66//93 65//93 +f 10//95 11//95 68//95 +f 10//95 68//95 67//95 +f 15//100 73//100 72//100 +f 19//104 77//104 76//104 +f 74//130 102//130 101//130 +f 75//131 103//131 102//131 +f 88//145 116//145 115//145 +f 91//148 92//148 119//148 +f 94//151 122//151 121//151 +f 95//152 123//152 122//152 +f 96//153 97//153 124//153 +f 96//153 124//153 123//153 +f 97//154 98//154 125//154 +f 98//156 99//156 126//156 +f 100//159 101//159 128//159 +f 101//160 129//160 128//160 +f 102//161 130//161 129//161 +f 103//162 104//162 131//162 +f 139//172 140//172 142//172 +f 168//172 150//172 151//172 +f 112//186 143//186 144//186 +f 158//197 121//197 159//197 +f 157//200 120//200 158//200 +f 161//202 126//202 127//202 +f 155//172 162//172 154//172 +f 159//213 123//213 124//213 +f 159//214 124//214 160//214 +usemtl Amethyst +f 17//51 43//51 18//51 +f 14//52 13//52 50//52 +f 50//55 44//55 51//55 +f 43//57 42//57 18//57 +f 44//66 38//66 43//66 +f 12//97 13//97 70//97 +f 17//102 18//102 75//102 +f 69//125 97//125 96//125 +f 70//126 98//126 97//126 +f 73//129 74//129 101//129 +f 75//131 76//131 103//131 +f 99//158 127//158 126//158 +f 161//208 160//208 124//208 +f 161//172 155//172 160//172 +f 155//172 161//172 162//172 +usemtl Deep_blue +f 24//3 54//3 52//3 +f 23//4 54//4 24//4 +f 35//28 55//28 37//28 +f 56//29 37//29 55//29 +f 22//31 57//31 56//31 +f 19//58 18//58 42//58 +f 13//98 14//98 71//98 +f 14//99 15//99 72//99 +f 16//101 17//101 74//101 +f 16//101 74//101 73//101 +f 21//106 22//106 79//106 +f 22//107 23//107 80//107 +f 26//111 27//111 84//111 +f 27//112 1//112 58//112 +f 27//113 58//113 84//113 +f 58//114 59//114 86//114 +f 59//115 60//115 87//115 +f 68//124 96//124 95//124 +f 69//125 70//125 97//125 +f 79//135 80//135 107//135 +f 81//137 82//137 109//137 +f 84//140 58//140 85//140 +f 85//141 86//141 113//141 +f 86//142 87//142 114//142 +f 97//155 125//155 124//155 +f 98//156 126//156 125//156 +f 101//160 102//160 129//160 +f 105//164 106//164 133//164 +f 106//165 134//165 133//165 +f 109//168 137//168 136//168 +f 110//169 138//169 137//169 +f 165//173 135//173 163//173 +f 148//172 167//172 166//172 +f 167//172 148//172 168//172 +f 127//203 162//203 161//203 +f 128//205 154//205 162//205 +f 153//209 154//209 129//209 +f 129//210 130//210 153//210 diff --git a/design/cursors/prism-hand/prism-hand-render.png b/design/cursors/prism-hand/prism-hand-render.png new file mode 100644 index 000000000..b148b1254 Binary files /dev/null and b/design/cursors/prism-hand/prism-hand-render.png differ diff --git a/design/cursors/star-sprout/arrow-color.png b/design/cursors/star-sprout/arrow-color.png new file mode 100644 index 000000000..9a1cc4824 Binary files /dev/null and b/design/cursors/star-sprout/arrow-color.png differ diff --git a/design/cursors/star-sprout/arrow-sdf.json b/design/cursors/star-sprout/arrow-sdf.json new file mode 100644 index 000000000..55cda3525 --- /dev/null +++ b/design/cursors/star-sprout/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7255554804218256, 1.0], + "hotspot": [0.040391620434320014, 0.028246431596310177], + "top": 0.0, + "thick": 0.14039740303926113, + "maxHeight": 0.08629190531818706 +} diff --git a/design/cursors/star-sprout/arrow-sdf.png b/design/cursors/star-sprout/arrow-sdf.png new file mode 100644 index 000000000..9fa725b74 Binary files /dev/null and b/design/cursors/star-sprout/arrow-sdf.png differ diff --git a/design/cursors/star-sprout/hotspots.json b/design/cursors/star-sprout/hotspots.json new file mode 100644 index 000000000..37a7300a6 --- /dev/null +++ b/design/cursors/star-sprout/hotspots.json @@ -0,0 +1,10 @@ +{ + "arrow": [ + 282.48, + 253.44 + ], + "pointer": [ + 1330.54, + 155.74 + ] +} diff --git a/design/cursors/star-sprout/pointer-color.png b/design/cursors/star-sprout/pointer-color.png new file mode 100644 index 000000000..ce4071396 Binary files /dev/null and b/design/cursors/star-sprout/pointer-color.png differ diff --git a/design/cursors/star-sprout/pointer-sdf.json b/design/cursors/star-sprout/pointer-sdf.json new file mode 100644 index 000000000..74b515e0c --- /dev/null +++ b/design/cursors/star-sprout/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9367595709452087, 1.0], + "hotspot": [0.41302151666894654, 0.761317417701274], + "top": 0.0, + "thick": 0.029411744145144688, + "maxHeight": 0.28421628501318535 +} diff --git a/design/cursors/star-sprout/pointer-sdf.png b/design/cursors/star-sprout/pointer-sdf.png new file mode 100644 index 000000000..68520f2a1 Binary files /dev/null and b/design/cursors/star-sprout/pointer-sdf.png differ diff --git a/design/cursors/star-sprout/source.png b/design/cursors/star-sprout/source.png index 930675346..e6cd8b54e 100644 Binary files a/design/cursors/star-sprout/source.png and b/design/cursors/star-sprout/source.png differ diff --git a/design/cursors/studio-ink/arrow-color.png b/design/cursors/studio-ink/arrow-color.png new file mode 100644 index 000000000..29f2424c9 Binary files /dev/null and b/design/cursors/studio-ink/arrow-color.png differ diff --git a/design/cursors/studio-ink/arrow-sdf.json b/design/cursors/studio-ink/arrow-sdf.json new file mode 100644 index 000000000..4c60a87f5 --- /dev/null +++ b/design/cursors/studio-ink/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.718352287655055, 1.0], + "hotspot": [0.040840764115435677, 0.028597061310687795], + "top": 0.0, + "thick": 0.14031950438555443, + "maxHeight": 0.05991139549692551 +} diff --git a/design/cursors/studio-ink/arrow-sdf.png b/design/cursors/studio-ink/arrow-sdf.png new file mode 100644 index 000000000..ee94bfdb8 Binary files /dev/null and b/design/cursors/studio-ink/arrow-sdf.png differ diff --git a/design/cursors/studio-ink/hotspots.json b/design/cursors/studio-ink/hotspots.json new file mode 100644 index 000000000..37a7300a6 --- /dev/null +++ b/design/cursors/studio-ink/hotspots.json @@ -0,0 +1,10 @@ +{ + "arrow": [ + 282.48, + 253.44 + ], + "pointer": [ + 1330.54, + 155.74 + ] +} diff --git a/design/cursors/studio-ink/pointer-color.png b/design/cursors/studio-ink/pointer-color.png new file mode 100644 index 000000000..c4a6f5cc1 Binary files /dev/null and b/design/cursors/studio-ink/pointer-color.png differ diff --git a/design/cursors/studio-ink/pointer-sdf.json b/design/cursors/studio-ink/pointer-sdf.json new file mode 100644 index 000000000..331b2c20f --- /dev/null +++ b/design/cursors/studio-ink/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9359752331364949, 1.0], + "hotspot": [0.41218266937107156, 0.763820765207442], + "top": 0.0, + "thick": 0.029411743934585507, + "maxHeight": 0.24547220499082076 +} diff --git a/design/cursors/studio-ink/pointer-sdf.png b/design/cursors/studio-ink/pointer-sdf.png new file mode 100644 index 000000000..df5238660 Binary files /dev/null and b/design/cursors/studio-ink/pointer-sdf.png differ diff --git a/design/cursors/studio-ink/source.png b/design/cursors/studio-ink/source.png index dc5b8a39b..7ee182184 100644 Binary files a/design/cursors/studio-ink/source.png and b/design/cursors/studio-ink/source.png differ diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index 4a765db2b..6b924e893 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -5,9 +5,11 @@ import { fileURLToPath } from "node:url"; import { app } from "electron"; import { type CursorKind, + getCursorTheme, readCursorAsArrow, resolveCursorSprites, } from "../../../src/lib/cursor/cursorThemes"; +import type { NativeCursorType } from "../../../src/native/contracts"; import type { GifExportJob } from "../../ipc/gifExportJobs"; import type { ClipInput, @@ -107,19 +109,48 @@ function resolveCursorSpritePaths( themeId: string, asArrow: readonly CursorKind[], model3d = false, -): Record { +): Record< + string, + { + path: string; + hotspotX: number; + hotspotY: number; + sculpt?: string; + modelSdfPath?: string; + modelColorPath?: string; + } +> { const resolved: Record< string, - { path: string; hotspotX: number; hotspotY: number; sculpt?: string } + { + path: string; + hotspotX: number; + hotspotY: number; + sculpt?: string; + modelSdfPath?: string; + modelColorPath?: string; + } > = {}; for (const [type, sprite] of Object.entries(resolveCursorSprites(themeId, asArrow, model3d))) { const absolute = resolveSceneAssetPath(sprite.assetPath); if (absolute) { + const [sculptThemeId, sculptState] = sprite.sculpt?.split("/", 2) ?? []; + const modelAsset = + sculptThemeId && sculptState + ? getCursorTheme(sculptThemeId)?.assets[sculptState as NativeCursorType] + : undefined; + const modelSdfPath = modelAsset?.modelSdfPath + ? resolveSceneAssetPath(modelAsset.modelSdfPath) + : null; + const modelColorPath = modelAsset?.modelColorPath + ? resolveSceneAssetPath(modelAsset.modelColorPath) + : null; resolved[type] = { path: absolute, hotspotX: sprite.hotspotX, hotspotY: sprite.hotspotY, ...(sprite.sculpt ? { sculpt: sprite.sculpt } : {}), + ...(modelSdfPath && modelColorPath ? { modelSdfPath, modelColorPath } : {}), }; } } @@ -141,7 +172,14 @@ export function resolveSceneAssetPaths(sceneJson: string): string { model3d?: boolean; cursorSprites?: Record< string, - { path: string; hotspotX: number; hotspotY: number; sculpt?: string } + { + path: string; + hotspotX: number; + hotspotY: number; + sculpt?: string; + modelSdfPath?: string; + modelColorPath?: string; + } >; }; webcamEffect?: { diff --git a/public/cursors/pixel-candy/arrow-color.png b/public/cursors/pixel-candy/arrow-color.png new file mode 100644 index 000000000..2e2d61b93 Binary files /dev/null and b/public/cursors/pixel-candy/arrow-color.png differ diff --git a/public/cursors/pixel-candy/arrow-sdf.json b/public/cursors/pixel-candy/arrow-sdf.json new file mode 100644 index 000000000..26f4961fc --- /dev/null +++ b/public/cursors/pixel-candy/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.734030950851246, 1.0], + "hotspot": [0.04006883452499369, 0.06369627670092512], + "top": 0.0, + "thick": 0.07581672018841357, + "maxHeight": 0.07381843701887433 +} diff --git a/public/cursors/pixel-candy/arrow-sdf.png b/public/cursors/pixel-candy/arrow-sdf.png new file mode 100644 index 000000000..d6c82dea7 Binary files /dev/null and b/public/cursors/pixel-candy/arrow-sdf.png differ diff --git a/public/cursors/pixel-candy/arrow.png b/public/cursors/pixel-candy/arrow.png index 1efe79656..cd1fa2c99 100644 Binary files a/public/cursors/pixel-candy/arrow.png and b/public/cursors/pixel-candy/arrow.png differ diff --git a/public/cursors/pixel-candy/pointer-color.png b/public/cursors/pixel-candy/pointer-color.png new file mode 100644 index 000000000..88c459d19 Binary files /dev/null and b/public/cursors/pixel-candy/pointer-color.png differ diff --git a/public/cursors/pixel-candy/pointer-sdf.json b/public/cursors/pixel-candy/pointer-sdf.json new file mode 100644 index 000000000..5a713087e --- /dev/null +++ b/public/cursors/pixel-candy/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9327730188136261, 1.0], + "hotspot": [0.38544599176239186, 0.050504995651785504], + "top": 0.0, + "thick": 0.029411764705882353, + "maxHeight": 0.12022351132899409 +} diff --git a/public/cursors/pixel-candy/pointer-sdf.png b/public/cursors/pixel-candy/pointer-sdf.png new file mode 100644 index 000000000..9ff1cd552 Binary files /dev/null and b/public/cursors/pixel-candy/pointer-sdf.png differ diff --git a/public/cursors/pixel-candy/pointer.png b/public/cursors/pixel-candy/pointer.png index 13e181de9..48c1737c6 100644 Binary files a/public/cursors/pixel-candy/pointer.png and b/public/cursors/pixel-candy/pointer.png differ diff --git a/public/cursors/pop-coral/arrow-color.png b/public/cursors/pop-coral/arrow-color.png new file mode 100644 index 000000000..c5e8664a5 Binary files /dev/null and b/public/cursors/pop-coral/arrow-color.png differ diff --git a/public/cursors/pop-coral/arrow-sdf.json b/public/cursors/pop-coral/arrow-sdf.json new file mode 100644 index 000000000..f75212946 --- /dev/null +++ b/public/cursors/pop-coral/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7185188746637131, 1.0], + "hotspot": [0.040701131649228305, 0.02747064307943106], + "top": 0.0, + "thick": 0.1405290229781066, + "maxHeight": 0.0664581862991136 +} diff --git a/public/cursors/pop-coral/arrow-sdf.png b/public/cursors/pop-coral/arrow-sdf.png new file mode 100644 index 000000000..9b10fbc0d Binary files /dev/null and b/public/cursors/pop-coral/arrow-sdf.png differ diff --git a/public/cursors/pop-coral/arrow.png b/public/cursors/pop-coral/arrow.png index 0032bb550..781bb1165 100644 Binary files a/public/cursors/pop-coral/arrow.png and b/public/cursors/pop-coral/arrow.png differ diff --git a/public/cursors/pop-coral/pointer-color.png b/public/cursors/pop-coral/pointer-color.png new file mode 100644 index 000000000..332f97708 Binary files /dev/null and b/public/cursors/pop-coral/pointer-color.png differ diff --git a/public/cursors/pop-coral/pointer-sdf.json b/public/cursors/pop-coral/pointer-sdf.json new file mode 100644 index 000000000..de60bd3da --- /dev/null +++ b/public/cursors/pop-coral/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9386528198789045, 1.0], + "hotspot": [0.4150400053111568, 0.7552755949031005], + "top": 0.0, + "thick": 0.029411764705882353, + "maxHeight": 0.24023877839599173 +} diff --git a/public/cursors/pop-coral/pointer-sdf.png b/public/cursors/pop-coral/pointer-sdf.png new file mode 100644 index 000000000..5bdfde50d Binary files /dev/null and b/public/cursors/pop-coral/pointer-sdf.png differ diff --git a/public/cursors/pop-coral/pointer.png b/public/cursors/pop-coral/pointer.png index 572f551c6..2f48ad3ea 100644 Binary files a/public/cursors/pop-coral/pointer.png and b/public/cursors/pop-coral/pointer.png differ diff --git a/public/cursors/prism-glow/arrow-color.png b/public/cursors/prism-glow/arrow-color.png new file mode 100644 index 000000000..4633c04f4 Binary files /dev/null and b/public/cursors/prism-glow/arrow-color.png differ diff --git a/public/cursors/prism-glow/arrow-sdf.json b/public/cursors/prism-glow/arrow-sdf.json new file mode 100644 index 000000000..bba446864 --- /dev/null +++ b/public/cursors/prism-glow/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7184577753737912, 1.0], + "hotspot": [0.040720327501619115, 0.02716068074425252], + "top": 0.0, + "thick": 0.14289131847599995, + "maxHeight": 0.10763340078405259 +} diff --git a/public/cursors/prism-glow/arrow-sdf.png b/public/cursors/prism-glow/arrow-sdf.png new file mode 100644 index 000000000..2add3bc22 Binary files /dev/null and b/public/cursors/prism-glow/arrow-sdf.png differ diff --git a/public/cursors/prism-glow/arrow.png b/public/cursors/prism-glow/arrow.png index f0b352a35..c5cd36ffb 100644 Binary files a/public/cursors/prism-glow/arrow.png and b/public/cursors/prism-glow/arrow.png differ diff --git a/public/cursors/prism-glow/pointer-color.png b/public/cursors/prism-glow/pointer-color.png new file mode 100644 index 000000000..cfebeae03 Binary files /dev/null and b/public/cursors/prism-glow/pointer-color.png differ diff --git a/public/cursors/prism-glow/pointer-sdf.json b/public/cursors/prism-glow/pointer-sdf.json new file mode 100644 index 000000000..0389fc905 --- /dev/null +++ b/public/cursors/prism-glow/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9638013470344713, 1.0], + "hotspot": [0.4370998321974549, 0.2325580885392326], + "top": 0.0, + "thick": 0.029411750808077647, + "maxHeight": 0.36130049970486233 +} diff --git a/public/cursors/prism-glow/pointer-sdf.png b/public/cursors/prism-glow/pointer-sdf.png new file mode 100644 index 000000000..5cbae1426 Binary files /dev/null and b/public/cursors/prism-glow/pointer-sdf.png differ diff --git a/public/cursors/prism-glow/pointer.png b/public/cursors/prism-glow/pointer.png index 9be3c514d..9f8bee0e4 100644 Binary files a/public/cursors/prism-glow/pointer.png and b/public/cursors/prism-glow/pointer.png differ diff --git a/public/cursors/star-sprout/arrow-color.png b/public/cursors/star-sprout/arrow-color.png new file mode 100644 index 000000000..9a1cc4824 Binary files /dev/null and b/public/cursors/star-sprout/arrow-color.png differ diff --git a/public/cursors/star-sprout/arrow-sdf.json b/public/cursors/star-sprout/arrow-sdf.json new file mode 100644 index 000000000..55cda3525 --- /dev/null +++ b/public/cursors/star-sprout/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.7255554804218256, 1.0], + "hotspot": [0.040391620434320014, 0.028246431596310177], + "top": 0.0, + "thick": 0.14039740303926113, + "maxHeight": 0.08629190531818706 +} diff --git a/public/cursors/star-sprout/arrow-sdf.png b/public/cursors/star-sprout/arrow-sdf.png new file mode 100644 index 000000000..9fa725b74 Binary files /dev/null and b/public/cursors/star-sprout/arrow-sdf.png differ diff --git a/public/cursors/star-sprout/arrow.png b/public/cursors/star-sprout/arrow.png index f460fba7d..91a914875 100644 Binary files a/public/cursors/star-sprout/arrow.png and b/public/cursors/star-sprout/arrow.png differ diff --git a/public/cursors/star-sprout/pointer-color.png b/public/cursors/star-sprout/pointer-color.png new file mode 100644 index 000000000..ce4071396 Binary files /dev/null and b/public/cursors/star-sprout/pointer-color.png differ diff --git a/public/cursors/star-sprout/pointer-sdf.json b/public/cursors/star-sprout/pointer-sdf.json new file mode 100644 index 000000000..74b515e0c --- /dev/null +++ b/public/cursors/star-sprout/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9367595709452087, 1.0], + "hotspot": [0.41302151666894654, 0.761317417701274], + "top": 0.0, + "thick": 0.029411744145144688, + "maxHeight": 0.28421628501318535 +} diff --git a/public/cursors/star-sprout/pointer-sdf.png b/public/cursors/star-sprout/pointer-sdf.png new file mode 100644 index 000000000..68520f2a1 Binary files /dev/null and b/public/cursors/star-sprout/pointer-sdf.png differ diff --git a/public/cursors/star-sprout/pointer.png b/public/cursors/star-sprout/pointer.png index 4d7ff0aba..f390a8e26 100644 Binary files a/public/cursors/star-sprout/pointer.png and b/public/cursors/star-sprout/pointer.png differ diff --git a/public/cursors/studio-ink/arrow-color.png b/public/cursors/studio-ink/arrow-color.png new file mode 100644 index 000000000..29f2424c9 Binary files /dev/null and b/public/cursors/studio-ink/arrow-color.png differ diff --git a/public/cursors/studio-ink/arrow-sdf.json b/public/cursors/studio-ink/arrow-sdf.json new file mode 100644 index 000000000..4c60a87f5 --- /dev/null +++ b/public/cursors/studio-ink/arrow-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.718352287655055, 1.0], + "hotspot": [0.040840764115435677, 0.028597061310687795], + "top": 0.0, + "thick": 0.14031950438555443, + "maxHeight": 0.05991139549692551 +} diff --git a/public/cursors/studio-ink/arrow-sdf.png b/public/cursors/studio-ink/arrow-sdf.png new file mode 100644 index 000000000..ee94bfdb8 Binary files /dev/null and b/public/cursors/studio-ink/arrow-sdf.png differ diff --git a/public/cursors/studio-ink/arrow.png b/public/cursors/studio-ink/arrow.png index 77f95c09c..8357218e9 100644 Binary files a/public/cursors/studio-ink/arrow.png and b/public/cursors/studio-ink/arrow.png differ diff --git a/public/cursors/studio-ink/pointer-color.png b/public/cursors/studio-ink/pointer-color.png new file mode 100644 index 000000000..c4a6f5cc1 Binary files /dev/null and b/public/cursors/studio-ink/pointer-color.png differ diff --git a/public/cursors/studio-ink/pointer-sdf.json b/public/cursors/studio-ink/pointer-sdf.json new file mode 100644 index 000000000..331b2c20f --- /dev/null +++ b/public/cursors/studio-ink/pointer-sdf.json @@ -0,0 +1,13 @@ +{ + "width": 64, + "height": 64, + "depth": 48, + "tilesX": 8, + "tilesY": 6, + "distanceRange": 0.14, + "size": [0.9359752331364949, 1.0], + "hotspot": [0.41218266937107156, 0.763820765207442], + "top": 0.0, + "thick": 0.029411743934585507, + "maxHeight": 0.24547220499082076 +} diff --git a/public/cursors/studio-ink/pointer-sdf.png b/public/cursors/studio-ink/pointer-sdf.png new file mode 100644 index 000000000..df5238660 Binary files /dev/null and b/public/cursors/studio-ink/pointer-sdf.png differ diff --git a/public/cursors/studio-ink/pointer.png b/public/cursors/studio-ink/pointer.png index 7ee633dad..7cf438e1a 100644 Binary files a/public/cursors/studio-ink/pointer.png and b/public/cursors/studio-ink/pointer.png differ diff --git a/scripts/export-prism-hand-cursor.py b/scripts/export-prism-hand-cursor.py new file mode 100644 index 000000000..45ffbb56d --- /dev/null +++ b/scripts/export-prism-hand-cursor.py @@ -0,0 +1,28 @@ +"""Export the Prism Glow study at the cursor's scene scale for the Blender importer.""" + +from pathlib import Path + +import bpy + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "design/cursors/prism-hand/prism-glow-hand.blend" +OUTPUT = ROOT / "design/cursors/prism-hand/prism-glow-hand.obj" + +bpy.ops.wm.open_mainfile(filepath=str(SOURCE)) +scene = bpy.context.scene +source = bpy.data.objects["Prism Glow - Hand"] +export_object = source.copy() +export_object.data = source.data.copy() +scene.collection.objects.link(export_object) + +# Keep the same scale and cursor contact offset as the compositor's existing hand model. +export_object.scale = (0.75, 0.75, 0.75) +export_object.location.z = -0.4 +export_object.name = "Prism Glow - Hand (cursor scale)" + +bpy.ops.object.select_all(action="DESELECT") +export_object.select_set(True) +bpy.context.view_layer.objects.active = export_object +bpy.ops.wm.obj_export(filepath=str(OUTPUT), export_selected_objects=True) +print(f"Exported cursor-scale Prism Glow hand: {OUTPUT}") diff --git a/scripts/generate-original-cursor-themes.mjs b/scripts/generate-original-cursor-themes.mjs index 678cae5ff..13a264f09 100644 --- a/scripts/generate-original-cursor-themes.mjs +++ b/scripts/generate-original-cursor-themes.mjs @@ -1,8 +1,8 @@ -// Prepare transparent cursor PNGs from the original raster artwork in design/cursors. +// Prepare transparent cursor PNGs from the Blender renders in design/cursors. // Run with `node scripts/generate-original-cursor-themes.mjs`. // Each source PNG contains the arrow on the left and the hand on the right. -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; @@ -13,39 +13,28 @@ const PUBLIC_DIR = path.join(ROOT, "public", "cursors"); const SIZE = 128; const INNER_SIZE = 112; -// Fingertip and arrow tip coordinates in the 1774 × 887 source PNGs. -const themes = [ - { - id: "studio-ink", - hotspots: { arrow: [158, 48], pointer: [1258, 50] }, - }, - { - id: "prism-glow", - hotspots: { arrow: [208, 42], pointer: [1220, 50] }, - }, - { - id: "pop-coral", - hotspots: { arrow: [265, 58], pointer: [1245, 65] }, - }, - { - id: "pixel-candy", - hotspots: { arrow: [273, 76], pointer: [1218, 78] }, - }, - { - id: "star-sprout", - hotspots: { arrow: [210, 65], pointer: [1218, 65] }, - }, -]; +// Each Blender scene writes arrow-tip and fingertip positions in source-image pixels. +const themeIds = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"]; +const themeNames = ["Studio Ink", "Prism Glow", "Pop Coral", "Pixel Candy", "Star Sprout"]; const browser = await chromium.launch(); try { const page = await browser.newPage(); await page.goto("about:blank"); - for (const theme of themes) { + const previewSprites = []; + for (const id of themeIds) { + const theme = { + id, + hotspots: JSON.parse(await readFile(path.join(SOURCE_DIR, id, "hotspots.json"), "utf8")), + }; const output = path.join(PUBLIC_DIR, theme.id); await mkdir(output, { recursive: true }); const source = await readFile(path.join(SOURCE_DIR, theme.id, "source.png")); + const sprites = {}; for (const type of ["arrow", "pointer"]) { + for (const filename of [`${type}-sdf.png`, `${type}-sdf.json`, `${type}-color.png`]) { + await copyFile(path.join(SOURCE_DIR, theme.id, filename), path.join(output, filename)); + } const result = await page.evaluate( async ({ png, type, size, innerSize, hotspot }) => { const image = new Image(); @@ -116,11 +105,60 @@ try { ); const filename = `${type}.png`; await writeFile(path.join(output, filename), Buffer.from(result.png, "base64")); + sprites[type] = result.png; console.log( `${theme.id}/${filename}: hotspot ${result.hotspotX.toFixed(4)}, ${result.hotspotY.toFixed(4)}`, ); } + previewSprites.push({ id, name: themeNames[themeIds.indexOf(id)], sprites }); } + const sheets = await page.evaluate(async (themes) => { + const imageFromBase64 = async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + return image; + }; + const contact = document.createElement("canvas"); + contact.width = 328; + contact.height = 820; + const contactContext = contact.getContext("2d"); + contactContext.fillStyle = "#ebeff6"; + contactContext.fillRect(0, 0, contact.width, contact.height); + contactContext.font = "12px Arial, sans-serif"; + contactContext.fillStyle = "#2a2f39"; + for (let row = 0; row < themes.length; row++) { + const theme = themes[row]; + const y = row * 164; + contactContext.fillText(theme.name, 10, y + 18); + for (const [column, type] of ["arrow", "pointer"].entries()) { + const image = await imageFromBase64(theme.sprites[type]); + contactContext.drawImage(image, 18 + column * 164, y + 28, 128, 128); + } + } + + const dark = document.createElement("canvas"); + dark.width = 112; + dark.height = 280; + const darkContext = dark.getContext("2d"); + darkContext.fillStyle = "#141822"; + darkContext.fillRect(0, 0, dark.width, dark.height); + for (let row = 0; row < themes.length; row++) { + for (const [column, type] of ["arrow", "pointer"].entries()) { + const image = await imageFromBase64(themes[row].sprites[type]); + darkContext.drawImage(image, 12 + column * 56, row * 56 + 12, 32, 32); + } + } + return { contact: contact.toDataURL("image/png"), dark: dark.toDataURL("image/png") }; + }, previewSprites); + await writeFile( + path.join(SOURCE_DIR, "contact-sheet.png"), + Buffer.from(sheets.contact.split(",")[1], "base64"), + ); + await writeFile( + path.join(SOURCE_DIR, "dark-32px.png"), + Buffer.from(sheets.dark.split(",")[1], "base64"), + ); } finally { await browser.close(); } diff --git a/scripts/model-original-cursors.py b/scripts/model-original-cursors.py new file mode 100644 index 000000000..7242cb7ad --- /dev/null +++ b/scripts/model-original-cursors.py @@ -0,0 +1,1000 @@ +"""Model and render OpenScreen's original cursor themes in Blender. + +From the repository root: + blender --background --python scripts/model-original-cursors.py + +The editable .blend opens on an overview of every theme and keeps one editable +scene per theme. Each theme scene contains a 3D arrow and hand, and renders a +transparent 1774 x 887 source.png plus projected hotspot coordinates. +""" + +import argparse +import json +import math +import os +import struct +import sys +import zlib + +import bpy +import bmesh +from bpy_extras.object_utils import world_to_camera_view +from mathutils.bvhtree import BVHTree +from mathutils import Vector + + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CURSOR_DIR = os.path.join(ROOT, "design", "cursors") +BLEND_PATH = os.path.join(CURSOR_DIR, "original-cursor-models.blend") +RENDER_WIDTH = 1774 +RENDER_HEIGHT = 887 +CAMERA_WIDTH = 7.6 +VOLUME_XY = 64 +VOLUME_Z = 48 +VOLUME_TILES_X = 8 +VOLUME_TILES_Y = 6 +VOLUME_SDF_RANGE = 0.14 + +ARROW = [ + (-0.78, 1.24), + (0.65, -0.03), + (0.02, -0.05), + (0.29, -0.66), + (-0.03, -0.80), + (-0.33, -0.18), + (-0.72, -0.54), +] + +HAND = [ + (-0.43, -0.23), (-0.52, 0.00), (-0.67, 0.34), (-0.65, 0.50), + (-0.55, 0.61), (-0.43, 0.61), (-0.15, 0.34), (-0.13, 0.37), + (-0.13, 1.28), (-0.09, 1.43), (0.02, 1.50), (0.14, 1.47), + (0.20, 1.38), (0.21, 0.60), (0.30, 0.82), (0.40, 0.88), + (0.50, 0.83), (0.53, 0.73), (0.48, 0.48), (0.59, 0.68), + (0.69, 0.73), (0.79, 0.68), (0.82, 0.56), (0.76, 0.31), + (0.87, 0.48), (0.97, 0.49), (1.06, 0.41), (1.04, 0.29), + (0.95, -0.04), (0.79, -0.28), (0.61, -0.37), (-0.17, -0.37), +] + + +def ccw(points): + area = sum( + points[i][0] * points[(i + 1) % len(points)][1] + - points[(i + 1) % len(points)][0] * points[i][1] + for i in range(len(points)) + ) + return points if area > 0 else list(reversed(points)) + + +def material(name, color, roughness=0.32, metallic=0.0, coat=0.0): + mat = bpy.data.materials.new(name) + mat.diffuse_color = (*color, 1.0) + mat.use_nodes = True + shader = mat.node_tree.nodes.get("Principled BSDF") + shader.inputs["Base Color"].default_value = (*color, 1.0) + shader.inputs["Roughness"].default_value = roughness + shader.inputs["Metallic"].default_value = metallic + if "Coat Weight" in shader.inputs: + shader.inputs["Coat Weight"].default_value = coat + if "Coat Roughness" in shader.inputs: + shader.inputs["Coat Roughness"].default_value = 0.2 + return mat + + +def finish_mesh(name, vertices, faces, materials, material_ids=None, bevel=0.0, segments=3): + mesh = bpy.data.meshes.new(name + " Mesh") + mesh.from_pydata(vertices, [], faces) + for mat in materials: + mesh.materials.append(mat) + mesh.update() + if material_ids: + for index, polygon in enumerate(mesh.polygons): + polygon.material_index = material_ids[index] + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(mesh) + bm.free() + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.scene.collection.objects.link(obj) + if bevel > 0: + modifier = obj.modifiers.new("Soft machined edges", "BEVEL") + modifier.width = bevel + modifier.segments = segments + modifier.limit_method = "ANGLE" + modifier.angle_limit = math.radians(28) + modifier.harden_normals = True + obj.modifiers.new("Weighted face normals", "WEIGHTED_NORMAL") + return obj + + +def extruded_polygon(name, points, depth, materials, bevel=0.0, facet_ids=None, dome=0.0): + points = ccw(points) + count = len(points) + front_y, back_y = -depth / 2, depth / 2 + vertices = [(x, front_y, z) for x, z in points] + vertices.extend((x, back_y, z) for x, z in points) + faces, material_ids = [], [] + if facet_ids is None: + if dome > 0: + cx = sum(x for x, _ in points) / count + cz = sum(z for _, z in points) / count + for factor, height in [(0.72, dome * 0.72), (0.4, dome * 0.94)]: + ring_start = len(vertices) + vertices.extend((cx + (x - cx) * factor, front_y - height, cz + (z - cz) * factor) for x, z in points) + for index in range(count): + next_index = (index + 1) % count + faces.append((index, next_index, ring_start + next_index, ring_start + index)) + material_ids.append(0) + inner_start = ring_start + center = (cx, front_y - dome, cz) + center_index = len(vertices) + vertices.append(center) + for index in range(count): + next_index = (index + 1) % count + faces.append((inner_start + index, inner_start + next_index, center_index)) + material_ids.append(0) + else: + faces.append(tuple(range(count))) + material_ids.append(0) + faces.append(tuple(reversed(range(count, count * 2)))) + material_ids.append(min(1, len(materials) - 1)) + else: + center_x = sum(x for x, _ in points) / count + center_z = sum(z for _, z in points) / count + for index, (x, z) in enumerate(points): + depth_factor = 0.07 + ((index * 7) % 5) * 0.055 + vertices[index] = (x, front_y - depth * depth_factor, z) + inner_start = len(vertices) + for index, (x, z) in enumerate(points): + depth_factor = 0.27 + ((index * 11) % 7) * 0.065 + vertices.append(( + center_x + (x - center_x) * 0.68, + front_y - depth * depth_factor, + center_z + (z - center_z) * 0.68, + )) + center_index = len(vertices) + vertices.append((center_x, front_y - depth * 0.54, center_z)) + back_center_index = len(vertices) + vertices.append((center_x, back_y, center_z)) + for index in range(count): + next_index = (index + 1) % count + inner = inner_start + index + inner_next = inner_start + next_index + faces.append((index, next_index, inner_next)) + material_ids.append(facet_ids[(index * 3) % len(facet_ids)]) + faces.append((index, inner_next, inner)) + material_ids.append(facet_ids[(index * 3 + 1) % len(facet_ids)]) + faces.append((center_index, inner, inner_next)) + material_ids.append(facet_ids[(index * 3 + 2) % len(facet_ids)]) + faces.append((back_center_index, count + next_index, count + index)) + material_ids.append(min(1, len(materials) - 1)) + for index in range(count): + next_index = (index + 1) % count + faces.append((index, next_index, count + next_index, count + index)) + material_ids.append(min(1, len(materials) - 1)) + return finish_mesh(name, vertices, faces, materials, material_ids, bevel, 4) + + +def curve_line(name, points, mat, depth=0.018, y=-0.13, location_x=0.0): + curve = bpy.data.curves.new(name + " Curve", "CURVE") + curve.dimensions = "3D" + curve.resolution_u = 1 + curve.bevel_depth = depth + curve.bevel_resolution = 3 + spline = curve.splines.new("POLY") + spline.points.add(len(points) - 1) + for item, (x, z) in zip(spline.points, points): + item.co = (x, y, z, 1) + spline.use_cyclic_u = True + obj = bpy.data.objects.new(name, curve) + bpy.context.scene.collection.objects.link(obj) + obj.location.x = location_x + obj.data.materials.append(mat) + for selected in bpy.context.selected_objects: + selected.select_set(False) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.convert(target="MESH") + obj = bpy.context.object + for polygon in obj.data.polygons: + polygon.use_smooth = True + return obj + + +def rounded_box(name, location, scale, mat, bevel=0.07): + bpy.ops.mesh.primitive_cube_add(size=1, location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = scale + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj.data.materials.append(mat) + modifier = obj.modifiers.new("Rounded molded edges", "BEVEL") + modifier.width = bevel + modifier.segments = 3 + modifier.limit_method = "ANGLE" + obj.modifiers.new("Weighted normals", "WEIGHTED_NORMAL") + return obj + + +def sphere(name, location, scale, mat): + bpy.ops.mesh.primitive_uv_sphere_add(segments=10, ring_count=6, location=location) + obj = bpy.context.object + obj.name = name + obj.scale = scale + obj.data.materials.append(mat) + for face in obj.data.polygons: + face.use_smooth = True + return obj + + +def curved_outline(name, points, center_x, mat, thickness, edge_round, resolution=1): + """Turn a rounded 2D contour into an editable, beveled mesh.""" + curve = bpy.data.curves.new(name + " Sculpted Outline", "CURVE") + curve.dimensions = "2D" + curve.resolution_u = resolution + curve.fill_mode = "BOTH" + curve.extrude = thickness / 2 + curve.bevel_depth = edge_round + curve.bevel_resolution = 3 + spline = curve.splines.new("BEZIER") + spline.bezier_points.add(len(points) - 1) + for bezier_point, (x, z) in zip(spline.bezier_points, ccw(points)): + bezier_point.co = (x, z, 0.0) + bezier_point.handle_left_type = "AUTO" + bezier_point.handle_right_type = "AUTO" + spline.use_cyclic_u = True + obj = bpy.data.objects.new(name, curve) + bpy.context.scene.collection.objects.link(obj) + obj.rotation_euler[0] = math.pi / 2 + obj.location.x = center_x + curve.materials.append(mat) + for selected in bpy.context.selected_objects: + selected.select_set(False) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.convert(target="MESH") + obj = bpy.context.object + for polygon in obj.data.polygons: + polygon.use_smooth = True + return obj + + +def curved_hand(name, center_x, mat, thickness=0.20, edge_round=0.055): + """Extrude a smooth, rounded pointer silhouette into a low-poly 3D hand.""" + return curved_outline(name, HAND, center_x, mat, thickness, edge_round) + + +def sculpted_hand(name, center_x, materials, faceted=False): + """Create a rounded palm and separate blended fingers, with an optional crystal finish.""" + volume = bpy.data.metaballs.new(name + " blended volume") + volume.resolution = 0.065 if faceted else 0.05 + volume.render_resolution = 0.04 + volume.threshold = 0.48 + obj = bpy.data.objects.new(name, volume) + bpy.context.scene.collection.objects.link(obj) + obj.location.x = center_x + + def ellipsoid(location, scale, rotation=0.0): + element = volume.elements.new() + element.type = "ELLIPSOID" + element.co = location + element.radius = 1.0 + element.size_x, element.size_y, element.size_z = scale + element.rotation = (math.cos(rotation / 2), 0.0, math.sin(rotation / 2), 0.0) + + # The lower palm tapers into a cuff; the raised index is longest and the other + # three fingers step down in length, matching the pointer silhouette in the concept. + ellipsoid((0.18, 0.0, 0.06), (0.76, 0.24, 0.58)) + ellipsoid((0.15, 0.0, -0.23), (0.48, 0.20, 0.30)) + ellipsoid((-0.13, 0.0, 0.72), (0.23, 0.20, 0.85)) + ellipsoid((0.26, 0.0, 0.55), (0.21, 0.19, 0.72)) + ellipsoid((0.58, 0.0, 0.40), (0.19, 0.18, 0.61)) + ellipsoid((0.82, 0.0, 0.23), (0.17, 0.17, 0.49)) + ellipsoid((-0.40, 0.0, 0.18), (0.37, 0.18, 0.28), rotation=math.radians(35)) + + for selected in bpy.context.selected_objects: + selected.select_set(False) + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.convert(target="MESH") + obj = bpy.context.object + for mat in materials: + obj.data.materials.append(mat) + + target_faces = 90 if faceted else 600 + if len(obj.data.polygons) > target_faces: + decimate = obj.modifiers.new("Cursor polygon budget", "DECIMATE") + decimate.ratio = target_faces / len(obj.data.polygons) + bpy.ops.object.modifier_apply(modifier=decimate.name) + + if faceted: + triangulate = obj.modifiers.new("Crystal triangular facets", "TRIANGULATE") + bpy.ops.object.modifier_apply(modifier=triangulate.name) + for polygon in obj.data.polygons: + polygon.use_smooth = False + polygon.material_index = (polygon.index * 37 + int((polygon.center.x + 1.4) * 19)) % len(materials) + else: + for polygon in obj.data.polygons: + polygon.use_smooth = True + return obj + + +def star_points(cx, cz, outer, inner, count=5, rotation=math.pi / 2): + points = [] + for index in range(count * 2): + radius = outer if index % 2 == 0 else inner + angle = rotation + math.pi * index / count + points.append((cx + math.cos(angle) * radius, cz + math.sin(angle) * radius)) + return ccw(points) + + +def inside_polygon(x, z, points): + inside = False + previous = len(points) - 1 + for current in range(len(points)): + x1, z1 = points[current] + x2, z2 = points[previous] + if (z1 > z) != (z2 > z) and x < (x2 - x1) * (z - z1) / (z2 - z1) + x1: + inside = not inside + previous = current + return inside + + +def voxel_volume(name, points, front_materials, side_material, cell_count=14, backing=True): + points = ccw(points) + xmin, xmax = min(x for x, _ in points), max(x for x, _ in points) + zmin, zmax = min(z for _, z in points), max(z for _, z in points) + cell = max(xmax - xmin, zmax - zmin) / cell_count + columns, rows = math.ceil((xmax - xmin) / cell), math.ceil((zmax - zmin) / cell) + occupied = { + (column, row) + for row in range(rows) + for column in range(columns) + if inside_polygon(xmin + (column + 0.5) * cell, zmin + (row + 0.5) * cell, points) + } + depth = cell * 0.72 + vertices, vertex_index, faces, mat_ids = [], {}, [], [] + + def vertex(x, y, z): + key = (round(x, 6), round(y, 6), round(z, 6)) + if key not in vertex_index: + vertex_index[key] = len(vertices) + vertices.append(key) + return vertex_index[key] + + front_y, back_y = -depth / 2, depth / 2 + for column, row in sorted(occupied): + x0, x1 = xmin + column * cell, xmin + (column + 1) * cell + z0, z1 = zmin + row * cell, zmin + (row + 1) * cell + faces.append(( + vertex(x0, front_y, z0), vertex(x1, front_y, z0), + vertex(x1, front_y, z1), vertex(x0, front_y, z1), + )) + base_colors = 3 if len(front_materials) > 3 else len(front_materials) + if len(front_materials) > 3 and column < 2 and row < rows * 0.62: + mat_ids.append(len(front_materials) - 1) + else: + mat_ids.append((column * 7 + row * 11) % base_colors) + faces.append(( + vertex(x0, back_y, z0), vertex(x0, back_y, z1), + vertex(x1, back_y, z1), vertex(x1, back_y, z0), + )) + mat_ids.append(len(front_materials)) + neighbors = [ + ((0, -1), [(x0, front_y, z0), (x0, back_y, z0), (x1, back_y, z0), (x1, front_y, z0)]), + ((1, 0), [(x1, front_y, z0), (x1, back_y, z0), (x1, back_y, z1), (x1, front_y, z1)]), + ((0, 1), [(x1, front_y, z1), (x1, back_y, z1), (x0, back_y, z1), (x0, front_y, z1)]), + ((-1, 0), [(x0, front_y, z1), (x0, back_y, z1), (x0, back_y, z0), (x0, front_y, z0)]), + ] + for (dx, dz), corners in neighbors: + if (column + dx, row + dz) not in occupied: + faces.append(tuple(vertex(*point) for point in corners)) + mat_ids.append(len(front_materials)) + obj = finish_mesh(name, vertices, faces, list(front_materials) + [side_material], mat_ids) + obj["voxel_count"] = len(occupied) + if backing and len(front_materials) > 1: + cx = sum(x for x, _ in points) / len(points) + cz = sum(z for _, z in points) / len(points) + expanded = [(cx + (x - cx) * 1.09, cz + (z - cz) * 1.09) for x, z in points] + back = voxel_volume(name + " | grape back layer", expanded, [side_material], side_material, cell_count, False) + back.parent = obj + back.location = (cell * 0.45, depth, -cell * 0.3) + return obj + + +def make_palette(theme): + if theme == "studio-ink": + return { + "body": material("Studio Ink | graphite ceramic", (0.018, 0.023, 0.032), 0.27, 0.12, 0.28), + "side": material("Studio Ink | shadow graphite", (0.006, 0.008, 0.014), 0.24, 0.1, 0.3), + "accent": material("Studio Ink | warm ivory piping", (0.98, 0.88, 0.68), 0.24, 0.03, 0.22), + "cuff": material("Studio Ink | cuff", (0.045, 0.052, 0.07), 0.31, 0.05, 0.12), + } + if theme == "prism-glow": + colors = [ + (0.015, 0.83, 0.98), (0.07, 0.28, 0.98), (0.28, 0.07, 0.85), + (0.08, 0.55, 0.93), (0.55, 0.16, 0.98), (0.02, 0.68, 0.77), + ] + return {"facets": [material(f"Prism | facet {i + 1}", c, 0.16, 0.36, 0.55) for i, c in enumerate(colors)]} + if theme == "pop-coral": + return { + "body": material("Pop Coral | tangerine rubber", (0.98, 0.12, 0.085), 0.37, 0.0, 0.12), + "side": material("Pop Coral | deep coral side", (0.72, 0.055, 0.05), 0.34, 0.0, 0.1), + "hand": material("Pop Coral | golden rubber", (1.0, 0.54, 0.025), 0.36, 0.0, 0.14), + } + if theme == "pixel-candy": + return { + "front": [ + material("Pixel Candy | strawberry", (0.98, 0.19, 0.48), 0.25, 0.05, 0.3), + material("Pixel Candy | sugar pink", (1.0, 0.35, 0.63), 0.25, 0.03, 0.32), + material("Pixel Candy | rose", (0.86, 0.11, 0.40), 0.27, 0.05, 0.25), + material("Pixel Candy | mint accents", (0.13, 0.88, 0.58), 0.25, 0.02, 0.3), + ], + "side": material("Pixel Candy | grape voxel sides", (0.25, 0.055, 0.52), 0.24, 0.08, 0.34), + } + return { + "body": material("Star Sprout | mint ceramic", (0.18, 0.80, 0.56), 0.24, 0.0, 0.4), + "side": material("Star Sprout | deep mint edge", (0.055, 0.40, 0.31), 0.26, 0.0, 0.32), + "hand": material("Star Sprout | warm ivory", (1.0, 0.91, 0.76), 0.31, 0.0, 0.25), + "accent": material("Star Sprout | marigold star", (1.0, 0.56, 0.035), 0.24, 0.0, 0.35), + "accent_side": material("Star Sprout | golden edge", (0.72, 0.29, 0.015), 0.26, 0.0, 0.26), + "leaf": material("Star Sprout | fresh leaf", (0.08, 0.62, 0.35), 0.25, 0.0, 0.3), + "eye": material("Star Sprout | ink eyes", (0.06, 0.07, 0.09), 0.3), + "cuff": material("Star Sprout | mint cuff", (0.13, 0.63, 0.43), 0.3, 0.0, 0.25), + } + + +def make_arrow(theme, center_x, mats): + if theme == "studio-ink": + obj = extruded_polygon("Studio Ink Arrow", ARROW, 0.24, [mats["body"], mats["side"]], bevel=0.065, dome=0.04) + cx = sum(x for x, _ in ARROW) / len(ARROW) + cz = sum(z for _, z in ARROW) / len(ARROW) + inset = [(cx + (x - cx) * 0.78, cz + (z - cz) * 0.78) for x, z in ARROW] + pipe = curve_line("Ivory inset piping", inset, mats["accent"], depth=0.026, y=-0.16, location_x=center_x) + elif theme == "prism-glow": + palette = mats["facets"] + obj = extruded_polygon("Prism Arrow Crystal", ARROW, 0.25, palette, bevel=0.01, facet_ids=[0, 1, 4, 2, 3, 0, 5]) + elif theme == "pop-coral": + obj = extruded_polygon("Pop Coral Rubber Arrow", ARROW, 0.24, [mats["body"], mats["side"]], bevel=0.10, dome=0.08) + elif theme == "pixel-candy": + obj = voxel_volume("Pixel Candy Arrow Voxels", ARROW, mats["front"], mats["side"]) + else: + obj = extruded_polygon("Star Sprout Ceramic Arrow", ARROW, 0.24, [mats["body"], mats["side"]], bevel=0.085, dome=0.06) + charm = extruded_polygon("Raised star charm", star_points(0.43, -0.30, 0.23, 0.105), 0.075, [mats["accent"], mats["accent_side"]], bevel=0.02) + charm.location = (center_x, -0.17, 0) + sphere("Star eye left", (center_x + 0.385, -0.225, -0.295), (0.025, 0.018, 0.032), mats["eye"]) + sphere("Star eye right", (center_x + 0.465, -0.225, -0.295), (0.025, 0.018, 0.032), mats["eye"]) + leaf_a = sphere("Mint leaf left", (center_x + 0.22, -0.18, 0.03), (0.095, 0.035, 0.055), mats["leaf"]) + leaf_a.rotation_euler[1] = math.radians(-28) + leaf_b = sphere("Mint leaf right", (center_x + 0.58, -0.18, 0.025), (0.095, 0.035, 0.055), mats["leaf"]) + leaf_b.rotation_euler[1] = math.radians(28) + obj.location.x = center_x + obj["cursor_state"] = "arrow" + obj["theme"] = theme + obj["hotspot_local"] = [ARROW[0][0], ARROW[0][1]] + return obj + + +def make_hand(theme, center_x, mats): + if theme == "studio-ink": + obj = curved_hand("Studio Ink Gloved Hand", center_x, mats["accent"], thickness=0.24, edge_round=0.075) + rounded_box("Studio Ink wrist cuff", (center_x, -0.15, -0.30), (0.77, 0.24, 0.23), mats["cuff"], bevel=0.085) + elif theme == "prism-glow": + obj = sculpted_hand("Prism Hand Crystal", center_x, mats["facets"], faceted=True) + elif theme == "pop-coral": + obj = curved_hand("Pop Coral Rubber Hand", center_x, mats["hand"], thickness=0.25, edge_round=0.11) + elif theme == "pixel-candy": + obj = voxel_volume("Pixel Candy Hand Voxels", HAND, mats["front"], mats["side"]) + else: + obj = curved_hand("Star Sprout Ivory Hand", center_x, mats["hand"], thickness=0.22, edge_round=0.085) + rounded_box("Star Sprout mint cuff", (center_x, -0.15, -0.30), (0.80, 0.24, 0.23), mats["cuff"], bevel=0.08) + charm = extruded_polygon("Star Sprout cuff charm", star_points(0.32, -0.29, 0.19, 0.085), 0.065, [mats["accent"], mats["accent_side"]], bevel=0.016) + charm.location = (center_x, -0.30, 0) + sphere("Cuff star eye left", (center_x + 0.285, -0.345, -0.285), (0.020, 0.014, 0.026), mats["eye"]) + sphere("Cuff star eye right", (center_x + 0.350, -0.345, -0.285), (0.020, 0.014, 0.026), mats["eye"]) + leaf_a = sphere("Cuff leaf left", (center_x + 0.16, -0.26, -0.08), (0.075, 0.032, 0.045), mats["leaf"]) + leaf_a.rotation_euler[1] = math.radians(-28) + leaf_b = sphere("Cuff leaf right", (center_x + 0.48, -0.26, -0.075), (0.075, 0.032, 0.045), mats["leaf"]) + leaf_b.rotation_euler[1] = math.radians(28) + obj.location.x = center_x + obj["cursor_state"] = "pointer" + obj["theme"] = theme + obj["hotspot_local"] = [-0.13, 1.18] if theme == "prism-glow" else [0.02, 1.50] + return obj + + +def add_camera_and_lights(scene): + bpy.context.window.scene = scene + target = Vector((0.0, 0.0, 0.30)) + camera_data = bpy.data.cameras.new("Cursor studio camera") + camera_data.type = "ORTHO" + camera_data.ortho_scale = CAMERA_WIDTH + camera = bpy.data.objects.new("Camera", camera_data) + scene.collection.objects.link(camera) + camera.location = (-2.45, -11.0, 2.15) + camera.rotation_euler = (target - camera.location).to_track_quat("-Z", "Y").to_euler() + scene.camera = camera + world = bpy.data.worlds.new(scene.name + " soft studio") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.18, 0.20, 0.24, 1) + world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.45 + scene.world = world + + def area(name, location, energy, size, color): + data = bpy.data.lights.new(name, "AREA") + data.energy, data.shape, data.size, data.color = energy, "DISK", size, color + light = bpy.data.objects.new(name, data) + scene.collection.objects.link(light) + light.location = location + light.rotation_euler = (target - light.location).to_track_quat("-Z", "Y").to_euler() + + area("Large warm key", (-4.8, -5.5, 6.5), 680, 4.3, (1.0, 0.86, 0.72)) + area("Cool soft fill", (4.6, -4.0, 2.6), 360, 3.4, (0.60, 0.78, 1.0)) + area("Cyan rim", (1.2, 3.0, 4.8), 800, 3.2, (0.46, 0.72, 1.0)) + scene.render.engine = "BLENDER_EEVEE" + scene.eevee.taa_render_samples = 64 + scene.render.resolution_x, scene.render.resolution_y = RENDER_WIDTH, RENDER_HEIGHT + scene.render.resolution_percentage = 100 + scene.render.film_transparent = True + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.image_settings.color_depth = "8" + scene.render.image_settings.compression = 12 + scene.view_settings.view_transform = "AgX" + try: + scene.view_settings.look = "AgX - Medium High Contrast" + except TypeError: + pass + return camera + + +def make_overview_scene(results): + """Put every theme's arrow and hand in a single, easy-to-browse Blender scene.""" + themes = [result[0] for result in results] + columns = 3 + rows = math.ceil(len(themes) / columns) + column_spacing = 6.2 + row_spacing = 4.2 + scene = bpy.data.scenes.new("00 Overview - all cursor models") + scene["description"] = ( + "Browse all cursor themes here. Edit a theme in its numbered scene; " + "the overview copies are for inspection only." + ) + + for index, (theme, source_scene, _camera, _arrow_tip, _hand_tip, _arrow, _hand) in enumerate(results): + column = index % columns + row = index // columns + x_offset = (column - (columns - 1) / 2) * column_spacing + z_offset = ((rows - 1) / 2 - row) * row_spacing + title = theme.replace("-", " ").title() + collection = bpy.data.collections.new(f"{index + 1:02d} {title} | Arrow + Hand") + scene.collection.children.link(collection) + for source in source_scene.objects: + if source.get("theme") != theme or source.get("cursor_state") not in {"arrow", "pointer"}: + continue + copy = source.copy() + copy.name = f"{title} | {source.name}" + copy.location += Vector((x_offset, 0.0, z_offset)) + for key in ("theme", "cursor_state", "hotspot_local", "polygon_budget", "model_root"): + if key in copy: + del copy[key] + collection.objects.link(copy) + + camera = add_camera_and_lights(scene) + camera.location = (0.0, -32.0, 0.0) + camera.rotation_euler = (math.pi / 2, 0.0, 0.0) + camera.data.ortho_scale = 20.0 + camera.data.clip_end = 100.0 + scene.render.resolution_x, scene.render.resolution_y = 1600, 900 + for obj in scene.objects: + if obj.type == "LIGHT" and obj.data.type == "AREA": + obj.data.energy *= 2.5 + obj.data.size = 8.0 + + for screen in bpy.data.screens: + for area in screen.areas: + if area.type != "VIEW_3D": + continue + space = area.spaces.active + space.region_3d.view_perspective = "CAMERA" + space.shading.type = "SOLID" + space.shading.color_type = "MATERIAL" + return scene + + +def projected_pixel(scene, camera, center_x, point, y=-0.12): + world = Vector((center_x + point[0], y, point[1])) + uv = world_to_camera_view(scene, camera, world) + return [round(uv.x * RENDER_WIDTH, 2), round((1.0 - uv.y) * RENDER_HEIGHT, 2)] + + +def evaluated_triangles(objects): + depsgraph = bpy.context.evaluated_depsgraph_get() + triangle_count = 0 + for obj in objects: + if obj.type not in {"MESH", "CURVE"}: + continue + mesh = obj.evaluated_get(depsgraph).to_mesh() + try: + triangle_count += sum(max(0, len(poly.vertices) - 2) for poly in mesh.polygons) + finally: + obj.evaluated_get(depsgraph).to_mesh_clear() + return triangle_count + + +def write_png(path, width, height, color_type, pixels): + """Write a small 8-bit PNG without adding a Python imaging dependency.""" + channels = 1 if color_type == 0 else 4 + stride = width * channels + rows = b"".join(b"\0" + pixels[y * stride:(y + 1) * stride] for y in range(height)) + + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + data = b"\x89PNG\r\n\x1a\n" + data += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0)) + data += chunk(b"IDAT", zlib.compress(rows, 7)) + data += chunk(b"IEND", b"") + with open(path, "wb") as file: + file.write(data) + + +def linear_to_srgb(value): + value = max(0.0, min(1.0, value)) + return 12.92 * value if value <= 0.0031308 else 1.055 * value ** (1.0 / 2.4) - 0.055 + + +def model_geometry(scene, root): + """Collect evaluated mesh triangles in the compositor's x-right/y-down/z-toward-camera axes.""" + depsgraph = bpy.context.evaluated_depsgraph_get() + vertices, triangles, face_colors = [], [], [] + for obj in scene.objects: + if obj == root and root.get("cursor_volume_ignore"): + continue + parent = obj.parent + while parent is not None and parent != root: + parent = parent.parent + if obj != root and obj.get("model_root") != root.name and parent != root: + continue + evaluated = obj.evaluated_get(depsgraph) + mesh = evaluated.to_mesh() + if mesh is None: + continue + try: + mesh.calc_loop_triangles() + base = len(vertices) + for vertex in mesh.vertices: + world = obj.matrix_world @ vertex.co + vertices.append(Vector((world.x, -world.z, -world.y))) + materials = [slot.material for slot in evaluated.material_slots] + for triangle in mesh.loop_triangles: + triangles.append(tuple(base + index for index in triangle.vertices)) + polygon = mesh.polygons[triangle.polygon_index] + material_index = polygon.material_index + mat = materials[material_index] if material_index < len(materials) else None + color = mat.diffuse_color[:3] if mat else (0.72, 0.76, 0.82) + face_colors.append(tuple(round(linear_to_srgb(channel) * 255) for channel in color)) + finally: + evaluated.to_mesh_clear() + if not triangles: + raise RuntimeError(f"No mesh triangles found for {root.name}") + return vertices, triangles, face_colors + + +def export_model_volume(scene, root, state, output_dir): + """Bake one editable Blender mesh into a compact SDF and material-color slice atlas.""" + vertices, triangles, face_colors = model_geometry(scene, root) + tree = BVHTree.FromPolygons(vertices, triangles, all_triangles=True) + local_hotspot = root.get("hotspot_local", [0.0, 0.0]) + hotspot_world = root.matrix_world @ Vector((local_hotspot[0], 0.0, local_hotspot[1])) + hx, hy = hotspot_world.x, -hotspot_world.z + z_min = min(vertex.z for vertex in vertices) + z_max = max(vertex.z for vertex in vertices) + hit = tree.ray_cast(Vector((hx, hy, z_min - 1.0)), Vector((0.0, 0.0, 1.0)), z_max - z_min + 2.0) + hotspot_z = hit[0].z if hit[0] is not None else 0.12 + + def model_point(point): + return ((point.x - hx), (point.y - hy), (point.z - hotspot_z)) + + model_vertices = [model_point(point) for point in vertices] + x0, x1 = min(p[0] for p in model_vertices), max(p[0] for p in model_vertices) + y0, y1 = min(p[1] for p in model_vertices), max(p[1] for p in model_vertices) + z0, z1 = min(p[2] for p in model_vertices), max(p[2] for p in model_vertices) + raw_extent = max(x1 - x0, y1 - y0) + if raw_extent <= 1e-6: + raise RuntimeError(f"Degenerate cursor geometry for {root.name}") + pad = raw_extent * (2.0 / VOLUME_XY) + model_scale = raw_extent + 2.0 * pad + lo = [x0 / model_scale - pad / model_scale, y0 / model_scale - pad / model_scale, + z0 / model_scale - pad / model_scale] + hi = [x1 / model_scale + pad / model_scale, y1 / model_scale + pad / model_scale, + z1 / model_scale + pad / model_scale] + size = [hi[0] - lo[0], hi[1] - lo[1]] + hotspot = [-lo[0] / size[0], -lo[1] / size[1]] + thick, max_height = max(0.001, -lo[2]), max(0.001, hi[2]) + + atlas_width = VOLUME_XY * VOLUME_TILES_X + atlas_height = VOLUME_XY * VOLUME_TILES_Y + sdf_pixels = bytearray(atlas_width * atlas_height) + color_pixels = bytearray(atlas_width * atlas_height * 4) + grid_lo = (lo[0], lo[1], lo[2]) + grid_size = (size[0], size[1], hi[2] - lo[2]) + ray_direction = Vector((1.0, 0.000173, 0.000271)).normalized() + step_eps = model_scale * 1e-5 + progress_stride = max(1, VOLUME_Z // 8) + + for iz in range(VOLUME_Z): + tile_x, tile_y = iz % VOLUME_TILES_X, iz // VOLUME_TILES_X + for iy in range(VOLUME_XY): + y = grid_lo[1] + (iy + 0.5) / VOLUME_XY * grid_size[1] + for ix in range(VOLUME_XY): + x = grid_lo[0] + (ix + 0.5) / VOLUME_XY * grid_size[0] + z = grid_lo[2] + (iz + 0.5) / VOLUME_Z * grid_size[2] + point_raw = Vector((hx + x * model_scale, hy + y * model_scale, + hotspot_z + z * model_scale)) + nearest = tree.find_nearest(point_raw) + if nearest[0] is None: + distance = VOLUME_SDF_RANGE + color = (0, 0, 0) + else: + distance = min(nearest[3] / model_scale, VOLUME_SDF_RANGE) + color = face_colors[nearest[2]] + + # Count crossings from outside along +X. A tiny skew avoids rays through shared edges. + ray_y = point_raw.y + (iz % 3 - 1) * step_eps + ray_z = point_raw.z + (iy % 3 - 1) * step_eps + ray_origin = Vector((hx + lo[0] * model_scale - model_scale, ray_y, ray_z)) + inside = False + for _ in range(64): + ray_hit = tree.ray_cast(ray_origin, ray_direction, 4.0 * model_scale) + if ray_hit[0] is None or (ray_hit[0] - point_raw).dot(ray_direction) >= -step_eps: + break + inside = not inside + ray_origin = ray_hit[0] + ray_direction * step_eps + if inside: + distance = -distance + + encoded = max(0, min(255, round(127.5 + 127.5 * distance / VOLUME_SDF_RANGE))) + px = tile_x * VOLUME_XY + ix + py = tile_y * VOLUME_XY + iy + index = py * atlas_width + px + sdf_pixels[index] = encoded + color_index = index * 4 + color_pixels[color_index:color_index + 4] = bytes((*color, 255)) + if iz % progress_stride == 0: + print(f"[cursor-model] {root.name}: baked SDF slice {iz + 1}/{VOLUME_Z}") + + stem = f"{state}-sdf" + write_png(os.path.join(output_dir, stem + ".png"), atlas_width, atlas_height, 0, sdf_pixels) + write_png(os.path.join(output_dir, f"{state}-color.png"), atlas_width, atlas_height, 6, color_pixels) + metadata = { + "width": VOLUME_XY, + "height": VOLUME_XY, + "depth": VOLUME_Z, + "tilesX": VOLUME_TILES_X, + "tilesY": VOLUME_TILES_Y, + "distanceRange": VOLUME_SDF_RANGE, + "size": size, + "hotspot": hotspot, + "top": 0.0, + "thick": thick, + "maxHeight": max_height, + } + metadata_items = list(metadata.items()) + with open(os.path.join(output_dir, stem + ".json"), "w", encoding="utf-8", newline="\n") as file: + file.write("{\n") + for index, (key, value) in enumerate(metadata_items): + encoded = json.dumps(value, separators=(", ", ": ")) + comma = "," if index + 1 < len(metadata_items) else "" + file.write(f'\t{json.dumps(key)}: {encoded}{comma}\n') + file.write("}\n") + return metadata + + +def bake_current_models(): + """Bake the currently opened .blend after an artist imports or edits cursor meshes.""" + count = 0 + for scene in bpy.data.scenes: + for root in scene.objects: + theme, state = root.get("theme"), root.get("cursor_state") + if theme not in {"studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"}: + continue + if state not in {"arrow", "pointer"} or "hotspot_local" not in root: + continue + output_dir = os.path.join(CURSOR_DIR, theme) + os.makedirs(output_dir, exist_ok=True) + metadata = export_model_volume(scene, root, state, output_dir) + count += 1 + print( + f"[cursor-model] baked {theme}/{state}: {metadata['width']}x{metadata['height']}x" + f"{metadata['depth']} SDF" + ) + if not count: + raise RuntimeError("No cursor roots with theme, cursor_state and hotspot_local metadata found") + + +def import_mesh_and_bake(mesh_path, theme, state): + """Replace one cursor's generated geometry with an imported OBJ, STL, or PLY mesh.""" + mesh_path = os.path.abspath(mesh_path) + if not os.path.isfile(mesh_path): + raise RuntimeError(f"Mesh file does not exist: {mesh_path}") + themes = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"] + theme_index = themes.index(theme) + title = theme.replace("-", " ").title() + scene = next((s for s in bpy.data.scenes if any( + o.get("theme") == theme and o.get("cursor_state") == state and "hotspot_local" in o + for o in s.objects + )), None) + if scene is None: + raise RuntimeError(f"No {theme}/{state} cursor root in the opened .blend") + bpy.context.window.scene = scene + root = next( + o for o in scene.objects + if o.get("theme") == theme and o.get("cursor_state") == state and "hotspot_local" in o + ) + overview_collection = bpy.data.collections.get(f"{theme_index + 1:02d} {title} | Arrow + Hand") + if overview_collection is not None: + replaced_names = { + obj.name + for obj in scene.objects + if obj.get("theme") == theme and obj.get("cursor_state") == state + } + for copy in list(overview_collection.objects): + if copy.name.startswith(f"{title} | ") and copy.name[len(title) + 3:] in replaced_names: + bpy.data.objects.remove(copy, do_unlink=True) + for obj in list(scene.objects): + if obj != root and ( + obj.get("model_root") == root.name + or (obj.parent == root and obj.get("cursor_imported")) + ): + bpy.data.objects.remove(obj, do_unlink=True) + root["cursor_volume_ignore"] = True + root.hide_render = True + if root.type == "MESH": + root.data.clear_geometry() + before = set(scene.objects) + if mesh_path.lower().endswith(".obj"): + bpy.ops.wm.obj_import(filepath=mesh_path) + elif mesh_path.lower().endswith(".stl"): + bpy.ops.wm.stl_import(filepath=mesh_path) + elif mesh_path.lower().endswith(".ply"): + bpy.ops.wm.ply_import(filepath=mesh_path) + else: + raise RuntimeError("Supported import formats are .obj, .stl, and .ply") + imported = [obj for obj in set(scene.objects) - before if obj.type in {"MESH", "CURVE", "SURFACE", "FONT"}] + if not imported: + raise RuntimeError(f"The importer found no mesh objects in {mesh_path}") + for obj in imported: + world_matrix = root.matrix_world @ obj.matrix_world + obj.parent = root + obj.matrix_world = world_matrix + obj["cursor_imported"] = True + obj["model_root"] = root.name + obj["theme"] = theme + obj["cursor_state"] = state + output_dir = os.path.join(CURSOR_DIR, theme) + metadata = export_model_volume(scene, root, state, output_dir) + root["polygon_budget"] = evaluated_triangles(imported) + if overview_collection is not None: + columns = 3 + rows = math.ceil(len(themes) / columns) + column = theme_index % columns + row = theme_index // columns + offset = Vector(( + (column - (columns - 1) / 2) * 6.2, + 0.0, + ((rows - 1) / 2 - row) * 4.2, + )) + for source in scene.objects: + if source.get("theme") != theme or source.get("cursor_state") != state: + continue + copy = source.copy() + copy.name = f"{title} | {source.name}" + copy.location += offset + for key in ("theme", "cursor_state", "hotspot_local", "polygon_budget", "model_root"): + if key in copy: + del copy[key] + overview_collection.objects.link(copy) + scene.render.filepath = os.path.join(output_dir, "source.png") + bpy.ops.render.render(write_still=True) + bpy.ops.wm.save_as_mainfile(filepath=BLEND_PATH) + print( + f"[cursor-model] imported {os.path.basename(mesh_path)} as {theme}/{state}; " + f"baked {metadata['width']}x{metadata['height']}x{metadata['depth']} SDF and rendered " + f"{os.path.basename(scene.render.filepath)} ({root['polygon_budget']} triangles)" + ) + + +def build(): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + for scene in list(bpy.data.scenes): + if scene.name != "Scene": + bpy.data.scenes.remove(scene) + base_scene = bpy.context.scene + results = [] + themes = ["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"] + for theme_index, theme in enumerate(themes): + if theme_index == 0: + scene = base_scene + scene.name = "01 Studio Ink" + else: + scene = bpy.data.scenes.new(f"{theme_index + 1:02d} {theme.replace('-', ' ').title()}") + bpy.context.window.scene = scene + bpy.ops.object.select_all(action="SELECT") + bpy.ops.object.delete(use_global=False) + mats = make_palette(theme) + arrow_x, hand_x = -1.9, 1.9 + before_arrow = set(scene.objects) + arrow = make_arrow(theme, arrow_x, mats) + arrow_parts = set(scene.objects) - before_arrow + for part in arrow_parts: + part["model_root"] = arrow.name + part["theme"] = theme + part["cursor_state"] = "arrow" + before_hand = set(scene.objects) + hand = make_hand(theme, hand_x, mats) + hand_parts = set(scene.objects) - before_hand + for part in hand_parts: + part["model_root"] = hand.name + part["theme"] = theme + part["cursor_state"] = "pointer" + camera = add_camera_and_lights(scene) + arrow["polygon_budget"] = evaluated_triangles(arrow_parts) + hand["polygon_budget"] = evaluated_triangles(hand_parts) + results.append(( + theme, + scene, + camera, + projected_pixel(scene, camera, arrow_x, ARROW[0]), + projected_pixel(scene, camera, hand_x, hand["hotspot_local"]), + arrow, + hand, + )) + + overview = make_overview_scene(results) + bpy.context.window.scene = overview + os.makedirs(CURSOR_DIR, exist_ok=True) + bpy.context.preferences.filepaths.save_version = 0 + bpy.ops.wm.save_as_mainfile(filepath=BLEND_PATH) + for theme, scene, camera, arrow_tip, fingertip, arrow, hand in results: + bpy.context.window.scene = scene + output_dir = os.path.join(CURSOR_DIR, theme) + os.makedirs(output_dir, exist_ok=True) + export_model_volume(scene, arrow, "arrow", output_dir) + export_model_volume(scene, hand, "pointer", output_dir) + scene.render.filepath = os.path.join(output_dir, "source.png") + bpy.ops.render.render(write_still=True) + with open(os.path.join(output_dir, "hotspots.json"), "w", encoding="utf-8", newline="\n") as file: + json.dump({"arrow": arrow_tip, "pointer": fingertip}, file, indent=2) + file.write("\n") + print( + f"[cursor-model] {theme}: arrow={arrow['polygon_budget']} tris, " + f"pointer={hand['polygon_budget']} tris; hotspots={arrow_tip}, {fingertip}" + ) + bpy.context.window.scene = overview + print(f"[cursor-model] saved editable scene: {BLEND_PATH}") + + +if "--bake-current" in sys.argv: + bake_current_models() +elif "--import-mesh" in sys.argv: + try: + separator = sys.argv.index("--") + arguments = sys.argv[separator + 1:] + except ValueError: + arguments = [] + parser = argparse.ArgumentParser() + parser.add_argument("--import-mesh", required=True) + parser.add_argument( + "--theme", + choices=["studio-ink", "prism-glow", "pop-coral", "pixel-candy", "star-sprout"], + required=True, + ) + parser.add_argument("--state", choices=["arrow", "pointer"], required=True) + args = parser.parse_args(arguments) + import_mesh_and_bake(args.import_mesh, args.theme, args.state) +else: + build() diff --git a/scripts/model-prism-hand.py b/scripts/model-prism-hand.py new file mode 100644 index 000000000..bfdb7a83f --- /dev/null +++ b/scripts/model-prism-hand.py @@ -0,0 +1,156 @@ +"""Standalone Prism Glow hand, reconstructed from 3d-concept.png in Blender. +Run: blender --background --python scripts/model-prism-hand.py +""" +from pathlib import Path +import math +import random +import json +import bpy +import bmesh +from mathutils import Vector +from mathutils.geometry import delaunay_2d_cdt + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'design/cursors/prism-hand' +OUT.mkdir(parents=True, exist_ok=True) +bpy.ops.wm.read_factory_settings(use_empty=True) +scene = bpy.context.scene +# Landmarks in the reference image's pixel coordinates, clockwise. +outline = [(659,435),(659,339),(672,323),(694,317),(718,319),(734,332), + (738,394),(750,393),(766,402),(773,411),(783,408),(799,416),(806,428), + (816,425),(834,434),(842,444),(841,494),(825,532),(798,572), + (678,577),(663,569),(630,515),(596,464),(592,425),(613,407),(633,408),(648,422)] +# Deliberately sparse gemstone ridges; no radial fan or automatic decimation. +landmarks = [(675,341),(695,335),(716,340),(704,357),(675,399),(695,407), + (722,426),(675,446),(700,455),(681,486),(759,508),(711,538),(688,560), + (744,561),(786,561),(811,502),(792,476),(755,453),(741,409),(754,421), + (781,427),(781,453),(812,442),(825,464),(612,425),(628,433),(615,458), + (649,467),(650,496),(670,538)] +n = len(outline) +points = outline + landmarks +uv, edges, faces, *_ = delaunay_2d_cdt([Vector(p) for p in points], + [(i,(i+1)%n) for i in range(n)], [list(range(n))], 1, 1e-6) +# CDT may reorder vertices. Associate reference landmarks geometrically. +def front_depth(x,y): + if y < 435 and 655 < x < 740: + return -.19 - .22 * max(0,1-abs(x-698)/43) + return -.20 - .28 * max(0,1-((x-716)/130)**2) * max(.3,1-((y-492)/110)**2) +vertices=[] +for p in uv: + is_boundary = any((p-Vector(q)).length < .001 for q in outline) + vertices.append(((p.x-717)/100, -.12 if is_boundary else front_depth(p.x,p.y), (577-p.y)/100+.035)) +mesh_faces=[tuple(f) for f in faces] +front_count=len(mesh_faces) +boundary_ids=[min(range(len(uv)),key=lambda i:(uv[i]-Vector(p)).length) for p in outline] +# A narrow crown bevel and a deep, faceted pavilion make a solid cut gemstone. +previous=boundary_ids +for depth,shrink in [(.025,1.035),(.28,.97),(.46,.83)]: + ids=[] + for x,y in outline: + ids.append(len(vertices)) + vertices.append(((x-717)/100*shrink,depth,(577-y)/100*shrink+.035+(1-shrink)*1.1)) + for i in range(n): + j=(i+1)%n + mesh_faces.extend([(previous[i],previous[j],ids[j]),(previous[i],ids[j],ids[i])]) + previous=ids +# Back follows the same constrained topology, so finger valleys remain intact. +backmap={} +for i,p in enumerate(uv): + if i in boundary_ids: + backmap[i]=previous[boundary_ids.index(i)] + else: + backmap[i]=len(vertices) + vertices.append(((p.x-717)/100*.83,.49,(577-p.y)/100*.83+.222)) +mesh_faces.extend([tuple(backmap[i] for i in reversed(f)) for f in faces]) +mesh=bpy.data.meshes.new('Hand-cut crystal topology') +mesh.from_pydata(vertices,[],mesh_faces) +mesh.update() +obj=bpy.data.objects.new('Prism Glow - Hand',mesh) +scene.collection.objects.link(obj) +bm=bmesh.new(); bm.from_mesh(mesh) +bmesh.ops.recalc_face_normals(bm,faces=bm.faces) +bm.to_mesh(mesh); bm.free() +colors=[('Sapphire',(.008,.035,.62)),('Electric blue',(.008,.17,.95)), + ('Cyan',(.005,.7,.95)),('Ice',(.18,.79,1)),('Violet',(.20,.012,.72)), + ('Amethyst',(.43,.035,.88)),('Deep blue',(.003,.013,.19))] +for name,color in colors: + mat=bpy.data.materials.new(name); mat.diffuse_color=(*color,1); mat.use_nodes=True + bs=mat.node_tree.nodes.get('Principled BSDF') + bs.inputs['Base Color'].default_value=(*color,1) + bs.inputs['Metallic'].default_value=.0 + bs.inputs['Roughness'].default_value=.025 + bs.inputs['IOR'].default_value=1.65 + bs.inputs['Transmission Weight'].default_value=.82 + bs.inputs['Coat Weight'].default_value=.35 + bs.inputs['Emission Color'].default_value=(*color,1) + bs.inputs['Emission Strength'].default_value=.18 + mesh.materials.append(mat) +rng=random.Random(28) +for p in mesh.polygons: + center=sum((mesh.vertices[v].co for v in p.vertices),Vector())/len(p.vertices) + if center.x<-.35: choices=[0,1,2,3,6] + elif center.x>.6: choices=[0,1,4,5,6] + else: choices=[0,1,2,3,4] + p.material_index=rng.choice(choices) + p.use_smooth=False +# Reference packed in the file and available in an image editor. +ref=bpy.data.images.load(str(ROOT/'design/cursors/3d-concept.png')); ref.use_fake_user=True; ref.pack() +obj['reference']='3d-concept.png: Prism Glow hand, second row' +obj['design']='Closed faceted gemstone; manually traced silhouette and crown landmarks' +mesh.calc_loop_triangles() +report={'vertices':len(mesh.vertices),'triangles':len(mesh.loop_triangles)} +bm=bmesh.new();bm.from_mesh(mesh) +report['non_manifold_edges']=sum(not e.is_manifold for e in bm.edges);bm.free() +(OUT/'mesh-info.json').write_text(json.dumps(report,indent=2)) +print('MESH_REPORT',report) + +def aim(ob,point): ob.rotation_euler=(Vector(point)-ob.location).to_track_quat('-Z','Y').to_euler() +def area(name,loc,power,color,size,target=(0,0,1.3)): + data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=color;data.shape='DISK';data.size=size + light=bpy.data.objects.new(name,data);scene.collection.objects.link(light);light.location=loc;aim(light,target) +area('Large white softbox',(-3,-4,6),650,(.8,.9,1),4) +area('Cyan edge',(-3,1,2.5),500,(.08,.7,1),2) +area('Violet rim',(3,1,3.5),700,(.48,.12,1),2) +area('Front strip',(2,-4,1),100,(.7,.87,1),.65) +area('Cyan foot',(-.5,-.15,.15),35,(.01,.8,1),.3,target=(0,0,1)) +area('Broad transmitted light',(0,3,3),700,(.25,.65,1),3) +area('Top glint',(0,0,5),350,(1,1,1),1) +bpy.ops.mesh.primitive_plane_add(size=200,location=(0,0,0)) +floor=bpy.context.object;floor.name='Studio floor (render only)' +mat=bpy.data.materials.new('Charcoal studio');mat.use_nodes=True +bs=mat.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=(.018,.024,.038,1);bs.inputs['Roughness'].default_value=.28 +floor.data.materials.append(mat) +world=bpy.data.worlds.new('Studio');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.16,.2,.3,1);world.node_tree.nodes['Background'].inputs[1].default_value=.35;scene.world=world +bpy.ops.object.camera_add(location=(0,-9,3.0));cam=bpy.context.object;aim(cam,(0,0,1.36));cam.data.type='ORTHO';cam.data.ortho_scale=3.55;scene.camera=cam +scene.render.engine='CYCLES';scene.cycles.samples=128;scene.cycles.use_denoising=True +scene.cycles.max_bounces=12;scene.cycles.transmission_bounces=8 +scene.render.resolution_x=1000;scene.render.resolution_y=1000;scene.render.resolution_percentage=100 +scene.view_settings.view_transform='Standard' +scene.view_settings.look='None' +scene.view_settings.exposure=-.8 +# Open directly on the selected hand in material preview. +bpy.ops.object.select_all(action='DESELECT');obj.select_set(True);bpy.context.view_layer.objects.active=obj +for screen in bpy.data.screens: + for a in screen.areas: + if a.type=='VIEW_3D': + a.spaces.active.region_3d.view_distance=5.5 + a.spaces.active.region_3d.view_location=(0,0,1.3) + a.spaces.active.region_3d.view_rotation=cam.rotation_euler.to_quaternion() + a.spaces.active.shading.type='MATERIAL' +floor.hide_set(True) +for ob in scene.objects: + if ob.type in {'LIGHT','CAMERA'}: ob.hide_set(True) +scene.render.filepath=str(OUT/'prism-hand-render.png') +bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'prism-glow-hand.blend')) +bpy.ops.render.render(write_still=True) +# Export only the editable hand, with its materials. +export_obj = obj.copy() +export_obj.data = obj.data.copy() +scene.collection.objects.link(export_obj) +export_obj.scale = (0.75, 0.75, 0.75) +export_obj.location.z = -0.4 +export_obj.name = 'Prism Glow - Hand (cursor scale)' +bpy.ops.object.select_all(action='DESELECT') +export_obj.select_set(True) +bpy.context.view_layer.objects.active = export_obj +bpy.ops.wm.obj_export(filepath=str(OUT/'prism-glow-hand.obj'),export_selected_objects=True) diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts index ada3ee416..c54204ec9 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -15,9 +15,12 @@ export interface CursorThemeAsset { height: number; hotspotX: number; hotspotY: number; - /** The compositor models this state in 3D (`crates/compositor/src/sculpt.rs`) instead of - * extruding the flat asset, which stays the 2D art. */ + /** The compositor has a dedicated 3D model for this state; the PNG remains the 2D art. */ sculpted?: boolean; + /** SDF volume baked from the editable Blender mesh for the compositor's 3D cursor mode. */ + modelSdfPath?: string; + /** Material-color volume baked from the same Blender mesh. */ + modelColorPath?: string; } export interface CursorTheme { @@ -51,8 +54,7 @@ export interface CursorSprite { /** Hotspot as a fraction of the image width / height. */ hotspotX: number; hotspotY: number; - /** The compositor's sculpted model for this state in 3D, `"/"` - * (`crates/compositor/src/sculpt.rs`). Without it, 3D extrudes the sprite. */ + /** Identifies a dedicated 3D model as `"/"`; unmodeled states extrude the PNG. */ sculpt?: string; } @@ -135,8 +137,8 @@ export function readCursorAsArrow( * * To add one: drop arrow.png/pointer.png into public/cursors// and add an entry here * with hotspots normalized to the 32-logical reference (divide a 128px-pack hotspot by 4). - * Mark an asset `sculpted` when the compositor has a 3D model for it (`sculpt.rs`); other states - * are extruded from their sprite in 3D. An id that leaves this list reads back as the default art + * Mark an asset `sculpted` when the compositor has a 3D model for it; other states are extruded + * from their sprite in 3D. An id that leaves this list reads back as the default art * through `normalizeCursorThemeId`, so a project saved with it still opens. */ export const CURSOR_THEMES: readonly CursorTheme[] = [ @@ -148,17 +150,21 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ assetPath: "cursors/studio-ink/arrow.png", width: 32, height: 32, - hotspotX: 6.2304, - hotspotY: 2.0992, + hotspotX: 6.7136, + hotspotY: 2.4992, sculpted: true, + modelSdfPath: "cursors/studio-ink/arrow-sdf.png", + modelColorPath: "cursors/studio-ink/arrow-color.png", }, pointer: { assetPath: "cursors/studio-ink/pointer.png", width: 32, height: 32, - hotspotX: 12.848, - hotspotY: 2.0704, + hotspotX: 14.0096, + hotspotY: 3.6256, sculpted: true, + modelSdfPath: "cursors/studio-ink/pointer-sdf.png", + modelColorPath: "cursors/studio-ink/pointer-color.png", }, }, }, @@ -170,17 +176,21 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ assetPath: "cursors/prism-glow/arrow.png", width: 32, height: 32, - hotspotX: 6.3456, - hotspotY: 2.0672, + hotspotX: 6.7552, + hotspotY: 2.4992, sculpted: true, + modelSdfPath: "cursors/prism-glow/arrow-sdf.png", + modelColorPath: "cursors/prism-glow/arrow-color.png", }, pointer: { assetPath: "cursors/prism-glow/pointer.png", width: 32, height: 32, - hotspotX: 11.968, - hotspotY: 2.0352, + hotspotX: 14.6432, + hotspotY: 8.336, sculpted: true, + modelSdfPath: "cursors/prism-glow/pointer-sdf.png", + modelColorPath: "cursors/prism-glow/pointer-color.png", }, }, }, @@ -192,17 +202,21 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ assetPath: "cursors/pop-coral/arrow.png", width: 32, height: 32, - hotspotX: 10.4768, - hotspotY: 2.1792, + hotspotX: 6.7264, + hotspotY: 2.4416, sculpted: true, + modelSdfPath: "cursors/pop-coral/arrow-sdf.png", + modelColorPath: "cursors/pop-coral/arrow-color.png", }, pointer: { assetPath: "cursors/pop-coral/pointer.png", width: 32, height: 32, - hotspotX: 12.3456, - hotspotY: 2, + hotspotX: 14.08, + hotspotY: 4.0768, sculpted: true, + modelSdfPath: "cursors/pop-coral/pointer-sdf.png", + modelColorPath: "cursors/pop-coral/pointer-color.png", }, }, }, @@ -214,17 +228,21 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ assetPath: "cursors/pixel-candy/arrow.png", width: 32, height: 32, - hotspotX: 7.3664, - hotspotY: 2, + hotspotX: 6.6656, + hotspotY: 3.7152, sculpted: true, + modelSdfPath: "cursors/pixel-candy/arrow-sdf.png", + modelColorPath: "cursors/pixel-candy/arrow-color.png", }, pointer: { assetPath: "cursors/pixel-candy/pointer.png", width: 32, height: 32, - hotspotX: 13.376, - hotspotY: 1.9264, + hotspotX: 13.5712, + hotspotY: 3.3312, sculpted: true, + modelSdfPath: "cursors/pixel-candy/pointer-sdf.png", + modelColorPath: "cursors/pixel-candy/pointer-color.png", }, }, }, @@ -236,17 +254,21 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ assetPath: "cursors/star-sprout/arrow.png", width: 32, height: 32, - hotspotX: 4.7232, - hotspotY: 2.1152, + hotspotX: 6.5088, + hotspotY: 2.4992, sculpted: true, + modelSdfPath: "cursors/star-sprout/arrow-sdf.png", + modelColorPath: "cursors/star-sprout/arrow-color.png", }, pointer: { assetPath: "cursors/star-sprout/pointer.png", width: 32, height: 32, - hotspotX: 13.1712, - hotspotY: 2.0384, + hotspotX: 14.0288, + hotspotY: 3.728, sculpted: true, + modelSdfPath: "cursors/star-sprout/pointer-sdf.png", + modelColorPath: "cursors/star-sprout/pointer-color.png", }, }, }, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 2eee5ecce..939006fd8 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -755,3 +755,4 @@ On macOS 15.2+ it opens at launch, until it has been closed once, while one of i | 2026-09-26 | worktree `fix-3d-cameras-left-right-02603d`, `claude/fix-3d-cameras-left-right-02603d` on `cbf85dc7` + this change; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor`, the rest of `bin/win32-x64` copied from the installed `v1.13.0-rc.6` (ffmpeg DLLs hash-identical to the vendored SDK); renderer rebuilt with `npm run build-vite`. Reference: installed `v1.13.0-rc.6` (= v1.13.0) through its own CLI | Windows 11 26200, NVENC export | Partial — no defect | **3D cameras against v1.13.0, `openscreen export` on both builds**, same synthetic 14 s 1080p grid and cursor sidecar (three held positions), zoom depth 2, `hideCursor`, motion blur 0. During the hold (2.5, 3.5, 7.5, 11.5 s), `left` here and `iso` in v1.13.0 differ only in a ~50×65 px box around the cursor sprite (0.05–0.09 % of pixels > 24; the cursor size changed since); same for `follow-cursor`. `iso` here renders byte-identical to `left`. `right` is the mirror of `left`: screen edge 1–2 px apart, row by row. With motion blur 0.35 the orbit differs while the camera moves (mean abs diff 9, 9 % of pixels): the mode-8 trail added on 25/09, kept on purpose. Not covered: editor preview, GUI, macOS and Linux, zoom ramps (their pacing changed with #683). | | 2026-09-26 | worktree `openscreen-screen-mask-zoom-e21ce9`, `claude/openscreen-screen-mask-zoom-e21ce9` on `1d67754c` (`integration/demo-never-ugly`) + the frame-container change; `compositor_view.node` rebuilt from this tree by `npm run build:native:compositor`, baseline addon and renderer built from `1d67754c` in a scratch worktree; ffmpeg DLLs hash-identical to the vendored SDK; renderer rebuilt with `vite build` | Windows 11 26200, h264 export | Partial — no defect | **Block layouts under a window or device frame: the frame is the fixed container, the footage zooms and tilts inside it, and the block lays the frame out beside the camera. Through the app's real export (`electron . export -o `).** Source: synthetic 1920×1080 `testsrc2` screen and 1280×720 `mandelbrot` webcam, 8 s. Legacy projects, default padding, a depth-4 zoom at focus (0.3, 0.6) on [1.5 s, 3.5 s] and a depth-3 `iso` zoom on [5.5 s, 7.8 s], frames at 0.2, 2.8 and 7.0 s. `dual-frame` (16:9) and `vertical-stack` (9:16) under window, laptop, phone and monitor. Baseline: the whole frame zooms and tilts over the camera, and at rest the laptop's deck and the monitor's stand run under it. New: the frame stays at rest, lined up with the camera on its body, deck and stand clear of it; the flat zoom and the `iso` tilt stay inside the aperture, over the device's black glass or the window chrome's fill, with square top corners under the title bar. `picture-in-picture` with and without a laptop, `no-webcam` with a laptop, and both block layouts without a frame: every decoded frame identical to the baseline (framemd5, 480 video frames plus audio). **Not covered:** the GUI editor preview (same `plan_frame` and `computeCompositeLayout`, not driven), `follow-cursor` inside a frame (unit-tested only), the Auto format with a frame in a block layout (its resting shape ignores the frame, so the camera is not exactly square there), macOS and Linux rendering (WGSL validated with naga, Metal not compiled). | | 2026-09-27 | worktree `camera-size-zoom-balance-7e14d7`, `claude/camera-size-zoom-balance-7e14d7` on `7aa00935` (`integration/demo-never-ugly`) + the camera size change; `compositor_view.node` built from this tree (`cargo build -p compositor-view-napi --release`), baseline addon built the same way with the old `1/zoom` curve; renderer built with `vite build` for each side, the baseline with the old 10–35 bounds; rest of `bin/win32-x64` copied from a 26/09 build, ffmpeg DLLs hash-identical to the vendored SDK | Windows 11 26200, NVENC export | Partial — no defect | **Camera size range and shrink on zoom, through the app's real export (`electron . export -o `).** Source: a real 1920×1080 recording with its 640×480 webcam; legacy 16:9 projects, picture-in-picture bottom right, shrink on zoom on, a depth-3 (1.8×) zoom on [4 s, 10 s]; frames at 2.5 s and 7 s, camera boxes measured by difference with a camera-less export. During the zoom: default 25 %, 150×113 → **210×158** px; minimum 10 → 15 %, 60×45 → **126×95**; maximum 35 → 50 %, 210×158 → **420×315**, each equal to the computed rect. At rest: 378 and 540 px wide at 35 and 50 %. **Not covered:** the GUI editor preview and the slider (unit-tested: `min` 15, `max` 50), the square camera, macOS and Linux rendering (same `plan_frame`, not compiled here). | +| 2026-09-28 | worktree `cursor-blender-models`, branch `codex/blender-cursor-refresh` @ `765bfb5c` (rebased on `2416e44c3`); Blender 5.2.1 generated the cursor meshes and atlases; `cargo check --manifest-path crates/compositor/Cargo.toml` (Windows FXC), `naga --bulk-validate crates/compositor/src/vk_shaders/layer.wgsl`, `tsc --noEmit`, and Biome passed | Windows dev host | Skipped — native UI access unavailable | **2D/3D cursor preview and export.** This session's computer-use connector exposes browser surfaces only (`apps: []`); it cannot drive the Electron window. No preview image or exported frame was measured. **Not covered:** the 3D cursor and 2D fallback in the app, exported pixels, macOS Metal or Linux runtime rendering. | \ No newline at end of file