diff --git a/assets/locales/pending/section-hatch-by-part.json b/assets/locales/pending/section-hatch-by-part.json new file mode 100644 index 0000000000..85c32ee634 --- /dev/null +++ b/assets/locales/pending/section-hatch-by-part.json @@ -0,0 +1,17 @@ +{ + "en": { + "prefs.section_hatch": "Section hatch", + "prefs.section_hatch_by_part": "By part", + "prefs.section_hatch_one": "One style" + }, + "ja": { + "prefs.section_hatch": "断面ハッチング", + "prefs.section_hatch_by_part": "部品ごと", + "prefs.section_hatch_one": "単一スタイル" + }, + "zh": { + "prefs.section_hatch": "剖面线", + "prefs.section_hatch_by_part": "按零件", + "prefs.section_hatch_one": "单一样式" + } +} diff --git a/assets/shaders/viewport.wgsl b/assets/shaders/viewport.wgsl index 8960cf20f2..6eb301683d 100644 --- a/assets/shaders/viewport.wgsl +++ b/assets/shaders/viewport.wgsl @@ -18,6 +18,8 @@ struct U { depth: vec4, // x = 1: reversed depth (1 near, 0 far); y, z: the faces' push (`FACE_BIAS`) tint: vec4, // xyz added to a whole hovered body's colour before lighting sel: vec4, // xyz a whole selected body's colour before lighting + // the section hatch colours by part (`solidcraft_render::hatch`), the first the cap's own + hatch: array, 3>, }; fn reversed() -> bool { @@ -37,16 +39,25 @@ fn clipped(p: vec3) -> bool { return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w; } @group(0) @binding(0) var u: U; -// The scene's colours, 2048 entries to a row: an R32Uint texture rather than a storage buffer, -// so the viewport draws on WebGL2 (which has no storage buffers) as well. +// The scene's colours, 2048 entries to a row: an Rg32Uint texture rather than a storage buffer, +// so the viewport draws on WebGL2 (which has no storage buffers) as well. Each entry is the +// colour's bytes, then 1 + the section hatch of the body it colours (0: the cap's own). @group(0) @binding(1) var pal: texture_2d; -/// The colour at palette entry `i`, as byte/255 (the old Unorm8x4 conversion, bit for bit). -fn palette(i: u32) -> vec4 { - let v = textureLoad(pal, vec2(i32(i & 2047u), i32(i >> 11u)), 0).x; +fn palette_entry(i: u32) -> vec2 { + return textureLoad(pal, vec2(i32(i & 2047u), i32(i >> 11u)), 0).xy; +} + +/// A palette entry's colour bytes as byte/255 (the old Unorm8x4 conversion, bit for bit). +fn unpack_color(v: u32) -> vec4 { return vec4(f32(v & 255u), f32((v >> 8u) & 255u), f32((v >> 16u) & 255u), f32((v >> 24u) & 255u)) / 255.0; } +/// The colour at palette entry `i`. +fn palette(i: u32) -> vec4 { + return unpack_color(palette_entry(i).x); +} + fn to_linear(c: vec3) -> vec3 { return select(c, pow((c + vec3(0.055)) / vec3(1.055), vec3(2.4)), c > vec3(0.04045)); } @@ -63,6 +74,8 @@ struct TOut { @location(0) n: vec3, @location(1) c: vec4, @location(2) wp: vec3, + // 1 + the section hatch of the body (0: the cap's own), from the palette. + @location(3) @interpolate(flat) h: u32, }; @vertex @@ -70,7 +83,9 @@ fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: var o: TOut; o.pos = u.vp * vec4(p, 1.0); o.n = n; - o.c = palette(c); + let e = palette_entry(c); + o.c = unpack_color(e.x); + o.h = e.y; o.wp = p; return o; } @@ -92,7 +107,9 @@ fn vs_tri_inst( var o: TOut; o.pos = u.vp * vec4(wp, 1.0); o.n = vec3(dot(r0.xyz, n), dot(r1.xyz, n), dot(r2.xyz, n)); - o.c = palette(c); + let e = palette_entry(c); + o.c = unpack_color(e.x); + o.h = e.y; o.wp = wp; return o; } @@ -107,6 +124,7 @@ struct FOut { @builtin(position) pos: vec4, @location(0) rp: vec3, @location(1) c: vec4, + @location(2) @interpolate(flat) h: u32, }; @vertex @@ -122,7 +140,9 @@ fn vs_tri_inst_flat( var o: FOut; o.pos = u.vp * vec4(wp, 1.0); o.rp = wp - u.eye.xyz; - o.c = palette(c); + let e = palette_entry(c); + o.c = unpack_color(e.x); + o.h = e.y; return o; } @@ -138,6 +158,7 @@ fn flat_fragment(i: FOut, front: bool) -> TOut { o.pos = i.pos; o.n = n; o.c = i.c; + o.h = i.h; o.wp = i.rp + u.eye.xyz; return o; } @@ -294,11 +315,19 @@ fn solid(i: TOut, front: bool) -> vec4 { if (clipped(i.wp)) { discard; } if (!front && length(u.clip.xyz) > 0.5) { // The cut face in its body's colour ("from component" colouring), tinted toward the - // cap colour so it reads as a cut, hatched at 45 degrees on screen; with the hatch off + // cap colour so it reads as a cut, hatched at 45 degrees on screen; by part, toward the + // body's own hatch colour and at its angle (45 or 135 degrees). With the hatch off // (`u.cap.a` 0), plain in the body's own colour. if (u.cap.a < 0.5) { return out_color(vec4(i.c.rgb, 1.0)); } - let base = mix(i.c.rgb, u.cap.rgb, 0.35); - let h = fract((i.pos.x + i.pos.y) / 9.0); + var tint = u.cap.rgb; + var turn = 1.0; + if (i.h > 0u) { + let k = i.h - 1u; + tint = u.hatch[min(k >> 1u, 2u)].rgb; + turn = select(1.0, -1.0, (k & 1u) == 1u); + } + let base = mix(i.c.rgb, tint, 0.35); + let h = fract((i.pos.x + turn * i.pos.y) / 9.0); return out_color(vec4(select(base, base * 0.6, h < 0.22), 1.0)); } if (u.ana.x > 0.5 && length(i.n) > 0.5) { diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index cf1fda870a..b28a3576ec 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -187,6 +187,11 @@ impl Tokens { dimension_driven: Color32::from_rgb(120, 126, 138), sketch_point_free: Color32::from_rgb(30, 90, 200), section_cap: Color32::from_rgb(214, 128, 72), + // A steel and a sage that tint a body about as strongly as the cap does and to within + // a few points of its lightness, so the cut stays calm, while staying apart from the + // cap, each other and the body in every colour vision mode (dark keeps them, as the + // cap). + section_hatch: [Color32::from_rgb(84, 138, 168), Color32::from_rgb(108, 180, 126)], dialog_bg: Color32::from_rgb(250, 251, 252), dialog_border: Color32::from_rgb(213, 219, 227), chip: Color32::from_rgb(38, 120, 218), diff --git a/crates/engine/src/cmd/file_thumbnail.rs b/crates/engine/src/cmd/file_thumbnail.rs index 8e43ceffa2..c377b1c733 100644 --- a/crates/engine/src/cmd/file_thumbnail.rs +++ b/crates/engine/src/cmd/file_thumbnail.rs @@ -37,7 +37,7 @@ fn scene(s: &Session) -> Option<(Scene, Camera)> { let face_colors = s.doc.face_colors(body).into_iter().map(|(face, look)| (face as u32, Rgb(look.color[0], look.color[1], look.color[2]))).collect(); let opacity = s.doc.model_body_opacity(body) as f32; - scene.meshes.push(SceneMesh { mesh, color, face_colors, opacity, closed: body.is_closed() }); + scene.meshes.push(SceneMesh { mesh, color, face_colors, opacity, closed: body.is_closed(), hatch: Default::default() }); } if bounds.is_empty() { return None; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index eef7ee2511..df07a28ee1 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -72,6 +72,8 @@ pub struct Palette { pub axes: [Rgb; 3], /// What the section cap is tinted toward. pub cap: Rgb, + /// The section hatch's other colours, after the cap's (`Scene::part_hatch`). + pub hatch: [Rgb; 2], /// Isocurve Analysis: the faces' isoparametric curves. pub isocurve: Rgb, /// The surface analysis colours. @@ -97,6 +99,7 @@ impl Palette { sketch_centerline: colors::SKETCH_CENTERLINE, axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], cap: colors::SECTION_CAP, + hatch: solidcraft_render::SECTION_HATCH, isocurve: colors::ISOCURVE, analysis: AnalysisColors::DEFAULT, }; @@ -589,6 +592,7 @@ fn build_scene( clip: section.map(|(o, n)| (n, n.dot(o))), cap: Some(p.cap), plain_cap: !s.section_hatch, + part_hatch: Some([p.cap, p.hatch[0], p.hatch[1]]), analysis: s.analysis.map(|a| SceneAnalysis { mode: a.mode(), colors: p.analysis }), ..Default::default() }; @@ -655,6 +659,8 @@ fn build_scene( .flatten() .collect(); let mut thread_budget = crate::cosmetic_threads::Budget::default(); + // Where the section cuts each closed solid body, by its mesh in `sc.meshes`: their hatches. + let mut cuts: Vec<(usize, u64, Vec<(Vec3, Vec3)>)> = Vec::new(); for body in st.bodies.iter().filter(|b| s.body_visible(&b.name)) { let selected = selected_occurrences.contains(&body.name) || s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); let mesh = body.mesh(); @@ -676,7 +682,9 @@ fn build_scene( // A see-through appearance is drawn see-through, as the viewport draws it; a selected // body is drawn solid, so the selection colour reads. let opacity = if selected { 1.0 } else { s.doc.model_body_opacity(body) as f32 }; - sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors, opacity, closed: body.is_closed() }); + let cut_solid = section.is_some() && body.is_closed() && opacity >= solidcraft_render::SEE_THROUGH; + sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color, face_colors, opacity, closed: body.is_closed(), hatch: Default::default() }); + let at = sc.meshes.len() - 1; if cosmetic_threads && thread_budget.available() && s.doc.model_body_opacity(body) >= 0.999 @@ -694,8 +702,21 @@ fn build_scene( } // The outline of the body where the section cuts it. if let Some((o, n)) = section { - for (a, b) in section_segments(&mesh, o, n) { - sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, alpha: 1.0, width: 1.6, on_top: false }); + let segments = section_segments(&mesh, o, n); + for (a, b) in &segments { + sc.lines.push(SceneLine { points: vec![*a, *b], color: p.edge, alpha: 1.0, width: 1.6, on_top: false }); + } + if cut_solid && !segments.is_empty() { + cuts.push((at, solidcraft_render::hatch::seed(&body.name), segments)); + } + } + } + if let Some((_, n)) = section { + let parts: Vec = + cuts.iter().map(|(_, seed, segments)| solidcraft_render::hatch::CutPart { seed: *seed, segments }).collect(); + for ((at, _, _), hatch) in cuts.iter().zip(solidcraft_render::hatch::hatches(&parts, n)) { + if let Some(m) = sc.meshes.get_mut(*at) { + m.hatch = hatch; } } } diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs index 1548e1c095..6f81265b61 100644 --- a/crates/engine/src/view_palette_tests.rs +++ b/crates/engine/src/view_palette_tests.rs @@ -93,6 +93,7 @@ const MARK: Palette = Palette { sketch_centerline: Rgb(43, 44, 45), axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)], cap: Rgb(55, 56, 57), + hatch: [Rgb(61, 62, 63), Rgb(64, 65, 66)], isocurve: Rgb(58, 59, 60), analysis: solidcraft_render::AnalysisColors { draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs index 8a41c6bc49..90e120ed70 100644 --- a/crates/gpu/src/capture.rs +++ b/crates/gpu/src/capture.rs @@ -12,10 +12,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use solidcraft_geom::Vec3; -use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene}; +use solidcraft_render::hatch::Hatch; +use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene, SceneMesh}; use wgpu::util::DeviceExt; -use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform}; +use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Palette, PaletteIndex, Renderer, analysis_colors, analysis_uniform}; use crate::{GpuError, Result, popped}; /// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual @@ -401,6 +402,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: scene.part_hatch.map_or_else(Default::default, |c| c.map(|Rgb(r, g, b)| [r, g, b].map(|x| f32::from(x) / 255.0))), } } @@ -408,16 +410,44 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces /// normals; a face normal where a vertex has none), lines as wide lines (over everything when /// `on_top`), the grid under everything. A body whose appearance is see-through /// ([`solidcraft_render::SceneMesh::glass`]) goes into the scene's glass, drawn as the viewport -/// draws glass: far sides first, then near sides, so what lies inside it shows through. +/// draws glass: far sides first, then near sides, so what lies inside it shows through. When the +/// scene hatches its cut by part ([`Scene::part_hatch`]), each body's colours are palette entries +/// of its own, carrying its hatch. pub fn scene_geometry(scene: &Scene) -> GpuScene { - let mut sc = GpuScene::default(); - for m in &scene.meshes { - let alpha = (m.alpha() * 255.0).round().clamp(0.0, 255.0) as u8; - let c4 = |c: Rgb| [c.0, c.1, c.2, alpha]; + let by_part = scene.part_hatch.is_some() && scene.clip.is_some(); + // Per body, its colours' stable entries (by part), and the stable palette they make. + let mut own: Vec> = Vec::new(); + let mut stable: Vec<[u8; 4]> = Vec::new(); + let mut hatches: Vec<(PaletteIndex, Hatch)> = Vec::new(); + let c4 = |m: &SceneMesh, c: Rgb| [c.0, c.1, c.2, (m.alpha() * 255.0).round().clamp(0.0, 255.0) as u8]; + if by_part { + for m in &scene.meshes { + let mut map = HashMap::new(); + for c in std::iter::once(m.color).chain(m.face_colors.iter().map(|(_, c)| *c)) { + map.entry(c4(m, c)).or_insert_with(|| { + let i = PaletteIndex(u32::try_from(stable.len()).unwrap_or(0)); + stable.push(c4(m, c)); + hatches.push((i, m.hatch)); + i + }); + } + own.push(map); + } + } + let mut palette = Palette::with_stable(stable); + for (i, h) in hatches { + palette.set_hatch(i, h); + } + let mut sc = GpuScene::with_palette(palette); + for (mi, m) in scene.meshes.iter().enumerate() { let faces: HashMap = m.face_colors.iter().copied().collect(); let mesh = &m.mesh; for (ti, t) in mesh.triangles.iter().enumerate() { - let col = sc.color(c4(mesh.tri_face.get(ti).and_then(|f| faces.get(f)).copied().unwrap_or(m.color))); + let rgba = c4(m, mesh.tri_face.get(ti).and_then(|f| faces.get(f)).copied().unwrap_or(m.color)); + let col = match own.get(mi).and_then(|o| o.get(&rgba)) { + Some(i) => *i, + None => sc.color(rgba), + }; let [Some(a), Some(b), Some(c)] = t.map(|k| mesh.positions.get(k as usize).copied()) else { continue }; let flat = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z); for (k, p) in t.iter().zip([a, b, c]) { @@ -557,7 +587,6 @@ mod tests { use super::*; use crate::view::TRI_SIZE; use solidcraft_geom::Mesh; - use solidcraft_render::SceneMesh; use std::sync::Arc; /// One triangle facing the camera. @@ -582,7 +611,14 @@ mod tests { /// headless picture of a translucent target shows the tool inside it. #[test] fn a_see_through_body_is_glass_at_its_own_alpha_and_a_solid_one_is_not() { - let mesh = |opacity: f32| SceneMesh { mesh: sheet(), color: Rgb(200, 30, 30), face_colors: Vec::new(), opacity, closed: true }; + let mesh = |opacity: f32| SceneMesh { + mesh: sheet(), + color: Rgb(200, 30, 30), + face_colors: Vec::new(), + opacity, + closed: true, + hatch: Default::default(), + }; let geometry = |opacity: f32| scene_geometry(&Scene { meshes: vec![mesh(opacity)], ..Default::default() }); let solid = geometry(1.0); @@ -606,7 +642,14 @@ mod tests { /// painting one face does not make the body solid again. #[test] fn a_painted_face_keeps_its_bodys_opacity() { - let mesh = SceneMesh { mesh: sheet(), color: Rgb(200, 30, 30), face_colors: vec![(0, Rgb(0, 255, 0))], opacity: 0.5, closed: true }; + let mesh = SceneMesh { + mesh: sheet(), + color: Rgb(200, 30, 30), + face_colors: vec![(0, Rgb(0, 255, 0))], + opacity: 0.5, + closed: true, + hatch: Default::default(), + }; let sc = scene_geometry(&Scene { meshes: vec![mesh], ..Default::default() }); assert_eq!(sc.tris.len(), 0); assert_eq!(color(&sc, &sc.glass), Some([0, 255, 0, 128]), "the face's own colour at the body's alpha"); diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs index 7cec789f88..c518cc6dd5 100644 --- a/crates/gpu/src/view.rs +++ b/crates/gpu/src/view.rs @@ -41,7 +41,7 @@ pub struct PaletteIndex(pub u32); /// How many palette entries fit on one texel row: the shader reads `x = i & 2047`, /// `y = i >> 11`. A power of two keeps the lookup a shift and a mask. pub const PALETTE_WIDTH: u32 = 2048; -/// Most palette entries a scene may carry (2048 × 512): the palette is an `R32Uint` texture +/// Most palette entries a scene may carry (2048 × 512): the palette is an `Rg32Uint` texture /// (`downlevel_defaults`/WebGL2 have no storage buffers in the vertex stage). pub const MAX_PALETTE: usize = 1 << 20; @@ -50,17 +50,22 @@ pub const MAX_PALETTE: usize = 1 << 20; /// The first [`Palette::stable_len`] entries are stable for a scene: cached block bytes keep /// referring to them, so appearance edits only change their values. Entries after that are /// interned per build for ephemeral geometry (grids, highlights, sketch, previews). +/// +/// A stable entry also carries the section hatch of the body it colours +/// (`solidcraft_render::hatch`): none, the cap's own hatch, unless the scene hatches by part. #[derive(Clone, Debug, Default)] pub struct Palette { entries: Vec<[u8; 4]>, lookup: std::collections::HashMap<[u8; 4], u32>, stable_len: usize, + /// Per entry, 1 + its body's hatch, or 0 for the cap's own (empty: 0 for every entry). + hatches: Vec, } impl Palette { /// A palette whose entries are all stable (the model scene's body/face slots). pub fn with_stable(entries: Vec<[u8; 4]>) -> Palette { - Palette { stable_len: entries.len(), entries, lookup: std::collections::HashMap::new() } + Palette { stable_len: entries.len(), entries, lookup: std::collections::HashMap::new(), hatches: Vec::new() } } /// Where the stable entries end and the per-build interned ones begin. @@ -116,9 +121,35 @@ impl Palette { PaletteIndex(i) } - /// The palette as the shader's `R32Uint` texels (rgba bytes packed little-endian). - pub fn texels(&self) -> Vec { - self.entries.iter().map(|[r, g, b, a]| u32::from_le_bytes([*r, *g, *b, *a])).collect() + /// Give stable entry `i` (a body's colour) that body's section hatch; `false` when `i` is not + /// a stable entry. + pub fn set_hatch(&mut self, i: PaletteIndex, hatch: solidcraft_render::hatch::Hatch) -> bool { + let at = i.0 as usize; + if at >= self.stable_len { + return false; + } + if self.hatches.len() < self.stable_len { + self.hatches.resize(self.stable_len, 0); + } + match self.hatches.get_mut(at) { + Some(slot) => { + *slot = hatch.0.saturating_add(1); + true + } + None => false, + } + } + + /// The section hatch entry `i` was given ([`Palette::set_hatch`]), if any. + pub fn hatch(&self, i: PaletteIndex) -> Option { + self.hatches.get(i.0 as usize).and_then(|h| h.checked_sub(1)).map(solidcraft_render::hatch::Hatch) + } + + /// Entry `i` as the shader's `Rg32Uint` texel: the rgba bytes packed little-endian, then + /// 1 + its hatch (0: the cap's own). + fn texel(&self, i: usize) -> [u32; 2] { + let colour = self.entries.get(i).map_or(0, |[r, g, b, a]| u32::from_le_bytes([*r, *g, *b, *a])); + [colour, u32::from(self.hatches.get(i).copied().unwrap_or(0))] } } @@ -151,8 +182,8 @@ fn octahedral6(n: [f32; 3]) -> u32 { } /// The shader's `U`: the view-projection matrix, 10 `vec4`s (the tenth carries the ambient /// occlusion radius), the 8 analysis colours, their flags, the depth direction and the two -/// whole-body highlight colours (`uniform_bytes`). -const UNIFORM_SIZE: u64 = 416; +/// whole-body highlight colours, then the section hatch colours (`uniform_bytes`). +const UNIFORM_SIZE: u64 = 416 + 16 * solidcraft_render::hatch::HATCH_COLOURS as u64; /// A body drawn from its display plan, evaluated on the GPU: the plan, and a palette index per /// face (the plan's face numbering; faces past the end take the last index). @@ -914,6 +945,9 @@ pub struct Frame { pub hover_tint: [f32; 3], /// Whole-body selection highlight: a selected body's colour before lighting (sRGB 0..1). pub selection_tint: [f32; 3], + /// The section hatch colours by part (straight sRGB, 0..1; the first the cap's own): what a + /// cut body whose palette entries carry a hatch ([`Palette::set_hatch`]) is tinted toward. + pub hatch: [[f32; 3]; solidcraft_render::hatch::HATCH_COLOURS], } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -1393,16 +1427,19 @@ fn joined<'a>(blocks: impl Iterator>, own: Option, /// Held for the bind group, which views it. _texture: wgpu::Texture, bind: wgpu::BindGroup, rows: u32, - entries: Vec<[u8; 4]>, } +/// Bytes of one palette texel. +const PALETTE_TEXEL: u32 = 8; + fn palette_texture(device: &wgpu::Device, rows: u32) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { label: Some("sc_palette"), @@ -1410,28 +1447,28 @@ fn palette_texture(device: &wgpu::Device, rows: u32) -> wgpu::Texture { mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::R32Uint, + format: wgpu::TextureFormat::Rg32Uint, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }) } -/// Rows `first..last` of `entries` as the texture's texels (zero past the end). -fn palette_bytes(entries: &[[u8; 4]], first: u32, last: u32) -> Vec { +/// Rows `first..last` of `palette` as the texture's texels (zero past the end). +fn palette_bytes(palette: &Palette, first: u32, last: u32) -> Vec { let width = PALETTE_WIDTH as usize; - let mut out = Vec::with_capacity((last - first) as usize * width * 4); + let mut out = Vec::with_capacity((last - first) as usize * width * PALETTE_TEXEL as usize); for row in first..last { for col in 0..PALETTE_WIDTH { - let i = row as usize * width + col as usize; - let texel = entries.get(i).map_or(0, |[r, g, b, a]| u32::from_le_bytes([*r, *g, *b, *a])); - out.extend_from_slice(&texel.to_le_bytes()); + for word in palette.texel(row as usize * width + col as usize) { + out.extend_from_slice(&word.to_le_bytes()); + } } } out } -fn palette_rows(entries: &[[u8; 4]]) -> u32 { - u32::try_from(entries.len().div_ceil(PALETTE_WIDTH as usize)).unwrap_or(1).max(1) +fn palette_rows(palette: &Palette) -> u32 { + u32::try_from(palette.len().div_ceil(PALETTE_WIDTH as usize)).unwrap_or(1).max(1) } impl PaletteGpu { @@ -1443,15 +1480,14 @@ impl PaletteGpu { palette: Arc, written: &mut u64, ) -> PaletteGpu { - let entries = palette.entries().to_vec(); - let rows = palette_rows(&entries); + let rows = palette_rows(&palette); let texture = palette_texture(device, rows); - let bytes = palette_bytes(&entries, 0, rows); + let bytes = palette_bytes(&palette, 0, rows); *written += bytes.len() as u64; queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, &bytes, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(PALETTE_WIDTH * 4), rows_per_image: Some(rows) }, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(PALETTE_WIDTH * PALETTE_TEXEL), rows_per_image: Some(rows) }, wgpu::Extent3d { width: PALETTE_WIDTH, height: rows, depth_or_array_layers: 1 }, ); let view = texture.create_view(&Default::default()); @@ -1463,7 +1499,7 @@ impl PaletteGpu { wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&view) }, ], }); - PaletteGpu { palette, _texture: texture, bind, rows, entries } + PaletteGpu { palette, _texture: texture, bind, rows } } /// Bring the texture up to `palette`, writing only the rows whose entries changed (or the @@ -1480,8 +1516,7 @@ impl PaletteGpu { if Arc::ptr_eq(&self.palette, &palette) { return; } - let entries = palette.entries().to_vec(); - let rows = palette_rows(&entries); + let rows = palette_rows(&palette); if rows != self.rows { *self = PaletteGpu::new(device, queue, uniform, bgl, palette, written); return; @@ -1489,7 +1524,7 @@ impl PaletteGpu { let width = PALETTE_WIDTH as usize; let same = |row: u32| { let base = row as usize * width; - (0..width).all(|c| self.entries.get(base + c).copied().unwrap_or_default() == entries.get(base + c).copied().unwrap_or_default()) + (0..width).all(|c| self.palette.texel(base + c) == palette.texel(base + c)) }; let mut row = 0u32; while row < rows { @@ -1501,7 +1536,7 @@ impl PaletteGpu { while row < rows && !same(row) { row += 1; } - let bytes = palette_bytes(&entries, first, row); + let bytes = palette_bytes(&palette, first, row); *written += bytes.len() as u64; queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -1511,11 +1546,10 @@ impl PaletteGpu { aspect: wgpu::TextureAspect::All, }, &bytes, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(PALETTE_WIDTH * 4), rows_per_image: Some(row - first) }, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(PALETTE_WIDTH * PALETTE_TEXEL), rows_per_image: Some(row - first) }, wgpu::Extent3d { width: PALETTE_WIDTH, height: row - first, depth_or_array_layers: 1 }, ); } - self.entries = entries; self.palette = palette; } } @@ -2942,6 +2976,7 @@ fn frame_key(cb: &Frame) -> u64 { // picture cached under the previous theme's colours is not shown (#5802). cb.hover_tint.map(f32::to_bits).hash(&mut h); cb.selection_tint.map(f32::to_bits).hash(&mut h); + cb.hatch.iter().flatten().for_each(|v| v.to_bits().hash(&mut h)); cb.outline.hash(&mut h); cb.ao_radius.to_bits().hash(&mut h); cb.ssaa.to_bits().hash(&mut h); @@ -3656,6 +3691,9 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) - v.extend([if reversed { 1.0 } else { 0.0 }, FACE_STEPS as f32, FACE_SLOPE, 0.0]); v.extend([cb.hover_tint[0], cb.hover_tint[1], cb.hover_tint[2], 0.0]); v.extend([cb.selection_tint[0], cb.selection_tint[1], cb.selection_tint[2], 0.0]); + for [r, g, b] in cb.hatch { + v.extend([r, g, b, 1.0]); + } v.iter().flat_map(|x| x.to_le_bytes()).collect() } @@ -3820,6 +3858,7 @@ mod tests { access: None, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), outline: false, eye: [0.0; 4], ao_radius: 0.0, @@ -4106,6 +4145,7 @@ mod glass_tests { ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), }; let texture = |format, usage, sample_count| { device.create_texture(&wgpu::TextureDescriptor { @@ -4273,6 +4313,7 @@ mod glass_tests { access: None, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), outline: false, eye: [0.0, 0.0, 100.0, 0.0], ao_radius: 0.0, diff --git a/crates/gpu/src/view/msaa_check.rs b/crates/gpu/src/view/msaa_check.rs index 591c3581d8..29869b29a4 100644 --- a/crates/gpu/src/view/msaa_check.rs +++ b/crates/gpu/src/view/msaa_check.rs @@ -116,6 +116,7 @@ impl Renderer { ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), }; let size = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 }; let color = device.create_texture(&wgpu::TextureDescriptor { diff --git a/crates/gpu/tests/depth.rs b/crates/gpu/tests/depth.rs index a5be94a91e..1d83c4d3f5 100644 --- a/crates/gpu/tests/depth.rs +++ b/crates/gpu/tests/depth.rs @@ -502,3 +502,76 @@ fn a_cut_hides_the_edges_behind_it() { } } } + +/// The hatch a picture shows over the pixels `at`: the colour most of them have (the fill, +/// rounded to 8 levels a channel), and whether the darker lines through it run at 135 degrees +/// (down to the right on screen) rather than 45. +fn hatch_seen(img: &[u8], w: u32, at: &[(i64, i64)]) -> ([u8; 3], bool) { + let px = |x: i64, y: i64| { + let i = (y * i64::from(w) + x) as usize * 4; + img.get(i..i + 3).map(|p| [p[0], p[1], p[2]]) + }; + let mut counts = std::collections::HashMap::new(); + for (x, y) in at { + if let Some(p) = px(*x, *y) { + *counts.entry(p.map(|c| c / 8)).or_insert(0usize) += 1; + } + } + let fill = counts.into_iter().max_by_key(|(_, n)| *n).map_or([0; 3], |(c, _)| c); + let line = |x: i64, y: i64| { + px(x, y).is_some_and(|p| p.iter().map(|c| u32::from(*c)).sum::() * 8 < fill.iter().map(|c| u32::from(*c) * 8 + 4).sum::() * 7) + }; + // Along a 45 degree line (x + y the same) the pixels agree; across it they alternate. + let agree = |dy: i64| at.iter().filter(|(x, y)| line(*x, *y) && line(x + 1, y + dy)).count(); + (fill, agree(1) > agree(-1)) +} + +/// Section hatch by part: two blocks side by side, cut through both, touch along the cut, so they +/// get different hatches (in angle or colour) and the picture shows them apart; the next frame +/// and a plane moved a little keep each block's hatch. With one hatch for every body (the +/// preference's old behaviour) both caps are the cap colour at 45 degrees. +#[test] +fn touching_caps_are_hatched_apart_and_keep_their_hatch() { + let left = ("solid.box", json!({"length": 20, "width": 30, "height": 20, "body_name": "left"})); + let right = ("solid.box", json!({"length": 20, "width": 30, "height": 20, "corner": [20, 0, 0], "operation": "new", "body_name": "right"})); + let cut = |y: f64| ("inspect.section", json!({"plane": {"origin": [0, y, 0], "normal": [0, -1, 0]}})); + let s = session(&[left.clone(), right.clone(), cut(15.0)]); + let moved = session(&[left, right, cut(15.5)]); + let mut cam = view::home_camera(&s).unwrap(); + (cam.fov, cam.yaw, cam.pitch) = (0.0, -0.3, 0.2); + let hatches = |s: &Session| view::scene(s, &cam).unwrap().meshes.iter().map(|m| m.hatch).collect::>(); + let first = hatches(&s); + assert_eq!(first.len(), 2); + assert_ne!(first[0], first[1], "touching caps share a hatch"); + assert_eq!(hatches(&s), first, "the next frame keeps them"); + assert_eq!(hatches(&moved), first, "a plane moved a little keeps them"); + let (w, h) = (360, 280); + // Each block's cap on screen, 4 px in: x 0..20 and 20..40, z 0..20 at y = 15. + let radius = view::scene(&s, &cam).unwrap().radius; + let cap_pixels = |x0: f64| -> Vec<(i64, i64)> { + let corner = |x: f64, z: f64| cam.to_screen(Vec3::new(x, 15.0, z), f64::from(w), f64::from(h), radius).map(|(x, y, _)| (x, y)); + let q: Vec<(f64, f64)> = [(x0, 0.0), (x0 + 20.0, 0.0), (x0 + 20.0, 20.0), (x0, 20.0)].iter().filter_map(|(x, z)| corner(*x, *z)).collect(); + assert_eq!(q.len(), 4, "the cut is on screen"); + let turn = (0..4).map(|i| q[i].0 * q[(i + 1) % 4].1 - q[(i + 1) % 4].0 * q[i].1).sum::().signum(); + let inside = |x: f64, y: f64| { + (0..4).all(|i| { + let ((ax, ay), (bx, by)) = (q[i], q[(i + 1) % 4]); + ((bx - ax) * (y - ay) - (by - ay) * (x - ax)) / (bx - ax).hypot(by - ay) * turn >= 4.0 + }) + }; + (0..i64::from(h)).flat_map(|y| (0..i64::from(w)).map(move |x| (x, y))).filter(|(x, y)| inside(*x as f64 + 0.5, *y as f64 + 0.5)).collect() + }; + let (a, b) = (cap_pixels(0.0), cap_pixels(20.0)); + assert!(a.len() > 1500 && b.len() > 1500, "the caps ({} and {} px)", a.len(), b.len()); + for (mode, c) in capturers() { + let by_part = c.rgba(&Scene { grid: Vec::new(), ..view::scene(&s, &cam).unwrap() }, &cam, w, h).unwrap(); + let (la, lb) = (hatch_seen(&by_part, w, &a), hatch_seen(&by_part, w, &b)); + assert_ne!(la, lb, "{mode} depth: the touching caps look alike"); + let expect = |hatch: solidcraft_engine::render::hatch::Hatch| hatch.turned(); + assert_eq!((la.1, lb.1), (expect(first[0]), expect(first[1])), "{mode} depth: the caps' angles"); + let one = c.rgba(&Scene { grid: Vec::new(), part_hatch: None, ..view::scene(&s, &cam).unwrap() }, &cam, w, h).unwrap(); + let (oa, ob) = (hatch_seen(&one, w, &a), hatch_seen(&one, w, &b)); + assert_eq!(oa, ob, "{mode} depth: one hatch for every body"); + assert!(!oa.1, "{mode} depth: one hatch runs at 45 degrees"); + } +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs new file mode 100644 index 0000000000..60af8d4714 --- /dev/null +++ b/crates/render/src/hatch.rs @@ -0,0 +1,245 @@ +//! The section hatch by part, as engineering section drawings tell parts apart: each body a +//! section cuts is hatched at 45 or 135 degrees in one of a few colours of one family, and bodies +//! whose cut faces touch or overlap never share a hatch (a map colouring of the cut, the angle +//! changed first, then the colour). Otherwise a body keeps the hatch its name seeds, so it stays +//! the same from frame to frame and as the plane moves. Spacing and line width are the same for +//! every hatch; only the angle and the colour change. + +use std::collections::{HashMap, HashSet}; + +use solidcraft_geom::{Vec2, Vec3}; + +/// The colours a cut is hatched in: the section cap's own, then the others of its family. +pub const HATCH_COLOURS: usize = 3; + +/// The hatches a body may get: every colour at 45 and at 135 degrees. +pub const HATCHES: usize = 2 * HATCH_COLOURS; + +/// One of the [`HATCHES`]: colour `n / 2`, at 135 degrees when `n` is odd. Hatch 0 is the one a +/// section draws every body with when it does not hatch by part: the cap's colour at 45 degrees. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Hatch(pub u8); + +impl Hatch { + /// Which of the [`HATCH_COLOURS`] it is drawn in. + pub fn colour(self) -> usize { + usize::from(self.0 / 2) % HATCH_COLOURS + } + + /// Its lines run at 135 degrees on screen (else 45). + pub fn turned(self) -> bool { + self.0 % 2 == 1 + } + + fn of(colour: usize, turned: bool) -> Hatch { + Hatch(((colour % HATCH_COLOURS) * 2 + usize::from(turned)) as u8) + } +} + +/// A body's seed: its persistent name hashed (FNV-1a, the same on every platform and release). +pub fn seed(name: &str) -> u64 { + name.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + +/// A closed body the section cuts: its [`seed`] and where the plane cuts it, as segments in the +/// plane (one per crossed triangle, in world space). +pub struct CutPart<'a> { + pub seed: u64, + pub segments: &'a [(Vec3, Vec3)], +} + +/// Cut faces closer than this, as a share of the whole cut's size, touch: a pin in its bore, +/// tessellated each its own way, still reads as one contact. +const TOUCH: f64 = 1e-3; + +/// The hatch of each part (in order) cut by a plane with normal `normal`: the one its seed picks, +/// unless a part it touches or overlaps on the cut, taken first, has that one already; then the +/// same colour at the other angle, then the next colours. Parts are taken in the order of their +/// seeds, so the hatches depend only on which parts are cut and which of them touch. +pub fn hatches(parts: &[CutPart], normal: Vec3) -> Vec { + let neighbours = touching(parts, normal); + let mut order: Vec = (0..parts.len()).collect(); + order.sort_by_key(|&i| (parts.get(i).map_or(0, |p| p.seed), i)); + let mut out: Vec> = vec![None; parts.len()]; + for i in order { + let Some(part) = parts.get(i) else { continue }; + let taken: Vec = neighbours.get(&i).into_iter().flatten().filter_map(|j| out.get(*j).copied().flatten()).collect(); + let own = Hatch((mix(part.seed) % HATCHES as u64) as u8); + let candidates = (0..HATCH_COLOURS).flat_map(|k| { + let colour = own.colour() + k; + [Hatch::of(colour, own.turned()), Hatch::of(colour, !own.turned())] + }); + // The first free one; with every one taken, the one the fewest neighbours have. + let pick = candidates.clone().find(|h| !taken.contains(h)).or_else(|| candidates.min_by_key(|h| taken.iter().filter(|t| *t == h).count())); + if let Some(slot) = out.get_mut(i) { + *slot = pick; + } + } + out.into_iter().map(Option::unwrap_or_default).collect() +} + +/// The seed spread over all its bits (SplitMix64's finaliser), so names that differ in one +/// letter pick unrelated hatches. +fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Which parts touch or overlap on the cut: per part, the others. +fn touching(parts: &[CutPart], normal: Vec3) -> HashMap> { + let u = normal.any_perp(); + let v = normal.cross(u).normalized().unwrap_or(Vec3::Y); + let flat = |p: Vec3| Vec2::new(p.dot(u), p.dot(v)); + let cuts: Vec> = parts + .iter() + .map(|p| p.segments.iter().map(|(a, b)| (flat(*a), flat(*b))).filter(|(a, b)| a.is_finite() && b.is_finite()).collect()) + .collect(); + let boxes: Vec> = cuts.iter().map(|c| bounds(c)).collect(); + let Some((lo, hi)) = boxes.iter().flatten().copied().reduce(|(a, b), (c, d)| (min2(a, c), max2(b, d))) else { + return HashMap::new(); + }; + let extent = (hi - lo).len(); + if !(extent.is_finite() && extent > 0.0) { + return HashMap::new(); + } + let eps = extent * TOUCH; + let mut pairs: HashSet<(usize, usize)> = HashSet::new(); + // Outlines closer than `eps`: through a grid of cells at least `eps` wide, each holding the + // pieces of segments (no longer than a cell) whose box, grown by half of `eps`, reaches it. + let cell = eps.max(extent / 1024.0); + let mut grid: HashMap<(i64, i64), Vec<(usize, usize)>> = HashMap::new(); + let at = |x: f64, o: f64| ((x - o) / cell).floor() as i64; + for (pi, cut) in cuts.iter().enumerate() { + for (si, (a, b)) in cut.iter().enumerate() { + let pieces = ((*b - *a).len() / cell).ceil().clamp(1.0, 4096.0) as usize; + let mut last = None; + for k in 0..pieces { + let (p, q) = (a.lerp(*b, k as f64 / pieces as f64), a.lerp(*b, (k + 1) as f64 / pieces as f64)); + let (x0, x1) = (at(p.x.min(q.x) - eps / 2.0, lo.x), at(p.x.max(q.x) + eps / 2.0, lo.x)); + let (y0, y1) = (at(p.y.min(q.y) - eps / 2.0, lo.y), at(p.y.max(q.y) + eps / 2.0, lo.y)); + for x in x0..=x1 { + for y in y0..=y1 { + // Consecutive pieces share cells: the segment goes in each cell once. + if last.is_some_and(|(lx0, lx1, ly0, ly1)| (lx0..=lx1).contains(&x) && (ly0..=ly1).contains(&y)) { + continue; + } + grid.entry((x, y)).or_default().push((pi, si)); + } + } + last = Some((x0, x1, y0, y1)); + } + } + } + for list in grid.values() { + for (k, &(pa, sa)) in list.iter().enumerate() { + for &(pb, sb) in list.iter().skip(k + 1) { + let pair = (pa.min(pb), pa.max(pb)); + if pa == pb || pairs.contains(&pair) { + continue; + } + let seg = |p: usize, s: usize| cuts.get(p).and_then(|c| c.get(s)).copied(); + if let (Some(x), Some(y)) = (seg(pa, sa), seg(pb, sb)) + && segment_distance(x, y) <= eps + { + pairs.insert(pair); + } + } + } + } + // Overlap without outlines that meet: one part's cut lies inside the other's material. + let mut rows: HashMap = HashMap::new(); + for a in 0..cuts.len() { + for b in a + 1..cuts.len() { + if pairs.contains(&(a, b)) { + continue; + } + let (Some(Some(ba)), Some(Some(bb))) = (boxes.get(a), boxes.get(b)) else { continue }; + if ba.1.x < bb.0.x || bb.1.x < ba.0.x || ba.1.y < bb.0.y || bb.1.y < ba.0.y { + continue; + } + let inside = |outer: usize, inner: usize, rows: &mut HashMap| { + let (Some(o), Some(Some(first))) = (cuts.get(outer), cuts.get(inner).map(|c| c.first())) else { return false }; + let r = rows.entry(outer).or_insert_with(|| Rows::new(o)); + r.contains(o, first.0.lerp(first.1, 0.5)) + }; + if inside(a, b, &mut rows) || inside(b, a, &mut rows) { + pairs.insert((a, b)); + } + } + } + let mut out: HashMap> = HashMap::new(); + for (a, b) in pairs { + out.entry(a).or_default().push(b); + out.entry(b).or_default().push(a); + } + out +} + +/// A part's cut segments sorted into rows across the plane's second axis, so a point-in-material +/// test looks at the segments of one row. +struct Rows { + lo: f64, + step: f64, + rows: Vec>, +} + +/// Rows a [`Rows`] makes, at most. +const ROWS: usize = 256; + +impl Rows { + fn new(cut: &[(Vec2, Vec2)]) -> Rows { + let (lo, hi) = cut.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(l, h), (a, b)| (l.min(a.y).min(b.y), h.max(a.y).max(b.y))); + let count = (cut.len() / 16).clamp(1, ROWS); + let step = if hi > lo { (hi - lo) / count as f64 } else { 1.0 }; + let mut rows = vec![Vec::new(); count]; + let row = |y: f64| (((y - lo) / step).floor().max(0.0) as usize).min(count - 1); + for (i, (a, b)) in cut.iter().enumerate() { + for r in rows.iter_mut().take(row(a.y.max(b.y)) + 1).skip(row(a.y.min(b.y))) { + r.push(i); + } + } + Rows { lo, step, rows } + } + + /// Whether `p` lies inside the material `cut` bounds: a ray along the first axis crosses its + /// outline an odd number of times. + fn contains(&self, cut: &[(Vec2, Vec2)], p: Vec2) -> bool { + let r = ((p.y - self.lo) / self.step).floor(); + let Some(row) = (r >= 0.0).then_some(r as usize).and_then(|r| self.rows.get(r)) else { return false }; + let crossings = row + .iter() + .filter_map(|i| cut.get(*i)) + .filter(|(a, b)| (a.y > p.y) != (b.y > p.y) && a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) > p.x) + .count(); + crossings % 2 == 1 + } +} + +fn min2(a: Vec2, b: Vec2) -> Vec2 { + Vec2::new(a.x.min(b.x), a.y.min(b.y)) +} + +fn max2(a: Vec2, b: Vec2) -> Vec2 { + Vec2::new(a.x.max(b.x), a.y.max(b.y)) +} + +fn bounds(cut: &[(Vec2, Vec2)]) -> Option<(Vec2, Vec2)> { + cut.iter().map(|(a, b)| (min2(*a, *b), max2(*a, *b))).reduce(|(a, b), (c, d)| (min2(a, c), max2(b, d))) +} + +/// The distance between two segments: zero where they cross, else the nearest end to the other. +fn segment_distance((a, b): (Vec2, Vec2), (c, d): (Vec2, Vec2)) -> f64 { + let side = |p: Vec2, q: Vec2, r: Vec2| (q - p).cross(r - p); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) { + return 0.0; + } + let to = |p: Vec2, s: Vec2, e: Vec2| { + let se = e - s; + let l2 = se.dot(se); + let t = if l2 > 0.0 { ((p - s).dot(se) / l2).clamp(0.0, 1.0) } else { 0.0 }; + (p - (s + se * t)).len() + }; + to(a, c, d).min(to(b, c, d)).min(to(c, a, b)).min(to(d, a, b)) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 420268e810..4d7e850481 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ pub mod clear_coat; mod curve_display; pub mod film; mod gif; +pub mod hatch; pub mod job; pub mod lighting; pub mod mapping; @@ -41,4 +42,4 @@ pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQua pub use gif::gif; pub use product::{FaceAppearance, ProductMesh, ProductScene}; pub use raster::{Canvas, render, render_png, render_transparent}; -pub use scene::{MIN_ALPHA, Rgb, SECTION_CAP, SEE_THROUGH, Scene, SceneLine, SceneMesh, png}; +pub use scene::{MIN_ALPHA, Rgb, SECTION_CAP, SECTION_HATCH, SEE_THROUGH, Scene, SceneLine, SceneMesh, png}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 7cd128b711..ab65ffc943 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -118,6 +118,11 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { let alpha = sm.alpha(); let m = &sm.mesh; let body = sm.color.f(); + // The hatch of its cut face: its own by part, else the cap's at 45 degrees. + let (tint, turned) = match scene.part_hatch { + Some(colours) => (colours.get(sm.hatch.colour()).map_or(cap_tint, |c| c.f()), sm.hatch.turned()), + None => (cap_tint, false), + }; let pts: Vec> = m.positions.iter().map(|p| project(*p)).collect(); // 1 / clip w per vertex, for perspective-correct interpolation of world positions (so // the section cuts along the plane, not a curve, in perspective views). @@ -194,15 +199,16 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { }; } if cut { - // The cut: the body's colour tinted toward the cap colour, hatched - // at 45 degrees (as the viewport draws it); a plain cap is the - // body's own colour. + // The cut: the body's colour tinted toward its hatch colour, hatched + // at 45 or 135 degrees (as the viewport draws it); a plain cap is + // the body's own colour. if scene.plain_cap { c.put(x, y, base, 1.0); continue; } - let tinted: [f64; 3] = std::array::from_fn(|k| base[k] + (cap_tint[k] - base[k]) * 0.35); - let k = if ((x + y) / SS) % 9 < 2 { 0.6 } else { 1.0 }; + let tinted: [f64; 3] = std::array::from_fn(|k| base[k] + (tint[k] - base[k]) * 0.35); + let along = if turned { x as i64 - y as i64 } else { (x + y) as i64 }; + let k = if along.div_euclid(SS as i64).rem_euclid(9) < 2 { 0.6 } else { 1.0 }; c.put(x, y, tinted.map(|v| v * k), 1.0); continue; } @@ -424,7 +430,14 @@ mod tests { /// A front panel over a back one, with a line between them, seen from the front. fn two_panels(front_opacity: f32) -> (Scene, Camera) { - let glass = SceneMesh { mesh: Arc::new(panel(-5.0)), color: Rgb(255, 0, 0), face_colors: Vec::new(), opacity: front_opacity, closed: true }; + let glass = SceneMesh { + mesh: Arc::new(panel(-5.0)), + color: Rgb(255, 0, 0), + face_colors: Vec::new(), + opacity: front_opacity, + closed: true, + hatch: Default::default(), + }; let scene = Scene { meshes: vec![SceneMesh::solid(Arc::new(panel(5.0)), Rgb(0, 255, 0)), glass], lines: vec![SceneLine { diff --git a/crates/render/src/scene.rs b/crates/render/src/scene.rs index ae90fe2b12..a87d233019 100644 --- a/crates/render/src/scene.rs +++ b/crates/render/src/scene.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use solidcraft_geom::{Mesh, Vec3}; use crate::SceneAnalysis; +use crate::hatch::{HATCH_COLOURS, Hatch}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rgb(pub u8, pub u8, pub u8); @@ -30,12 +31,14 @@ pub struct SceneMesh { /// The body encloses a volume (a solid, a closed mesh); an open one (a surface body, a solid /// with faces left out) has no inside, so a section gives it no cap. pub closed: bool, + /// The hatch of its cut face when the scene hatches by part ([`Scene::part_hatch`]). + pub hatch: Hatch, } impl SceneMesh { /// A solid body in `color`, with no face of its own look. pub fn solid(mesh: Arc, color: Rgb) -> SceneMesh { - SceneMesh { mesh, color, face_colors: Vec::new(), opacity: 1.0, closed: true } + SceneMesh { mesh, color, face_colors: Vec::new(), opacity: 1.0, closed: true, hatch: Hatch(0) } } /// Is the body drawn see-through? (Its opacity is below [`SEE_THROUGH`].) @@ -87,6 +90,9 @@ pub struct Scene { pub cap: Option, /// The section cap without its hatch: plain, in the body's own colour. pub plain_cap: bool, + /// Each cut body hatched its own way ([`SceneMesh::hatch`]), in these colours (the first + /// the cap's own); `None`: every cut face in [`Scene::cap`] at 45 degrees. + pub part_hatch: Option<[Rgb; HATCH_COLOURS]>, /// A surface analysis shading the meshes (zebra, draft, curvature map, ...). pub analysis: Option, } @@ -94,6 +100,10 @@ pub struct Scene { /// The section cap's default tint, as in the viewport. pub const SECTION_CAP: Rgb = Rgb(214, 128, 72); +/// The section hatch's other colours by default, as in the viewport: a steel and a sage that tint +/// a cut face about as strongly as the cap does, so the cut stays calm. +pub const SECTION_HATCH: [Rgb; HATCH_COLOURS - 1] = [Rgb(84, 138, 168), Rgb(108, 180, 126)]; + /// RGBA pixels (`w × h`) to PNG bytes. pub fn png(w: usize, h: usize, rgba: Vec) -> Option> { let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?; diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index b1559249a8..a0f934f168 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -306,7 +306,11 @@ fn groups(t: &Tokens) -> Vec { ], &[], ), - group("section", &[("section_cap", t.section_cap)], &[("body", t.body)]), + group( + "section", + &[("section_cap", t.section_cap), ("section_hatch[0]", t.section_hatch[0]), ("section_hatch[1]", t.section_hatch[1])], + &[("body", t.body)], + ), group("interference", &[("interference", t.interference)], &[("body", t.body)]), group( "continuity seams", diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f78be64753..b6377455b9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -389,6 +389,9 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides ) .map_err(|e| e.to_string())?; scene.plain_cap = !crate::viewport::section_hatch(app); + if app.preferences.section_hatch == crate::prefs::SectionHatch::One { + scene.part_hatch = None; + } Ok(scene) } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 2535f3ddaa..6b4eb52497 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -365,6 +365,7 @@ fn offscreen( ssaa, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), }); let texture = |format, usage| { device.create_texture(&wgpu::TextureDescriptor { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 44a1ddec2f..2ab6b458eb 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -332,6 +332,19 @@ pub enum HistorySketches { UnderFeature, } +/// How a section hatches the faces it cuts (Preferences: "Section hatch"). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SectionHatch { + /// Each body in its own hatch (`solidcraft_render::hatch`): 45 or 135 degrees in one of the + /// theme's hatch colours, touching bodies never alike. Also what a value this version doesn't + /// know reads as (`lenient`). + #[default] + ByPart, + /// Every cut face in the cap colour at 45 degrees (how it was before by part). + One, +} + /// What an orbit turns around (Preferences: "Orbit around"). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -586,6 +599,9 @@ preference_settings! { ambient_occlusion: bool = false, PAGE_GRAPHICS; /// Optional schematic markings on threaded cylindrical walls; off for new and older preferences. cosmetic_threads: bool = false, PAGE_GRAPHICS; + /// A section hatches each cut body its own way (the default), or every one alike, in the cap + /// colour at 45 degrees, as before. + section_hatch: SectionHatch = SectionHatch::ByPart, PAGE_GRAPHICS; /// The grid turns to the upright origin plane facing a side view (#1470). Off by default: the /// grid stays on the ground plane (XY) whatever the view. grid_follows_view: bool = false => check("Grid follows the view", "Side views show the grid on the upright plane facing them instead of the ground"); diff --git a/crates/ui-egui/src/prefs/graphics.rs b/crates/ui-egui/src/prefs/graphics.rs index ecdefff899..58e3f3db1a 100644 --- a/crates/ui-egui/src/prefs/graphics.rs +++ b/crates/ui-egui/src/prefs/graphics.rs @@ -154,6 +154,15 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let response = ui_kit::checkbox(ui, app.tr("ui.cosmetic_thread_markings"), &mut app.preferences.cosmetic_threads) .on_hover_text(app.tr("ui.cosmetic_thread_markings_help")); crate::scenario::publish_handle("prefs:cosmetic_threads", response.rect.center()); + ui_kit::row(ui, app.tr("prefs.section_hatch"), |ui| { + let styles = [crate::prefs::SectionHatch::One, crate::prefs::SectionHatch::ByPart]; + let mut selected = styles.iter().position(|s| *s == app.preferences.section_hatch).unwrap_or(1); + if ui_kit::segmented(ui, &mut selected, &[app.tr("prefs.section_hatch_one"), app.tr("prefs.section_hatch_by_part")], Default::default()) + && let Some(style) = styles.get(selected) + { + app.preferences.section_hatch = *style; + } + }); ui.columns(2, |columns| { if let Some(left) = columns.get_mut(0) { ui_kit::checkbox(left, crate::i18n::tr_label("Grid"), &mut app.ui.show_grid); diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 04bf2ee603..1f2d21a77b 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -1637,6 +1637,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 }, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), }); ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback)); Ok(()) diff --git a/crates/ui-egui/src/scale_tests.rs b/crates/ui-egui/src/scale_tests.rs index 96c5a45cf3..5f89659102 100644 --- a/crates/ui-egui/src/scale_tests.rs +++ b/crates/ui-egui/src/scale_tests.rs @@ -290,6 +290,7 @@ pub(crate) fn view_frame( ssaa: 1.0, hover_tint: [f32::from(t.hover_face_lift) / 255.0; 3], selection_tint: [0.4, 0.6, 0.9], + hatch: Default::default(), } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c1c8591976..6977d5d3f1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -758,6 +758,9 @@ pub struct Tokens { pub chip: Color32, /// Inside of bodies cut by a section. pub section_cap: Color32, + /// The section hatch's other colours by part, after `section_cap` (Preferences > Graphics > + /// Section hatch): of its family and lightness, so a cut stays calm. + pub section_hatch: [Color32; 2], /// Closed sketch profiles (translucent fill). pub profile_fill: Color32, /// Constraint glyph boxes. @@ -902,6 +905,7 @@ impl Tokens { sketch_point: view.sketch_point, sketch_point_free: view.sketch_point_free, section_cap: view.section_cap, + section_hatch: view.section_hatch, profile_fill: view.profile_fill, glyph_bg: view.glyph_bg, glyph_edge: view.glyph_edge, diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index 4d4b7487da..01dc51eba1 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -315,7 +315,7 @@ theme_keys! { app_bar_hover_button, app_bar_hover_strong, app_bar_tab_active, form_ink, form_dim, form_primary, form_error, form_error_fill, form_success, form_success_fill, dialog_body, dialog_footer, dialog_rule, control_fill, control_border, dialog_label, selected_ink, selected_bg, ], - many: [continuity, logo], + many: [continuity, logo, section_hatch], icons: [highlight, agent, marker_halo], } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 752a2c691e..b796e70118 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -451,6 +451,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.preview.effective_replaced().hash(&mut h); app.preview.rolled_back.hash(&mut h); app.preferences.cosmetic_threads.hash(&mut h); + app.preferences.section_hatch.hash(&mut h); crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); // Two themes may share a light/dark base while drawing different geometry colours. @@ -1422,6 +1423,9 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { let mut kept = app.viewport.section_index.lock().map(|mut k| std::mem::take(&mut *k)).unwrap_or_default(); let mut made = std::collections::HashMap::with_capacity(kept.len()); let normal = [n.x.to_bits(), n.y.to_bits(), n.z.to_bits()]; + // The closed solid bodies the plane cuts and where it cuts them: their hatches by part. + let by_part = app.preferences.section_hatch == crate::prefs::SectionHatch::ByPart; + let mut cuts: Vec<(&str, Vec<(Vec3, Vec3)>)> = Vec::new(); for b in st.bodies.iter().filter(|b| app.session.body_visible(&b.name)) { let mesh = b.mesh(); let key = (std::sync::Arc::as_ptr(&mesh) as usize, normal); @@ -1429,10 +1433,28 @@ pub(crate) fn build_scene(app: &SolveApp) -> GpuScene { Some((_, index)) => index, None => std::sync::Arc::new(solidcraft_engine::view::SectionIndex::new(&mesh, n)), }; - for (a, c) in index.segments(&mesh, o) { + let segments = index.segments(&mesh, o); + for (a, c) in &segments { sc.line(a.to_f32(), c.to_f32(), col, 1.8, false); } made.insert(key, (mesh, index)); + let solid = b.is_closed() && !body_faded(app, b, &active_bodies) && s.doc.model_body_opacity(b) >= 0.999; + if by_part && solid && !segments.is_empty() { + cuts.push((b.name.as_str(), segments)); + } + } + // Each cut body's hatch on all its palette entries (its colour, faces and placements). + let parts: Vec = cuts + .iter() + .map(|(name, segments)| solidcraft_engine::render::hatch::CutPart { seed: solidcraft_engine::render::hatch::seed(name), segments }) + .collect(); + let hatches = solidcraft_engine::render::hatch::hatches(&parts, n); + let palette = std::sync::Arc::make_mut(&mut sc.palette); + for ((name, _), hatch) in cuts.iter().zip(hatches) { + let Some(slots) = arena.slots(name) else { continue }; + for slot in (0..slots.face_capacity as usize).map(|f| slots.face(f)).chain([slots.body, slots.instance]) { + palette.set_hatch(slot, hatch); + } } if let Ok(mut k) = app.viewport.section_index.lock() { *k = made; @@ -3935,6 +3957,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let c = t.body_selected; [f32::from(c.r()) / 255.0, f32::from(c.g()) / 255.0, f32::from(c.b()) / 255.0] }, + // The section hatch colours by part: the cap's, then the theme's others. + hatch: std::array::from_fn(|k| { + let c = if k == 0 { t.section_cap } else { t.section_hatch.get(k - 1).copied().unwrap_or(t.section_cap) }; + let [r, g, b, _] = c.to_normalized_gamma_f32(); + [r, g, b] + }), }); painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } @@ -5350,6 +5378,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli sketch_centerline: rgb(t.sketch_centerline), axes: t.axes().map(rgb), cap: rgb(t.section_cap), + hatch: t.section_hatch.map(rgb), isocurve: rgb(t.accent), analysis: t.analysis, } @@ -7161,6 +7190,7 @@ mod tests { ssaa: 1.0, hover_tint: [0.0; 3], selection_tint: [0.0; 3], + hatch: Default::default(), }; let t0 = std::time::Instant::now(); let mut encoder = device.create_command_encoder(&Default::default());