Canvases in the 3D pass: textured quads depth-tested against the model (the model hides them, decals sit on their face), with the section cut applied; Canvas panel (opened after placing a canvas or by Edit Canvas in INSERT): opacity slider, width, angle, flip, visibility, delete
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d5b8aa0375
commit
3aacebdda4
6 files changed
+336
-25
No files matched your search
@@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("decal")
|
||||
.params("path | data; face: [x,y,z] on a planar face (the image is centred there); width?: mm; angle?: deg; opacity?: (default 1)"),
|
||||
CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"),
|
||||
CommandSpec::new("canvas.edit", "Edit Canvas", edit_canvas).at("SKETCH", "INSERT").icon("canvas").params("canvas: id or name; center?, width?, angle?: deg, opacity?, flip?, visible?, name?"),
|
||||
CommandSpec::new("canvas.calibrate", "Calibrate Canvas", calibrate)
|
||||
.params("canvas: id or name; a, b: two points on the canvas plane [x,y]; distance: their true distance (mm): scales the image about a"),
|
||||
CommandSpec::new("canvas.delete", "Delete Canvas", delete_canvas).params("canvas: id or name"),
|
||||
|
||||
@@ -81,6 +81,23 @@ impl GpuScene {
|
||||
|
||||
pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
|
||||
|
||||
/// A reference image (canvas or decal): a textured quad, depth-tested against the model.
|
||||
#[derive(Clone)]
|
||||
pub struct GpuImage {
|
||||
pub id: u64,
|
||||
/// Changes when the pixels change (the texture is re-uploaded).
|
||||
pub version: u64,
|
||||
pub size: [u32; 2],
|
||||
/// Straight RGBA8, `size[0] * size[1] * 4` bytes.
|
||||
pub rgba: Arc<Vec<u8>>,
|
||||
/// Bottom-left, bottom-right, top-right, top-left of the image.
|
||||
pub corners: [[f32; 3]; 4],
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32).
|
||||
const IMG_SIZE: usize = 24;
|
||||
|
||||
/// Per-frame parameters.
|
||||
pub struct ViewportCallback {
|
||||
pub key: u64,
|
||||
@@ -101,6 +118,8 @@ pub struct ViewportCallback {
|
||||
pub clip: [f32; 4],
|
||||
/// Colour of the inside of cut bodies (straight sRGBA, 0..1).
|
||||
pub cap: [f32; 4],
|
||||
/// Canvases and decals.
|
||||
pub images: Vec<GpuImage>,
|
||||
}
|
||||
|
||||
struct Batch {
|
||||
@@ -153,6 +172,13 @@ struct Resources {
|
||||
model: Batches,
|
||||
highlight: Batches,
|
||||
preview: Batches,
|
||||
image: wgpu::RenderPipeline,
|
||||
image_bgl: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
/// Textures by image id: (version, bind group).
|
||||
textures: std::collections::HashMap<u64, (u64, wgpu::BindGroup)>,
|
||||
/// This frame's quads: (image id, vertices).
|
||||
quads: Vec<(u64, Batch)>,
|
||||
}
|
||||
|
||||
const SHADER: &str = r#"
|
||||
@@ -278,6 +304,35 @@ fn fs_line(i: LOut) -> @location(0) vec4<f32> {
|
||||
return out_color(i.c);
|
||||
}
|
||||
|
||||
struct IOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) a: f32,
|
||||
@location(2) wp: vec3<f32>,
|
||||
};
|
||||
|
||||
@group(1) @binding(0) var img_t: texture_2d<f32>;
|
||||
@group(1) @binding(1) var img_s: sampler;
|
||||
|
||||
@vertex
|
||||
fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a: f32) -> IOut {
|
||||
var o: IOut;
|
||||
o.pos = u.vp * vec4<f32>(p, 1.0);
|
||||
// Pulled toward the eye like lines, so a decal on a face is not lost in it.
|
||||
o.pos.z = o.pos.z - u.screen.z * o.pos.w;
|
||||
o.uv = uv;
|
||||
o.a = a;
|
||||
o.wp = p;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp)) { discard; }
|
||||
let c = textureSample(img_t, img_s, i.uv);
|
||||
return out_color(vec4<f32>(c.rgb, c.a * i.a));
|
||||
}
|
||||
|
||||
/// Lines the section never cuts (the grid, highlights drawn on top).
|
||||
@fragment
|
||||
fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
|
||||
@@ -365,7 +420,70 @@ impl Resources {
|
||||
})
|
||||
};
|
||||
let alpha = Some(wgpu::BlendState::ALPHA_BLENDING);
|
||||
let image_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("sc_image"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_image"),
|
||||
bind_group_layouts: &[Some(&bgl), Some(&image_bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
const IA: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32];
|
||||
let image = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_image"),
|
||||
layout: Some(&image_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_img"),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: IMG_SIZE as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &IA,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
|
||||
depth_stencil: depth(wgpu::CompareFunction::LessEqual, false),
|
||||
multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_img"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("sc_image"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
Resources {
|
||||
image,
|
||||
image_bgl,
|
||||
sampler,
|
||||
textures: Default::default(),
|
||||
quads: Vec::new(),
|
||||
tri: pipeline("sc_tris", "vs_tri", "fs_solid", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
|
||||
tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
@@ -419,6 +537,55 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
res.highlight.take(device, self.hl_key, &self.hl_slot);
|
||||
res.preview.take(device, self.pv_key, &self.pv_slot);
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out));
|
||||
// Images: upload new or changed textures, drop removed ones, rebuild the quads.
|
||||
res.textures.retain(|id, _| self.images.iter().any(|i| i.id == *id));
|
||||
res.quads.clear();
|
||||
for img in &self.images {
|
||||
let [w, h] = img.size;
|
||||
if w == 0 || h == 0 || img.rgba.len() != (w as usize) * (h as usize) * 4 {
|
||||
continue;
|
||||
}
|
||||
if res.textures.get(&img.id).is_none_or(|t| t.0 != img.version) {
|
||||
let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 };
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("sc_image"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
|
||||
&img.rgba,
|
||||
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * w), rows_per_image: Some(h) },
|
||||
size,
|
||||
);
|
||||
let view = tex.create_view(&Default::default());
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("sc_image"),
|
||||
layout: &res.image_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.sampler) },
|
||||
],
|
||||
});
|
||||
res.textures.insert(img.id, (img.version, bind));
|
||||
}
|
||||
let c = img.corners;
|
||||
let uv = [[0.0_f32, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]];
|
||||
let mut bytes = Vec::with_capacity(6 * IMG_SIZE);
|
||||
for k in [0, 1, 2, 0, 2, 3] {
|
||||
for v in c[k].iter().chain(&uv[k]).chain(std::iter::once(&img.opacity)) {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
if let Some(b) = upload(device, "sc_image_quad", &bytes, IMG_SIZE) {
|
||||
res.quads.push((img.id, b));
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -452,6 +619,14 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
|
||||
lines(pass, &res.line, &m.lines);
|
||||
lines(pass, &res.line, &pv.lines);
|
||||
lines(pass, &res.line, &h.lines);
|
||||
for (id, q) in &res.quads {
|
||||
if let Some((_, bind)) = res.textures.get(id) {
|
||||
pass.set_pipeline(&res.image);
|
||||
pass.set_bind_group(1, bind, &[]);
|
||||
pass.set_vertex_buffer(0, q.buffer.slice(..));
|
||||
pass.draw(0..q.count, 0..1);
|
||||
}
|
||||
}
|
||||
tris(pass, &res.ghost, &pv.ghost);
|
||||
tris(pass, &res.trans, &m.trans);
|
||||
tris(pass, &res.trans, &h.trans);
|
||||
|
||||
@@ -317,7 +317,7 @@ impl SolveApp {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !sketch_tools::pick_image(self, id) {
|
||||
if !sketch_tools::start_hook(self, id) {
|
||||
return;
|
||||
}
|
||||
if id == "ChangeParameterCommand" {
|
||||
|
||||
@@ -1,55 +1,112 @@
|
||||
//! Canvases and decals: the design's reference images, drawn on their planes under the sketch
|
||||
//! overlays.
|
||||
//! Canvases and decals: the design's reference images. With the GPU viewport they are textured
|
||||
//! quads in the 3D pass (the model hides them); the CPU viewport draws them over its image.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Mesh, Shape, TextureHandle, TextureId, pos2};
|
||||
use solvecraft_engine::doc::canvas::Canvas;
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::gpu::GpuImage;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
/// Largest texture side; bigger images are scaled down for display.
|
||||
const MAX_SIDE: u32 = 2048;
|
||||
/// Grid cells per side, so perspective views bend the image only slightly.
|
||||
/// Grid cells per side, so perspective views bend the image only slightly (CPU path).
|
||||
const CELLS: usize = 8;
|
||||
|
||||
/// Decoded pixels: (data length they came from, version, size, RGBA).
|
||||
type Decoded = (usize, u64, [u32; 2], Arc<Vec<u8>>);
|
||||
|
||||
thread_local! {
|
||||
/// Textures by canvas id, with the length of the data they were made from.
|
||||
static TEX: RefCell<HashMap<u64, (usize, Option<TextureHandle>)>> = RefCell::new(HashMap::new());
|
||||
static PIXELS: RefCell<HashMap<u64, Option<Decoded>>> = RefCell::new(HashMap::new());
|
||||
/// egui textures for the CPU path, by canvas id, with the version they hold.
|
||||
static TEX: RefCell<HashMap<u64, (u64, TextureHandle)>> = RefCell::new(HashMap::new());
|
||||
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn texture(ctx: &egui::Context, id: u64, data: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Option<TextureId> {
|
||||
TEX.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
if let Some((len, h)) = t.get(&id)
|
||||
&& *len == data.len()
|
||||
fn pixels(c: &Canvas) -> Option<Decoded> {
|
||||
PIXELS.with(|m| {
|
||||
let mut m = m.borrow_mut();
|
||||
if let Some(d) = m.get(&c.id)
|
||||
&& d.as_ref().is_none_or(|d| d.0 == c.data.len())
|
||||
{
|
||||
return h.as_ref().map(|h| h.id());
|
||||
return d.clone();
|
||||
}
|
||||
let img = bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| {
|
||||
let d = c.bytes().and_then(|b| image::load_from_memory(&b).ok()).map(|i| {
|
||||
let i = if i.width() > MAX_SIDE || i.height() > MAX_SIDE { i.thumbnail(MAX_SIDE, MAX_SIDE) } else { i };
|
||||
let rgba = i.to_rgba8();
|
||||
egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())
|
||||
let v = VERSION.with(|v| {
|
||||
v.set(v.get() + 1);
|
||||
v.get()
|
||||
});
|
||||
(c.data.len(), v, [rgba.width(), rgba.height()], Arc::new(rgba.into_raw()))
|
||||
});
|
||||
let h = img.map(|img| ctx.load_texture(format!("sc_canvas_{id}"), img, egui::TextureOptions::LINEAR));
|
||||
let out = h.as_ref().map(|h| h.id());
|
||||
t.insert(id, (data.len(), h));
|
||||
out
|
||||
m.insert(c.id, d.clone());
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
fn forget_removed(app: &SolveApp) {
|
||||
let doc = &app.session.doc;
|
||||
PIXELS.with(|m| m.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id)));
|
||||
TEX.with(|t| t.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id)));
|
||||
}
|
||||
|
||||
/// The visible canvases as GPU quads.
|
||||
pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
|
||||
forget_removed(app);
|
||||
let doc = &app.session.doc;
|
||||
if doc.canvases.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.canvases
|
||||
.iter()
|
||||
.filter(|c| c.visible && c.opacity > 0.0)
|
||||
.filter_map(|c| {
|
||||
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
|
||||
let (_, version, size, rgba) = pixels(c)?;
|
||||
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
|
||||
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn texture(ctx: &egui::Context, c: &Canvas) -> Option<TextureId> {
|
||||
let (_, version, [w, h], rgba) = pixels(c)?;
|
||||
TEX.with(|t| {
|
||||
let mut t = t.borrow_mut();
|
||||
if let Some((v, h)) = t.get(&c.id)
|
||||
&& *v == version
|
||||
{
|
||||
return Some(h.id());
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);
|
||||
let handle = ctx.load_texture(format!("sc_canvas_{}", c.id), img, egui::TextureOptions::LINEAR);
|
||||
let id = handle.id();
|
||||
t.insert(c.id, (version, handle));
|
||||
Some(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// CPU viewport only: the images over the rendered model.
|
||||
pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
if app.viewport.gpu.is_some() {
|
||||
return;
|
||||
}
|
||||
forget_removed(app);
|
||||
let doc = &app.session.doc;
|
||||
if doc.canvases.is_empty() {
|
||||
TEX.with(|t| t.borrow_mut().clear());
|
||||
return;
|
||||
}
|
||||
let (vals, _) = doc.param_values();
|
||||
for c in doc.canvases.iter().filter(|c| c.visible) {
|
||||
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
|
||||
let Some(tex) = texture(ui.ctx(), c.id, &c.data, || c.bytes()) else { continue };
|
||||
let Some(tex) = texture(ui.ctx(), c) else { continue };
|
||||
let [bl, br, _, tl] = c.corners();
|
||||
let (u, v) = (br - bl, tl - bl);
|
||||
let tint = Color32::from_white_alpha((c.opacity.clamp(0.0, 1.0) * 255.0) as u8);
|
||||
@@ -80,5 +137,4 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
|
||||
}
|
||||
painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
TEX.with(|t| t.borrow_mut().retain(|id, _| doc.canvases.iter().any(|c| c.id == *id)));
|
||||
}
|
||||
@@ -28,10 +28,20 @@ thread_local! {
|
||||
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
||||
/// The image file a Canvas or Decal tool places.
|
||||
static IMAGE: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// The canvas whose panel is open.
|
||||
static CANVAS_PANEL: Cell<Option<u64>> = const { Cell::new(None) };
|
||||
/// The opacity slider while it is dragged: (canvas, value).
|
||||
static OPACITY: Cell<Option<(u64, f64)>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Canvas and Decal start by asking for the image; false when none was chosen.
|
||||
pub fn pick_image(app: &SolveApp, id: &str) -> bool {
|
||||
/// Commands that start with a step of their own: Canvas and Decal ask for the image first,
|
||||
/// Edit Canvas opens the canvas panel. False when the command should not go on.
|
||||
pub fn start_hook(app: &SolveApp, id: &str) -> bool {
|
||||
if id == "canvas.edit" {
|
||||
let first = app.session.doc.canvases.first().map(|c| c.id);
|
||||
CANVAS_PANEL.with(|c| c.set(first));
|
||||
return false;
|
||||
}
|
||||
if !matches!(id, "FusionAddCanvasCommand" | "FusionAddEditDecalCommand") {
|
||||
return true;
|
||||
}
|
||||
@@ -41,6 +51,73 @@ pub fn pick_image(app: &SolveApp, id: &str) -> bool {
|
||||
ok
|
||||
}
|
||||
|
||||
/// The canvas panel: opacity, size, angle, flip, visibility of one canvas.
|
||||
fn canvas_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = CANVAS_PANEL.with(|c| c.get()) else { return };
|
||||
let Some(c) = app.session.doc.canvases.iter().find(|c| c.id == id).cloned() else {
|
||||
CANVAS_PANEL.with(|c| c.set(None));
|
||||
return;
|
||||
};
|
||||
let ids: Vec<(u64, String)> = app.session.doc.canvases.iter().map(|c| (c.id, c.name.clone())).collect();
|
||||
let mut open = true;
|
||||
let mut edit: Option<Value> = None;
|
||||
let mut delete = false;
|
||||
egui::Window::new("Canvas").open(&mut open).collapsible(false).resizable(false).default_pos(egui::pos2(320.0, 160.0)).show(ctx, |ui| {
|
||||
egui::ComboBox::from_id_salt("sc_canvas_pick").selected_text(&c.name).show_ui(ui, |ui| {
|
||||
for (cid, name) in &ids {
|
||||
if ui.selectable_label(*cid == id, name).clicked() {
|
||||
CANVAS_PANEL.with(|p| p.set(Some(*cid)));
|
||||
}
|
||||
}
|
||||
});
|
||||
egui::Grid::new("sc_canvas_grid").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Opacity");
|
||||
let mut o = OPACITY.with(|v| v.get()).filter(|v| v.0 == id).map(|v| v.1).unwrap_or(c.opacity);
|
||||
let r = ui.add(egui::Slider::new(&mut o, 0.0..=1.0).fixed_decimals(2));
|
||||
OPACITY.with(|v| v.set(Some((id, o))));
|
||||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
edit = Some(json!({"canvas": id, "opacity": o}));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Width");
|
||||
let mut w = c.width;
|
||||
if ui.add(egui::DragValue::new(&mut w).range(0.001..=1e6).suffix(" mm").speed(c.width * 0.005)).changed() {
|
||||
edit = Some(json!({"canvas": id, "width": w}));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("Angle");
|
||||
let mut a = c.angle.to_degrees();
|
||||
if ui.add(egui::DragValue::new(&mut a).suffix("°").speed(0.5)).changed() {
|
||||
edit = Some(json!({"canvas": id, "angle": a}));
|
||||
}
|
||||
ui.end_row();
|
||||
let mut flip = c.flip;
|
||||
if ui.checkbox(&mut flip, "Flip").changed() {
|
||||
edit = Some(json!({"canvas": id, "flip": flip}));
|
||||
}
|
||||
let mut vis = c.visible;
|
||||
if ui.checkbox(&mut vis, "Visible").changed() {
|
||||
edit = Some(json!({"canvas": id, "visible": vis}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
if ui.button("Delete canvas").clicked() {
|
||||
delete = true;
|
||||
}
|
||||
});
|
||||
if let Some(p) = edit {
|
||||
let _ = app.run("canvas.edit", p);
|
||||
OPACITY.with(|v| v.set(None));
|
||||
}
|
||||
if delete {
|
||||
let _ = app.run("canvas.delete", json!({"canvas": id}));
|
||||
open = false;
|
||||
}
|
||||
if !open {
|
||||
CANVAS_PANEL.with(|c| c.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<Vec3> {
|
||||
Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))
|
||||
}
|
||||
@@ -428,9 +505,10 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
} else {
|
||||
json!({"path": path, "plane": plane, "at": [at.x, at.y, at.z]})
|
||||
};
|
||||
if app.run(cmd, p).is_ok() {
|
||||
if let Ok(v) = app.run(cmd, p) {
|
||||
IMAGE.with(|i| *i.borrow_mut() = None);
|
||||
app.tool = None;
|
||||
CANVAS_PANEL.with(|c| c.set(v["canvas"].as_u64()));
|
||||
}
|
||||
}
|
||||
Mode::ModelRef => {
|
||||
@@ -849,6 +927,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
snap_hint(app, painter, proj);
|
||||
combs(app, painter, proj);
|
||||
text_entry(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// The text-entry box for a placed text.
|
||||
|
||||
@@ -1105,6 +1105,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
view_proj: proj.vp.to_f32(),
|
||||
back: proj.cam.back().to_f32(),
|
||||
size_px: [rect.width() * ppp, rect.height() * ppp],
|
||||
images: crate::ref_images::gpu_images(app),
|
||||
};
|
||||
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user