Files
OneCAD/crates/ui-egui/src/sketch_palette.rs
T
bherbruckandClaude Opus 5.5 7a2eb059c4 Components are frames (user bug #4)
What a component holds moves with its occurrence, and picks in the world land in the
component's frame:
- Sketches and construction planes show where their component is placed (free move, joint
  drive, Capture Position); sketch.inspect reports sketch points in the world.
- frames.rs maps a command's world picks into the frame of the body they lie on (through the
  inverse of its placement: a moved component or a pasted instance), points that make new
  geometry into the active component's frame, and directions with them; instance body names
  ("Body1 (Pin:2)") become the component's body. Fillet/chamfer edges, shell/offset/draft
  faces, face extrude/revolve, sketch on face, holes, sheet metal and plastic picks, primitives,
  appearance faces, analyses. Once per command (not again for commands run by commands); the
  active-frame fallback stays for commands without a table. Nothing changes while every
  placement is the identity.
- component.move_bodies keeps a body where it is in the world (Document.body_offsets: from the
  new component's frame to the one it was made in; renames and deleted components follow).
- The UI reads the placed model (Session::world_state) for display, picking, sketch editing and
  dialogs; live previews use it once anything is placed.
Tests (engine): sketches and construction planes move (and turn) with the component; world
picks fillet, sketch, hole and make boxes in a moved component; a pick on a pasted instance;
move_bodies keeps the world place there and back; a sketch on a face follows a Move feature.
QA's s34 now runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-08 15:04:32 +00:00

219 lines
8.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Sketch Palette: a panel docked at the right while a sketch is being edited, like a
//! command dialog. Options: the line type of the selected curves (normal, construction,
//! centerline), Look At, and the display toggles kept with the sketch (grid, snap, slice,
//! profiles, points, dimensions, constraints, projected geometry, 3D sketch); the sketch's
//! degrees of freedom; Finish Sketch. Toggles run `sketch.options`, line types
//! `sketch.construction` / `sketch.centerline`.
use std::cell::Cell;
use egui::{Color32, RichText, Stroke, vec2};
use serde_json::{Value, json};
use solvecraft_engine::Sel;
use crate::SolveApp;
use crate::theme::Tokens;
thread_local! {
static FOLDED: Cell<bool> = const { Cell::new(false) };
static OPTIONS_FOLDED: Cell<bool> = const { Cell::new(false) };
}
/// Palette width (logical px).
const WIDTH: f32 = 236.0;
/// The display toggles: option key and label.
pub const TOGGLES: [(&str, &str); 9] = [
("grid", "Sketch Grid"),
("snap", "Snap"),
("slice", "Slice"),
("show_profile", "Show Profile"),
("show_points", "Show Points"),
("show_dimensions", "Show Dimensions"),
("show_constraints", "Show Constraints"),
("show_projected", "Show Projected Geometries"),
("sketch_3d", "3D Sketch"),
];
/// The degrees-of-freedom line: "Fully constrained" or "N degrees of freedom".
pub fn dof_text(dof: usize, ok: bool) -> String {
if !ok {
"Constraints conflict".into()
} else if dof == 0 {
"Fully constrained".into()
} else if dof == 1 {
"1 degree of freedom".into()
} else {
format!("{dof} degrees of freedom")
}
}
/// Selected curves of the active sketch.
fn selected_curves(app: &SolveApp) -> Vec<String> {
app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect()
}
/// Set the line type of the selected curves: 0 normal, 1 construction, 2 centerline (lines).
pub fn set_linetype(app: &mut SolveApp, kind: u8) {
let curves = selected_curves(app);
if curves.is_empty() {
app.set_status("Select sketch curves first", false);
return;
}
let lines: Vec<String> = {
let st = app.session.world_state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
curves
.iter()
.filter(|id| {
ss.sketch
.curve_index(id)
.and_then(|i| ss.sketch.curves.get(i))
.is_some_and(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }))
})
.cloned()
.collect()
};
let r = match kind {
0 => {
if !lines.is_empty() {
let _ = app.run("sketch.centerline", json!({ "curves": lines, "value": false }));
}
app.run("sketch.construction", json!({ "curves": curves, "value": false }))
}
1 => app.run("sketch.construction", json!({ "curves": curves, "value": true })),
_ if lines.is_empty() => Err("a centerline must be a line".to_string()),
_ => app.run("sketch.centerline", json!({ "curves": lines, "value": true })),
};
if let Err(e) = r {
app.set_status(e, true);
}
}
/// Draw the palette (while sketching and no command dialog is open).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if app.dialog.is_some() {
return;
}
let (opts, dof_line, ok) = {
let st = app.session.world_state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
(ss.sketch.view.clone(), dof_text(ss.report.dof, ss.report.ok()), ss.report.ok() && ss.report.dof == 0)
};
let t = Tokens::get();
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
let shown = |k: &str| match k {
"grid" => !opts.hide_grid,
"snap" => !opts.no_snap,
"slice" => opts.slice,
"show_profile" => !opts.hide_profile,
"show_points" => !opts.hide_points,
"show_dimensions" => !opts.hide_dimensions,
"show_constraints" => !opts.hide_constraints,
"show_projected" => !opts.hide_projected,
"sketch_3d" => opts.three_d,
_ => false,
};
let mut set: Option<(String, bool)> = None;
let mut linetype: Option<u8> = None;
let mut look = false;
let mut finish = false;
let has_curves = !selected_curves(app).is_empty();
egui::Window::new("SKETCH PALETTE")
.id(egui::Id::new("sc_sketch_palette"))
.title_bar(false)
.frame(frame)
.pivot(egui::Align2::RIGHT_TOP)
.fixed_pos(anchor)
.constrain(false)
.auto_sized()
.collapsible(false)
.show(ctx, |ui| {
ui.set_width(WIDTH);
ui.horizontal(|ui| {
let folded = FOLDED.with(Cell::get);
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() {
FOLDED.with(|f| f.set(!folded));
}
ui.label(RichText::new("SKETCH PALETTE").strong().size(12.5));
});
if FOLDED.with(Cell::get) {
return;
}
let ofold = OPTIONS_FOLDED.with(Cell::get);
if ui.add(egui::Button::new(RichText::new(format!("{} Options", if ofold { "+" } else { "−" })).size(12.0)).frame(false)).clicked() {
OPTIONS_FOLDED.with(|f| f.set(!ofold));
}
if !ofold {
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Linetype");
ui.horizontal(|ui| {
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
if ui.add_enabled(has_curves, egui::Button::new(*label).min_size(vec2(30.0, 20.0))).on_hover_text(*tip).clicked() {
linetype = Some(i as u8);
}
}
});
ui.end_row();
ui.label("Look At");
if ui.button("Look At").clicked() {
look = true;
}
ui.end_row();
for (k, label) in TOGGLES {
// The label is part of the checkbox: clicking it toggles too.
let mut on = shown(k);
if ui.checkbox(&mut on, label).changed() {
set = Some((k.to_string(), on));
}
ui.end_row();
}
});
}
ui.add_space(4.0);
ui.label(RichText::new(dof_line.as_str()).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(WIDTH - 100.0);
if ui.add(egui::Button::new(RichText::new("Finish Sketch").color(Color32::WHITE)).fill(t.accent)).clicked() {
finish = true;
}
});
});
if let Some((k, on)) = set {
let mut p = serde_json::Map::new();
p.insert(k, Value::Bool(on));
if let Err(e) = app.run("sketch.options", Value::Object(p)) {
app.set_status(e, true);
}
}
if let Some(k) = linetype {
set_linetype(app, k);
}
if look {
crate::dialogs::look_at_sketch(app);
}
if finish {
app.finish_sketch();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dof_readout() {
assert_eq!(dof_text(0, true), "Fully constrained");
assert_eq!(dof_text(1, true), "1 degree of freedom");
assert_eq!(dof_text(4, true), "4 degrees of freedom");
assert_eq!(dof_text(4, false), "Constraints conflict");
}
}