diff --git a/crates/bone-render/src/lib.rs b/crates/bone-render/src/lib.rs index c85e980..87f7291 100644 --- a/crates/bone-render/src/lib.rs +++ b/crates/bone-render/src/lib.rs @@ -20,9 +20,11 @@ pub use pick::{ EntityKindTag, PickAperture, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker, }; pub use pipelines::{ - ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, Edge3dPipeline, - EdgeProjection, EdgeView, FaceFill, GlyphPipeline, GridPipeline, LinesPipeline, - SdfGlyphInstance, SolidPipeline, SolidView, TextPipeline, + ArcPipeline, ChromeInstance, ChromePipeline, ChromeTextPipeline, GlyphPipeline, GridPipeline, + LinesPipeline, SdfGlyphInstance, TextPipeline, +}; +pub(crate) use pipelines::{ + Edge3dPipeline, EdgeProjection, EdgeView, FaceFill, HiddenEdges, SolidPipeline, SolidView, }; pub use preview::{PreviewArc, PreviewCircle, SketchPreview}; pub use scene::{ @@ -30,8 +32,8 @@ pub use scene::{ ScenePoint, SceneRelationGlyph, SilhouetteCandidate, SketchScene, SolidScene, }; pub use snapshot::{ - ClearColor, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, decode_png, - encode_png, + ClearColor, EdgeStyle, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, + decode_png, encode_png, }; pub use surface::{SurfaceContext, SurfaceError}; @@ -217,25 +219,26 @@ impl SolidRenderer { camera: bone_types::Camera3, style: &Style, ) -> Result { - self.render_with_edges( + self.render_display( ctx, scene, &EdgeScene::empty(), camera, style, - FaceFill::Shaded, + bone_types::DisplayMode::ShadedNoEdges, ) } - pub fn render_with_edges( + pub fn render_display( &mut self, ctx: &OffscreenContext, scene: &SolidScene, edges: &EdgeScene, camera: bone_types::Camera3, style: &Style, - fill: FaceFill, + mode: bone_types::DisplayMode, ) -> Result { + let plan = DisplayPlan::for_mode(mode); let extent = ctx.extent(); let clip_from_world = camera3::clip_from_world(camera, extent)?; let (ex, ey, ez) = camera.eye().coords_mm(); @@ -254,12 +257,7 @@ impl SolidRenderer { .create_view(&wgpu::TextureViewDescriptor::default()); let solid = &self.solid; let edge_pipeline = &self.edges; - let solid_view = SolidView { - clip_from_world, - eye_world, - shading: self.shading, - fill, - }; + let shading = self.shading; let edge_view = EdgeView { clip_from_world, projection: EdgeProjection::from_camera(camera), @@ -268,21 +266,133 @@ impl SolidRenderer { lower_f32(f64::from(extent.height().value())), ], crease_threshold_rad: CREASE_THRESHOLD_RAD, - dash_period_px: EDGE_DASH_DISABLED_PX, - dash_on_ratio: 0.0, + dash_period_px: HIDDEN_DASH_PERIOD_PX, + dash_on_ratio: HIDDEN_DASH_ON_RATIO, + edge_color: style.edges().visible().to_rgba_array(), + hidden_color: style.edges().hidden().to_rgba_array(), }; ctx.render(|encoder, color_view, pick_view| { let targets = RenderTargets::new(color_view, pick_view); - solid.draw(encoder, targets, &depth_view, scene, solid_view, style); - if !edges.is_empty() { - edge_pipeline.draw(encoder, targets, &depth_view, edges, edge_view); + match plan.solid { + Some(fill) => solid.draw( + encoder, + targets, + &depth_view, + scene, + SolidView { + clip_from_world, + eye_world, + shading, + fill, + }, + style, + ), + None => clear_solid_targets(encoder, targets, &depth_view, style), + } + if plan.edges && !edges.is_empty() { + edge_pipeline.draw(encoder, targets, &depth_view, edges, edge_view, plan.hidden); } }) } } +struct DisplayPlan { + solid: Option, + edges: bool, + hidden: HiddenEdges, +} + +impl DisplayPlan { + const fn for_mode(mode: bone_types::DisplayMode) -> Self { + use bone_types::DisplayMode; + match mode { + DisplayMode::Wireframe => Self { + solid: None, + edges: true, + hidden: HiddenEdges::Omit, + }, + DisplayMode::HiddenLineRemoved => Self { + solid: Some(FaceFill::Occluder), + edges: true, + hidden: HiddenEdges::Omit, + }, + DisplayMode::HiddenLineGray => Self { + solid: Some(FaceFill::Occluder), + edges: true, + hidden: HiddenEdges::Dashed, + }, + DisplayMode::ShadedWithEdges => Self { + solid: Some(FaceFill::Shaded), + edges: true, + hidden: HiddenEdges::Omit, + }, + DisplayMode::ShadedNoEdges => Self { + solid: Some(FaceFill::Shaded), + edges: false, + hidden: HiddenEdges::Omit, + }, + } + } +} + +pub(crate) fn solid_clear_color_attachments<'a>( + targets: RenderTargets<'a>, + style: &Style, +) -> [Option>; 2] { + [ + Some(wgpu::RenderPassColorAttachment { + view: targets.color, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(style.background().into()), + store: wgpu::StoreOp::Store, + }, + }), + Some(wgpu::RenderPassColorAttachment { + view: targets.pick, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(gpu::pick_clear_color()), + store: wgpu::StoreOp::Store, + }, + }), + ] +} + +pub(crate) fn solid_clear_depth_attachment( + depth_view: &wgpu::TextureView, +) -> wgpu::RenderPassDepthStencilAttachment<'_> { + wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + } +} + +fn clear_solid_targets( + encoder: &mut wgpu::CommandEncoder, + targets: RenderTargets<'_>, + depth_view: &wgpu::TextureView, + style: &Style, +) { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("bone-render:solid-clear"), + color_attachments: &solid_clear_color_attachments(targets, style), + depth_stencil_attachment: Some(solid_clear_depth_attachment(depth_view)), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); +} + const CREASE_THRESHOLD_RAD: f64 = core::f64::consts::FRAC_PI_6; -const EDGE_DASH_DISABLED_PX: f32 = 0.0; +const HIDDEN_DASH_PERIOD_PX: f32 = 6.0; +const HIDDEN_DASH_ON_RATIO: f32 = 0.5; fn depth_texture(device: &wgpu::Device, extent: ViewportExtent) -> wgpu::Texture { device.create_texture(&wgpu::TextureDescriptor { diff --git a/crates/bone-render/src/pipelines/edge_3d.rs b/crates/bone-render/src/pipelines/edge_3d.rs index d984892..94a6c43 100644 --- a/crates/bone-render/src/pipelines/edge_3d.rs +++ b/crates/bone-render/src/pipelines/edge_3d.rs @@ -9,13 +9,14 @@ use crate::pick::PickId; use crate::pipelines::solid::DEPTH_FORMAT; use crate::scene::{EdgeScene, GenuineEdge, SilhouetteCandidate}; -const EDGE_COLOR: [f32; 4] = [0.90, 0.90, 0.93, 1.0]; -const HIDDEN_COLOR: [f32; 4] = [0.45, 0.45, 0.50, 1.0]; const DEPTH_BIAS_NDC_PER_PX: f32 = 0.001_5; const GENUINE_OFFSET_PX: f32 = 2.0; const SILHOUETTE_OFFSET_PX: f32 = 3.0; const EDGE_HALF_WIDTH_PX: f32 = 0.75; +const STYLE_DASHED: u32 = 1 << 0; +const STYLE_HIDDEN: u32 = 1 << 1; + #[derive(Copy, Clone)] enum EdgeKind { Genuine, @@ -33,6 +34,34 @@ impl EdgeKind { } } +#[derive(Copy, Clone)] +enum EdgeLayer { + Visible, + Hidden, +} + +impl EdgeLayer { + const fn extra_style_bits(self) -> u32 { + match self { + Self::Visible => 0, + Self::Hidden => STYLE_DASHED | STYLE_HIDDEN, + } + } + + const fn depth_compare(self) -> wgpu::CompareFunction { + match self { + Self::Visible => wgpu::CompareFunction::LessEqual, + Self::Hidden => wgpu::CompareFunction::Greater, + } + } +} + +#[derive(Copy, Clone)] +pub(crate) enum HiddenEdges { + Omit, + Dashed, +} + #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct EdgeInstance { @@ -71,7 +100,7 @@ const INSTANCE_ATTRS: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![ ]; #[derive(Copy, Clone)] -pub enum EdgeProjection { +pub(crate) enum EdgeProjection { Orthographic { toward_viewer: Vec3 }, Perspective { eye: Point3 }, } @@ -96,19 +125,22 @@ impl EdgeProjection { } #[derive(Copy, Clone)] -pub struct EdgeView { +pub(crate) struct EdgeView { pub clip_from_world: [f32; 16], pub projection: EdgeProjection, pub viewport_px: [f32; 2], pub crease_threshold_rad: f64, pub dash_period_px: f32, pub dash_on_ratio: f32, + pub edge_color: [f32; 4], + pub hidden_color: [f32; 4], } -pub struct Edge3dPipeline { +pub(crate) struct Edge3dPipeline { device: wgpu::Device, queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, + visible: wgpu::RenderPipeline, + hidden: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, } @@ -140,56 +172,20 @@ impl Edge3dPipeline { bind_group_layouts: &[Some(&bind_group_layout)], immediate_size: 0, }); - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("bone-render:edge3d-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs"), - compilation_options: wgpu::PipelineCompilationOptions::default(), - buffers: &[wgpu::VertexBufferLayout { - array_stride: INSTANCE_STRIDE, - step_mode: wgpu::VertexStepMode::Instance, - attributes: &INSTANCE_ATTRS, - }], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fs"), - compilation_options: wgpu::PipelineCompilationOptions::default(), - targets: &[ - Some(wgpu::ColorTargetState { - format: color_format, - blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - }), - Some(wgpu::ColorTargetState { - format: PICK_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - }), - ], - }), - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - strip_index_format: None, - front_face: wgpu::FrontFace::Ccw, - cull_mode: None, - polygon_mode: wgpu::PolygonMode::Fill, - conservative: false, - unclipped_depth: false, - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: DEPTH_FORMAT, - depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::LessEqual), - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState::default(), - }), - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); + let visible = build_pipeline( + &device, + &pipeline_layout, + &shader, + color_format, + EdgeLayer::Visible.depth_compare(), + ); + let hidden = build_pipeline( + &device, + &pipeline_layout, + &shader, + color_format, + EdgeLayer::Hidden.depth_compare(), + ); let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("bone-render:edge3d-uniform"), size: UNIFORM_SIZE, @@ -207,7 +203,8 @@ impl Edge3dPipeline { Self { device, queue, - pipeline, + visible, + hidden, uniform_buffer, bind_group, } @@ -220,15 +217,22 @@ impl Edge3dPipeline { depth_view: &wgpu::TextureView, scene: &EdgeScene, view: EdgeView, + hidden: HiddenEdges, ) { - let instances = build_instances(scene, view); - if instances.is_empty() { + let visible = build_instances(scene, view, EdgeLayer::Visible.extra_style_bits()); + let occluded = match hidden { + HiddenEdges::Dashed => { + build_instances(scene, view, EdgeLayer::Hidden.extra_style_bits()) + } + HiddenEdges::Omit => Vec::new(), + }; + if visible.is_empty() && occluded.is_empty() { return; } let uniform = EdgeFrame { clip_from_world: view.clip_from_world, - edge_color: EDGE_COLOR, - hidden_color: HIDDEN_COLOR, + edge_color: view.edge_color, + hidden_color: view.hidden_color, viewport_px: view.viewport_px, dash_period_px: view.dash_period_px, dash_on_ratio: view.dash_on_ratio, @@ -237,13 +241,8 @@ impl Edge3dPipeline { }; self.queue .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); - let instance_buffer = self - .device - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("bone-render:edge3d-instances"), - contents: bytemuck::cast_slice(&instances), - usage: wgpu::BufferUsages::VERTEX, - }); + let visible_buffer = (!visible.is_empty()).then(|| self.instance_buffer(&visible)); + let occluded_buffer = (!occluded.is_empty()).then(|| self.instance_buffer(&occluded)); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bone-render:edge3d-pass"), color_attachments: &[ @@ -278,54 +277,143 @@ impl Edge3dPipeline { occlusion_query_set: None, multiview_mask: None, }); - let len = instances.len(); - let Ok(count) = u32::try_from(len) else { - panic!("edge instance count {len} exceeds u32::MAX"); - }; - pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.bind_group, &[]); - pass.set_vertex_buffer(0, instance_buffer.slice(..)); - pass.draw(0..6, 0..count); + if let Some(buffer) = &visible_buffer { + record_layer(&mut pass, &self.visible, buffer, visible.len()); + } + if let Some(buffer) = &occluded_buffer { + record_layer(&mut pass, &self.hidden, buffer, occluded.len()); + } + } + + fn instance_buffer(&self, instances: &[EdgeInstance]) -> wgpu::Buffer { + self.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bone-render:edge3d-instances"), + contents: bytemuck::cast_slice(instances), + usage: wgpu::BufferUsages::VERTEX, + }) } } +fn record_layer( + pass: &mut wgpu::RenderPass<'_>, + pipeline: &wgpu::RenderPipeline, + buffer: &wgpu::Buffer, + len: usize, +) { + let Ok(count) = u32::try_from(len) else { + panic!("edge instance count {len} exceeds u32::MAX"); + }; + pass.set_pipeline(pipeline); + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..6, 0..count); +} + impl core::fmt::Debug for Edge3dPipeline { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("Edge3dPipeline").finish_non_exhaustive() } } -fn build_instances(scene: &EdgeScene, view: EdgeView) -> Vec { +fn build_pipeline( + device: &wgpu::Device, + layout: &wgpu::PipelineLayout, + shader: &wgpu::ShaderModule, + color_format: wgpu::TextureFormat, + depth_compare: wgpu::CompareFunction, +) -> wgpu::RenderPipeline { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("bone-render:edge3d-pipeline"), + layout: Some(layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[wgpu::VertexBufferLayout { + array_stride: INSTANCE_STRIDE, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &INSTANCE_ATTRS, + }], + }, + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs"), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[ + Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: PICK_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + ], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + conservative: false, + unclipped_depth: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(false), + depth_compare: Some(depth_compare), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) +} + +fn build_instances(scene: &EdgeScene, view: EdgeView, extra_bits: u32) -> Vec { let genuine = scene .genuine() .iter() .copied() - .map(|edge| genuine_instance(edge, view.crease_threshold_rad)); + .map(|edge| genuine_instance(edge, view.crease_threshold_rad, extra_bits)); let silhouette = scene .silhouettes() .iter() .copied() .filter(|candidate| straddles(*candidate, view.projection)) - .map(silhouette_instance); + .map(|candidate| silhouette_instance(candidate, extra_bits)); genuine.chain(silhouette).collect() } -fn genuine_instance(edge: GenuineEdge, threshold_rad: f64) -> EdgeInstance { +fn genuine_instance(edge: GenuineEdge, threshold_rad: f64, extra_bits: u32) -> EdgeInstance { let kind = if edge.crease().radians() >= threshold_rad { EdgeKind::Crease } else { EdgeKind::Genuine }; - instance(edge.a(), edge.b(), edge.pick(), kind, GENUINE_OFFSET_PX) + instance( + edge.a(), + edge.b(), + edge.pick(), + kind, + GENUINE_OFFSET_PX, + extra_bits, + ) } -fn silhouette_instance(candidate: SilhouetteCandidate) -> EdgeInstance { +fn silhouette_instance(candidate: SilhouetteCandidate, extra_bits: u32) -> EdgeInstance { instance( candidate.a(), candidate.b(), candidate.pick(), EdgeKind::Silhouette, SILHOUETTE_OFFSET_PX, + extra_bits, ) } @@ -335,13 +423,14 @@ fn instance( pick: PickId, kind: EdgeKind, depth_offset_px: f32, + extra_bits: u32, ) -> EdgeInstance { EdgeInstance { a: lower_point(a), b: lower_point(b), half_width_px: EDGE_HALF_WIDTH_PX, depth_offset_px, - style_bits: kind.style_bits(), + style_bits: kind.style_bits() | extra_bits, pick_id: pick.raw(), } } diff --git a/crates/bone-render/src/pipelines/mod.rs b/crates/bone-render/src/pipelines/mod.rs index 5dae56d..4026914 100644 --- a/crates/bone-render/src/pipelines/mod.rs +++ b/crates/bone-render/src/pipelines/mod.rs @@ -12,11 +12,11 @@ mod text_common; pub use arc::ArcPipeline; pub use chrome::{ChromeInstance, ChromePipeline}; pub use chrome_text::{ChromeTextPipeline, SdfGlyphInstance}; -pub use edge_3d::{Edge3dPipeline, EdgeProjection, EdgeView}; +pub(crate) use edge_3d::{Edge3dPipeline, EdgeProjection, EdgeView, HiddenEdges}; pub use glyph::GlyphPipeline; pub use grid::GridPipeline; pub use lines::LinesPipeline; -pub use solid::{FaceFill, SolidPipeline, SolidView}; +pub(crate) use solid::{FaceFill, SolidPipeline, SolidView}; pub use text::TextPipeline; use crate::camera::Camera2; diff --git a/crates/bone-render/src/pipelines/solid.rs b/crates/bone-render/src/pipelines/solid.rs index 6803032..c25587d 100644 --- a/crates/bone-render/src/pipelines/solid.rs +++ b/crates/bone-render/src/pipelines/solid.rs @@ -3,7 +3,7 @@ use wgpu::util::DeviceExt; use bone_types::ShadingModel; use crate::RenderTargets; -use crate::gpu::{Gpu, PICK_FORMAT, pick_clear_color}; +use crate::gpu::{Gpu, PICK_FORMAT}; use crate::lower_f32; use crate::scene::SolidScene; use crate::snapshot::Style; @@ -28,7 +28,7 @@ const fn shading_code(model: ShadingModel) -> u32 { } #[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum FaceFill { +pub(crate) enum FaceFill { Shaded, Occluder, } @@ -41,7 +41,7 @@ const fn fill_code(fill: FaceFill) -> u32 { } #[derive(Copy, Clone)] -pub struct SolidView { +pub(crate) struct SolidView { pub clip_from_world: [f32; 16], pub eye_world: [f32; 3], pub shading: ShadingModel, @@ -80,7 +80,7 @@ const VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 2 => Uint32, ]; -pub struct SolidPipeline { +pub(crate) struct SolidPipeline { device: wgpu::Device, queue: wgpu::Queue, pipeline: wgpu::RenderPipeline, @@ -214,34 +214,8 @@ impl SolidPipeline { let indices = build_indices(scene); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("bone-render:solid-pass"), - color_attachments: &[ - Some(wgpu::RenderPassColorAttachment { - view: targets.color, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(style.background().into()), - store: wgpu::StoreOp::Store, - }, - }), - Some(wgpu::RenderPassColorAttachment { - view: targets.pick, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(pick_clear_color()), - store: wgpu::StoreOp::Store, - }, - }), - ], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: depth_view, - depth_ops: Some(wgpu::Operations { - load: wgpu::LoadOp::Clear(1.0), - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), + color_attachments: &crate::solid_clear_color_attachments(targets, style), + depth_stencil_attachment: Some(crate::solid_clear_depth_attachment(depth_view)), timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, diff --git a/crates/bone-render/src/snapshot.rs b/crates/bone-render/src/snapshot.rs index 7baf4d0..b28f05f 100644 --- a/crates/bone-render/src/snapshot.rs +++ b/crates/bone-render/src/snapshot.rs @@ -269,6 +269,34 @@ impl Default for TextStyle { } } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct EdgeStyle { + visible: ClearColor, + hidden: ClearColor, +} + +impl EdgeStyle { + pub const DEFAULT: Self = Self { + visible: ClearColor::opaque(0.90, 0.90, 0.93), + hidden: ClearColor::opaque(0.249, 0.274, 0.300), + }; + + #[must_use] + pub const fn new(visible: ClearColor, hidden: ClearColor) -> Self { + Self { visible, hidden } + } + + #[must_use] + pub const fn visible(self) -> ClearColor { + self.visible + } + + #[must_use] + pub const fn hidden(self) -> ClearColor { + self.hidden + } +} + #[derive(Copy, Clone, Debug, PartialEq)] pub struct Style { background: ClearColor, @@ -276,6 +304,7 @@ pub struct Style { strokes: StrokeStyle, glyphs: GlyphStyle, text: TextStyle, + edges: EdgeStyle, } impl Style { @@ -287,6 +316,7 @@ impl Style { strokes: StrokeStyle::DEFAULT, glyphs: GlyphStyle::DEFAULT, text: TextStyle::DEFAULT, + edges: EdgeStyle::DEFAULT, } } @@ -295,6 +325,11 @@ impl Style { self.background } + #[must_use] + pub const fn edges(self) -> EdgeStyle { + self.edges + } + #[must_use] pub const fn grid(self) -> GridStyle { self.grid @@ -319,6 +354,11 @@ impl Style { pub const fn with_text(self, text: TextStyle) -> Self { Self { text, ..self } } + + #[must_use] + pub const fn with_edges(self, edges: EdgeStyle) -> Self { + Self { edges, ..self } + } } impl Default for Style { diff --git a/crates/bone-render/tests/edges.rs b/crates/bone-render/tests/edges.rs index bf76e25..ae76ab0 100644 --- a/crates/bone-render/tests/edges.rs +++ b/crates/bone-render/tests/edges.rs @@ -4,13 +4,12 @@ use bone_kernel::{ evaluate_extrude, }; use bone_render::{ - EdgeScene, FaceFill, OffscreenContext, SnapshotFrame, SolidRenderer, SolidScene, Style, - frame_isometric, + EdgeScene, OffscreenContext, SnapshotFrame, SolidRenderer, SolidScene, Style, frame_isometric, }; use bone_types::{ - Aabb3, Angle, AngleTolerance, Camera3, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, - Point3, PositiveLength, Projection, SketchEntityId, SketchId, Tolerance, UnitVec3, millimeter, - radian, + Aabb3, Angle, AngleTolerance, Camera3, ChordHeightTolerance, DisplayMode, FeatureId, Length, + Plane3, Point2, Point3, PositiveLength, Projection, SketchEntityId, SketchId, Tolerance, + UnitVec3, millimeter, radian, }; use slotmap::{Key, SlotMap}; @@ -20,6 +19,7 @@ use common::{check_golden, extent_square as extent, make_context}; const TOLERANCE: Tolerance = Tolerance::new(1.0e-9); const UPDATE_ENV: &str = "BONE_UPDATE_EDGES_GOLDENS"; +const DISPLAY_ENV: &str = "BONE_UPDATE_DISPLAY_GOLDENS"; const CHORD_MM: f64 = 0.05; const ANGLE_RAD: f64 = 0.2; @@ -182,31 +182,41 @@ fn render( faces: &SolidScene, edges: &EdgeScene, camera: Camera3, - fill: FaceFill, + mode: DisplayMode, ) -> SnapshotFrame { let mut renderer = SolidRenderer::new(ctx.gpu(), ctx.color_format()); - let Ok(frame) = renderer.render_with_edges(ctx, faces, edges, camera, &Style::default(), fill) + let Ok(frame) = renderer.render_display(ctx, faces, edges, camera, &Style::default(), mode) else { - panic!("SolidRenderer::render_with_edges failed"); + panic!("SolidRenderer::render_display failed"); }; frame } -#[test] -fn cube_shaded_with_edges_matches_golden() { +fn cube_frame(mode: DisplayMode) -> SnapshotFrame { let size = extent(256); let ctx = make_context(size); let solid = unit_cube(); let (faces, edges) = scenes(&solid); - let frame = render( - &ctx, - &faces, - &edges, - cube_camera(&solid, size), - FaceFill::Shaded, + render(&ctx, &faces, &edges, cube_camera(&solid, size), mode) +} + +fn cylinder_frame(mode: DisplayMode) -> SnapshotFrame { + let size = extent(256); + let ctx = make_context(size); + let solid = cylinder(); + let (faces, edges) = scenes(&solid); + let camera = framed( + aabb_of(&solid), + direction(1.0, 1.0, 0.6), + UnitVec3::z_axis(), ); + render(&ctx, &faces, &edges, camera, mode) +} + +#[test] +fn cube_shaded_with_edges_matches_golden() { check_golden( - &frame, + &cube_frame(DisplayMode::ShadedWithEdges), "tests/goldens/shaded_with_edges_256.png", UPDATE_ENV, ); @@ -214,19 +224,8 @@ fn cube_shaded_with_edges_matches_golden() { #[test] fn cube_hidden_line_removed_matches_golden() { - let size = extent(256); - let ctx = make_context(size); - let solid = unit_cube(); - let (faces, edges) = scenes(&solid); - let frame = render( - &ctx, - &faces, - &edges, - cube_camera(&solid, size), - FaceFill::Occluder, - ); check_golden( - &frame, + &cube_frame(DisplayMode::HiddenLineRemoved), "tests/goldens/hidden_line_removed_256.png", UPDATE_ENV, ); @@ -243,7 +242,7 @@ fn cube_perspective_with_edges_matches_golden() { direction(1.0, 1.0, 0.6), UnitVec3::z_axis(), ); - let frame = render(&ctx, &faces, &edges, camera, FaceFill::Shaded); + let frame = render(&ctx, &faces, &edges, camera, DisplayMode::ShadedWithEdges); check_golden( &frame, "tests/goldens/perspective_with_edges_256.png", @@ -253,18 +252,8 @@ fn cube_perspective_with_edges_matches_golden() { #[test] fn cylinder_shaded_with_edges_matches_golden() { - let size = extent(256); - let ctx = make_context(size); - let solid = cylinder(); - let (faces, edges) = scenes(&solid); - let camera = framed( - aabb_of(&solid), - direction(1.0, 1.0, 0.6), - UnitVec3::z_axis(), - ); - let frame = render(&ctx, &faces, &edges, camera, FaceFill::Shaded); check_golden( - &frame, + &cylinder_frame(DisplayMode::ShadedWithEdges), "tests/goldens/shaded_with_edges_cylinder_256.png", UPDATE_ENV, ); @@ -272,23 +261,67 @@ fn cylinder_shaded_with_edges_matches_golden() { #[test] fn cylinder_hidden_line_removed_matches_golden() { - let size = extent(256); - let ctx = make_context(size); - let solid = cylinder(); - let (faces, edges) = scenes(&solid); - let camera = framed( - aabb_of(&solid), - direction(1.0, 1.0, 0.6), - UnitVec3::z_axis(), - ); - let frame = render(&ctx, &faces, &edges, camera, FaceFill::Occluder); check_golden( - &frame, + &cylinder_frame(DisplayMode::HiddenLineRemoved), "tests/goldens/hidden_line_removed_cylinder_256.png", UPDATE_ENV, ); } +#[test] +fn cube_wireframe_matches_golden() { + check_golden( + &cube_frame(DisplayMode::Wireframe), + "tests/goldens/wireframe_256.png", + DISPLAY_ENV, + ); +} + +#[test] +fn cylinder_wireframe_matches_golden() { + check_golden( + &cylinder_frame(DisplayMode::Wireframe), + "tests/goldens/wireframe_cylinder_256.png", + DISPLAY_ENV, + ); +} + +#[test] +fn cube_hidden_line_gray_matches_golden() { + check_golden( + &cube_frame(DisplayMode::HiddenLineGray), + "tests/goldens/hidden_line_gray_256.png", + DISPLAY_ENV, + ); +} + +#[test] +fn cylinder_hidden_line_gray_matches_golden() { + check_golden( + &cylinder_frame(DisplayMode::HiddenLineGray), + "tests/goldens/hidden_line_gray_cylinder_256.png", + DISPLAY_ENV, + ); +} + +#[test] +fn cube_shaded_no_edges_matches_golden() { + check_golden( + &cube_frame(DisplayMode::ShadedNoEdges), + "tests/goldens/shaded_no_edges_256.png", + DISPLAY_ENV, + ); +} + +#[test] +fn cylinder_shaded_no_edges_matches_golden() { + check_golden( + &cylinder_frame(DisplayMode::ShadedNoEdges), + "tests/goldens/shaded_no_edges_cylinder_256.png", + DISPLAY_ENV, + ); +} + #[test] fn cube_genuine_edges_classify_as_creases() { let solid = unit_cube(); diff --git a/crates/bone-render/tests/goldens/hidden_line_gray_256.png b/crates/bone-render/tests/goldens/hidden_line_gray_256.png new file mode 100644 index 0000000000000000000000000000000000000000..51538ee48910d828b8b2ab8d78eb93cc692af89b GIT binary patch literal 18317 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 literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/hidden_line_gray_cylinder_256.png b/crates/bone-render/tests/goldens/hidden_line_gray_cylinder_256.png new file mode 100644 index 0000000000000000000000000000000000000000..c66628154ff4b21513b6b61c60211c3538df48cf GIT binary patch literal 12991 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 JWt~$(69A($$@BmK literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/shaded_no_edges_256.png b/crates/bone-render/tests/goldens/shaded_no_edges_256.png new file mode 100644 index 0000000000000000000000000000000000000000..0c66a80d311cf0c109080f53e1927a276a3d198b GIT binary patch literal 6554 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 PU|{fc^>bP0l+XkKZyP;A literal 0 HcmV?d00001 diff --git a/crates/bone-render/tests/goldens/wireframe_256.png b/crates/bone-render/tests/goldens/wireframe_256.png new file mode 100644 index 0000000000000000000000000000000000000000..b099ae37409aa1ce0c8233842cbe1749497bf380 GIT binary patch literal 20192 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 literal 0 HcmV?d00001