Render appearance: click, drag and paint to assign, Pick Up, copy and paste (#6281) (#6431)

* Render appearance: click, drag and paint to assign, Pick Up, copy and paste, and Remove

* Exclude the workspace Shots strip from dialog width checks

* Exclude the collapsed Shots control from dialog width checks

* Retain assembly cache listings and measure actual dialog bounds

* fix: handle toast button rectangles in assignment status

* fix: restrict gallery note measurement helper to tests

* test: measure assignment help at the catalogue footer

* fix: share the command revision snapshot after restacking
This commit is contained in:
Nathan Dane
2026-10-29 04:33:31 +00:00
committed by GitHub
parent 75035ac4a5
commit c1925bb219
29 changed files with 1725 additions and 132 deletions
@@ -0,0 +1,146 @@
{
"en": {
"cmd.appearance_copy": "Copy Look",
"cmd.appearance_favorite": "Favourite Look",
"cmd.appearance_paste": "Paste Look",
"render.assign_help": "Click to apply to the selection; drag to preview and drop, or click with nothing selected to paint.",
"render.browse_looks": "Browse…",
"render.choose_look": "Choose a look in Appearances first",
"render.hover_look": "{object} · {look} · click to select",
"render.look_applied": "Applied {name}",
"render.look_material": "Material",
"render.look_pasted": "Look pasted",
"render.painting_with": "Painting with {name}",
"render.pick_up_prompt": "Click an object to pick up its look",
"render.recent_looks": "Recent looks",
"render.remove_override": "Remove Override",
"render.replace_all": "Replace All",
"render.replace_with": "Replace with"
},
"de": {
"cmd.appearance_copy": "Look kopieren",
"cmd.appearance_favorite": "Look favorisieren",
"cmd.appearance_paste": "Look einfügen",
"render.assign_help": "Klicken wendet den Look auf die Auswahl an. Ziehen zeigt eine Vorschau; ohne Auswahl startet Malen.",
"render.browse_looks": "Durchsuchen…",
"render.choose_look": "Zuerst einen Look in der Bibliothek wählen",
"render.hover_look": "{object} · {look} · Zum Auswählen klicken",
"render.look_applied": "{name} angewendet",
"render.look_material": "Material",
"render.look_pasted": "Look eingefügt",
"render.painting_with": "Malen mit {name}",
"render.pick_up_prompt": "Ein Objekt anklicken, um seinen Look aufzunehmen",
"render.recent_looks": "Letzte Looks",
"render.remove_override": "Überschreibung entfernen",
"render.replace_all": "Alle ersetzen",
"render.replace_with": "Ersetzen durch"
},
"ja": {
"cmd.appearance_copy": "外観をコピー",
"cmd.appearance_favorite": "外観をお気に入りに追加",
"cmd.appearance_paste": "外観を貼り付け",
"render.assign_help": "クリックで選択に適用。ドラッグでプレビューしてドロップ。未選択ならペイント。",
"render.browse_looks": "参照…",
"render.choose_look": "まず外観ライブラリで外観を選択",
"render.hover_look": "{object} · {look} · クリックして選択",
"render.look_applied": "{name} を適用",
"render.look_material": "材質",
"render.look_pasted": "外観を貼り付け",
"render.painting_with": "{name} でペイント中",
"render.pick_up_prompt": "外観を取得するオブジェクトをクリック",
"render.recent_looks": "最近の外観",
"render.remove_override": "上書きを削除",
"render.replace_all": "すべて置換",
"render.replace_with": "置換後の外観"
},
"zh": {
"cmd.appearance_copy": "复制外观",
"cmd.appearance_favorite": "收藏外观",
"cmd.appearance_paste": "粘贴外观",
"render.assign_help": "单击应用到所选对象;拖动预览并放置,未选择对象时单击进入涂刷。",
"render.browse_looks": "浏览…",
"render.choose_look": "请先在外观库中选择外观",
"render.hover_look": "{object} · {look} · 单击选择",
"render.look_applied": "已应用 {name}",
"render.look_material": "材质",
"render.look_pasted": "已粘贴外观",
"render.painting_with": "正在使用 {name} 涂刷",
"render.pick_up_prompt": "单击对象以拾取外观",
"render.recent_looks": "最近的外观",
"render.remove_override": "移除覆盖",
"render.replace_all": "全部替换",
"render.replace_with": "替换为"
},
"ko": {
"cmd.appearance_copy": "외관 복사",
"cmd.appearance_favorite": "외관 즐겨찾기",
"cmd.appearance_paste": "외관 붙여넣기",
"render.assign_help": "클릭하여 선택 항목에 적용하거나 드래그하여 미리 보고 놓으세요. 선택이 없으면 칠하기를 시작합니다.",
"render.browse_looks": "찾아보기…",
"render.choose_look": "먼저 외관 라이브러리에서 외관을 선택하세요",
"render.hover_look": "{object} · {look} · 클릭하여 선택",
"render.look_applied": "{name} 적용됨",
"render.look_material": "재질",
"render.look_pasted": "외관 붙여넣음",
"render.painting_with": "{name}(으)로 칠하는 중",
"render.pick_up_prompt": "외관을 가져올 객체를 클릭하세요",
"render.recent_looks": "최근 외관",
"render.remove_override": "재정의 제거",
"render.replace_all": "모두 바꾸기",
"render.replace_with": "바꿀 외관"
},
"ar": {
"cmd.appearance_copy": "نسخ المظهر",
"cmd.appearance_favorite": "مظهر مفضّل",
"cmd.appearance_paste": "لصق المظهر",
"render.assign_help": "انقر للتطبيق على التحديد؛ اسحب للمعاينة ثم أفلت، أو انقر دون تحديد للتلوين.",
"render.browse_looks": "استعراض…",
"render.choose_look": "اختر مظهرًا من المظاهر أولًا",
"render.hover_look": "{object} · {look} · انقر للتحديد",
"render.look_applied": "تم تطبيق {name}",
"render.look_material": "المادة",
"render.look_pasted": "تم لصق المظهر",
"render.painting_with": "التلوين بـ {name}",
"render.pick_up_prompt": "انقر كائنًا لالتقاط مظهره",
"render.recent_looks": "المظاهر الأخيرة",
"render.remove_override": "إزالة التجاوز",
"render.replace_all": "استبدال الكل",
"render.replace_with": "استبدال بـ"
},
"he": {
"cmd.appearance_copy": "העתק מראה",
"cmd.appearance_favorite": "מראה מועדף",
"cmd.appearance_paste": "הדבק מראה",
"render.assign_help": "לחץ כדי להחיל על הבחירה; גרור לתצוגה מקדימה ושחרר, או לחץ ללא בחירה כדי לצבוע.",
"render.browse_looks": "עיון…",
"render.choose_look": "בחר קודם מראה במראות",
"render.hover_look": "{object} · {look} · לחץ לבחירה",
"render.look_applied": "{name} הוחל",
"render.look_material": "חומר",
"render.look_pasted": "המראה הודבק",
"render.painting_with": "צובעים עם {name}",
"render.pick_up_prompt": "לחץ על אובייקט כדי לבחור את המראה שלו",
"render.recent_looks": "מראות אחרונים",
"render.remove_override": "הסר עקיפה",
"render.replace_all": "החלף הכול",
"render.replace_with": "החלף ב"
},
"vi": {
"cmd.appearance_copy": "Sao chép diện mạo",
"cmd.appearance_favorite": "Diện mạo yêu thích",
"cmd.appearance_paste": "Dán diện mạo",
"render.assign_help": "Bấm để áp dụng cho vùng chọn; kéo để xem trước rồi thả, hoặc bấm khi chưa chọn gì để tô.",
"render.browse_looks": "Duyệt…",
"render.choose_look": "Hãy chọn một diện mạo trong Diện mạo trước",
"render.hover_look": "{object} · {look} · bấm để chọn",
"render.look_applied": "Đã áp dụng {name}",
"render.look_material": "Vật liệu",
"render.look_pasted": "Đã dán diện mạo",
"render.painting_with": "Đang tô bằng {name}",
"render.pick_up_prompt": "Bấm vào một đối tượng để lấy diện mạo của nó",
"render.recent_looks": "Diện mạo gần đây",
"render.remove_override": "Gỡ ghi đè",
"render.replace_all": "Thay tất cả",
"render.replace_with": "Thay bằng"
}
}
@@ -0,0 +1,77 @@
//! Assignment tools share the existing look parser, chain and atomic assignment command.
use crate::{Result, Session, params::bad};
use serde_json::{Value, json};
use solidcraft_doc::appearance::Look;
pub(in crate::cmd) fn paint(_s: &mut Session, p: &Value) -> Result<Value> {
if p.get("done").and_then(Value::as_bool) == Some(true) {
return Ok(json!({"done":true}));
}
let look = super::appearance_look("appearance.paint", p)?.ok_or_else(|| bad("appearance.paint", "choose a look first"))?;
Ok(json!({"look": super::look_json(&look)}))
}
fn resolved(s: &mut Session, p: &Value) -> Result<Look> {
let chain = super::appearance_chain::chain(s, p)?;
let value = chain["steps"].as_array().and_then(|steps| steps.iter().find(|step| !step["look"].is_null())).map(|step| step["look"].clone());
if let Some(value) = value {
return super::appearance_look("appearance.pick_up", &value)?.ok_or_else(|| bad("appearance.pick_up", "target has no look"));
}
// Imported paint is outside the four-step chain, but remains the display fallback.
let state = s.model.state();
let body = p
.pointer("/target/body")
.and_then(Value::as_str)
.and_then(|name| state.body(name))
.ok_or_else(|| bad("appearance.pick_up", "target has no resolved look"))?;
let displayed = p
.pointer("/target/face")
.and_then(Value::as_u64)
.and_then(|face| usize::try_from(face).ok())
.map_or_else(|| s.doc.model_display_look(body), |face| s.doc.model_face_look(body, face));
if let Some(look) = displayed {
return Ok(look);
}
let fallback = crate::view::colors::BODY;
let color =
s.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([fallback.0, fallback.1, fallback.2]);
Ok(Look { name: "Default".into(), color, opacity: 1.0, pbr: None })
}
pub(in crate::cmd) fn pick_up(s: &mut Session, p: &Value) -> Result<Value> {
if p.as_object().is_some_and(|p| p.is_empty()) {
return Ok(json!({"tool":"pick_up"}));
}
Ok(json!({"look":super::look_json(&resolved(s, p)?)}))
}
pub(in crate::cmd) fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let look = resolved(s, p)?;
let reply = json!({"look":super::look_json(&look)});
s.appearance_clipboard = Some(look);
Ok(reply)
}
pub(in crate::cmd) fn favorite(_s: &mut Session, p: &Value) -> Result<Value> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
look: Value,
favorite: bool,
}
let request: Request = serde_json::from_value(p.clone()).map_err(|e| bad("appearance.favorite", e.to_string()))?;
let look = super::appearance_look("appearance.favorite", &request.look)?.ok_or_else(|| bad("appearance.favorite", "give a look"))?;
Ok(json!({"look":super::look_json(&look), "favorite":request.favorite}))
}
pub(in crate::cmd) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let look = s.appearance_clipboard.as_ref().ok_or_else(|| bad("appearance.paste", "copy a look first"))?;
let mut params = p.as_object().cloned().ok_or_else(|| bad("appearance.paste", "give assignment targets"))?;
for key in ["appearance", "name", "color", "opacity", "pbr"] {
params.remove(key);
}
for (key, value) in super::look_json(look).as_object().into_iter().flatten() {
params.insert(key.clone(), value.clone());
}
super::appearance(s, &Value::Object(params))
}
+9
View File
@@ -15,6 +15,9 @@ use crate::{EngineError, Result, Session};
#[path = "appearance_chain.rs"]
mod appearance_chain;
#[path = "appearance_gestures.rs"]
pub(super) mod appearance_gestures;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
@@ -49,6 +52,12 @@ pub static COMMANDS: &[CommandSpec] = &[
appearance?: library name or stable starter.* ID (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
name?: inert copied look label (requires color, mutually exclusive with appearance); pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); a starter ID or registered starter name resolves its recipe before color overrides; omitted pbr retains that response and pbr: null explicitly clears it. Unavailable glass recipes refuse through either selector. Other custom labels with an explicit color retain legacy scalar behavior. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
),
CommandSpec::new("appearance.copy", "Copy Look", appearance_gestures::copy).icon("copy").noundo().key("Ctrl+Alt+C")
.params("target: appearance.chain target. Copies its resolved look to a transient session clipboard. No document change or dialogs."),
CommandSpec::new("appearance.paste", "Paste Look", appearance_gestures::paste).icon("paste").key("Ctrl+Alt+V")
.params("bodies/faces/components: appearance.assign targets. Applies the copied look atomically in one undo step; no dialogs."),
CommandSpec::new("appearance.favorite", "Favourite Look", appearance_gestures::favorite).icon("star").noundo().doc_only()
.params("look: appearance.assign look; favorite: bool → validated look and preference request; UI library preference only, no document change or dialogs."),
CommandSpec::new("appearance.chain", "Appearance Chain", appearance_chain::chain).icon("inspect").noundo().params(
"target: {body: name} | {body: name, face: zero-based model.faces index} | {component: name or id}. Returns steps [{step: face|body|component|material, look: {name,color,opacity,pbr?} or null}] in precedence order and active (step or null). Component material is the common default look of its directly owned bodies, null if mixed/empty. Imported paint is a display fallback outside the four steps. Read-only; no dialogs",
),
@@ -878,3 +878,70 @@ fn appearance_chain_and_replace_process_ten_thousand_entries_with_bounded_output
0
);
}
#[test]
fn appearance_gestures_copy_paste_preserve_full_resolved_look_and_history() {
let mut s = fixture();
s.execute(
"appearance.assign",
&json!({"body":"Specimen","faces":[[5,10,5]],"name":"Sampled","color":"#345678","pbr":{"metallic":0.2,"roughness":0.3,"dielectric_ior":1.6}}),
)
.unwrap();
let face = s.execute("model.faces", &json!({"body":"Specimen"})).unwrap()["faces"]
.as_array()
.unwrap()
.iter()
.find(|f| f["point"][2].as_f64() == Some(5.0))
.unwrap()["index"]
.clone();
let before = s.doc.clone();
let undo = s.undo.len();
let target = json!({"body":"Specimen","face":face});
let sampled = s.execute("appearance.pick_up", &json!({"target":target})).unwrap();
assert_eq!(sampled["look"]["name"], "Sampled");
assert_eq!(s.execute("appearance.copy", &json!({"target":target})).unwrap(), sampled);
s.execute("appearance.paint", &sampled["look"]).unwrap();
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(s.undo.len(), undo);
s.execute("appearance.paste", &json!({"bodies":["Specimen"]})).unwrap();
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(serde_json::to_value(&s.doc.appearances.bodies["Specimen"]).unwrap()["pbr"], sampled["look"]["pbr"]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.to_json(), before.to_json());
let document = s.doc.clone();
let history = (s.undo.len(), s.redo.len());
for (id, params) in [
("appearance.pick_up", json!({"target":{"body":"missing"}})),
("appearance.copy", json!({"target":{"body":"Specimen","face":999999}})),
("appearance.paste", json!({"bodies":["Specimen","missing"]})),
("appearance.paint", json!({"appearance":"starter.glass.clear-soda-lime"})),
] {
assert!(s.execute(id, &params).is_err(), "{id}");
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!((s.undo.len(), s.redo.len()), history);
}
}
#[test]
fn appearance_gestures_favorite_is_a_validated_transient_request() {
let mut s = fixture();
let before = s.doc.clone();
let state = (s.undo.len(), s.redo.len(), s.revision, s.evaluations, s.is_dirty());
for favorite in [true, false] {
let reply = s.execute("appearance.favorite", &json!({"look":{"appearance":"Copper"},"favorite":favorite})).unwrap();
assert_eq!(reply["look"]["name"], "Copper");
assert_eq!(reply["favorite"], favorite);
}
for params in [
json!(null),
json!([]),
json!({}),
json!({"look":{},"favorite":true}),
json!({"look":{"color":"#123456"},"favorite":"true"}),
json!({"look":{"color":"#123456"},"favorite":true,"extra":1}),
] {
assert!(s.execute("appearance.favorite", &params).is_err());
}
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!((s.undo.len(), s.redo.len(), s.revision, s.evaluations, s.is_dirty()), state);
}
+7 -2
View File
@@ -35,8 +35,13 @@ pub static COMMANDS: &[CommandSpec] = &[
.doc_only()
.params("No parameters. Return to Model Preview; headless echo only, never cancels a job."),
waiting!("render.lights.add", "Add Light", "RENDER SCENE", "LIGHTS", "render_scene", "named light creation"),
waiting!("appearance.paint", "Paint Appearance", "RENDER APPEARANCE", "APPEARANCE", "appearance", "product picking and appearance painting"),
waiting!("appearance.pick_up", "Pick Up Appearance", "RENDER APPEARANCE", "APPEARANCE", "select", "product picking and appearance sampling"),
CommandSpec::new("appearance.paint", "Paint Appearance", super::inspect::appearance_gestures::paint)
.icon("appearance").key("B").noundo()
.params("appearance or color/name/opacity/pbr: appearance.assign look parameters; validates and returns look for ui.start paint. done?: true ends the UI tool. Programmatic calls never open UI."),
CommandSpec::new("appearance.pick_up", "Pick Up Appearance", super::inspect::appearance_gestures::pick_up)
.icon("select").key("I").noundo()
.params("target?: appearance.chain target; returns its resolved look, or requests the UI sampling tool when omitted. Programmatic calls never open UI."),
waiting!("render.lights.solo", "Solo Light", "RENDER SCENE", "LIGHTS", "render_scene", "light visibility and solo controls"),
waiting!("render.compare", "Compare Images", "RENDER", "RESULT", "compare", "retained image comparison"),
waiting!("render.batch", "Render All Shots", "RENDER", "BATCH", "render_image", "the saved-shot render queue"),
+4
View File
@@ -403,6 +403,8 @@ pub struct Session {
pub clipboard: Vec<solidcraft_doc::Feature>,
/// Sketch geometry copied with `sketch.copy` (pasted by `sketch.paste`).
pub sketch_clipboard: Option<cmd::sketch_clipboard::SketchClip>,
/// Resolved appearance copied by appearance.copy; transient, outside design/history.
pub appearance_clipboard: Option<solidcraft_doc::appearance::Look>,
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
pub section: Option<(Vec3, Vec3)>,
/// Whether the section's cut faces are hatched (`inspect.section`'s `hatch`; on by default).
@@ -585,6 +587,7 @@ impl Session {
contact_meshes: Default::default(),
clipboard: Vec::new(),
sketch_clipboard: None,
appearance_clipboard: None,
section: None,
section_hatch: true,
colored_components: None,
@@ -1425,6 +1428,7 @@ impl Session {
contact_meshes: self.contact_meshes.clone(),
clipboard: self.clipboard.clone(),
sketch_clipboard: self.sketch_clipboard.clone(),
appearance_clipboard: self.appearance_clipboard.clone(),
section: None,
section_hatch: true,
colored_components: None,
+8 -5
View File
@@ -369,11 +369,14 @@ impl Session {
}
if let Some(cache) = cache.as_mut() {
if !cache.bodies.contains_key(&key) && cache.bodies.len() >= OCCURRENCE_BODIES_CACHED {
// Retire one remembered answer, not the whole cache: a burst of other
// paths (a Browser reveal, a hover) no longer evicts the active
// occurrence's entry and forces a full re-expansion on the next
// highlight build (#7502).
if let Some(evict) = cache.bodies.keys().next().cloned() {
// Retire the deepest path first, preferring direct-body answers over
// subtree answers at equal depth, then the greatest full key for a
// stable tie break. Broader assembly listings survive bursts of per-copy
// queries (#7502). Selecting a victim scans at most 256 cached keys,
// avoiding extra eviction bookkeeping while keeping the cache bounded.
if let Some(evict) =
cache.bodies.keys().max_by(|a, b| a.0.len().cmp(&b.0.len()).then_with(|| b.1.cmp(&a.1)).then_with(|| a.cmp(b))).cloned()
{
cache.bodies.remove(&evict);
}
}
+9 -1
View File
@@ -131,7 +131,7 @@ impl Item {
swatch: None,
}
}
fn heading(label: &str) -> Item {
pub(crate) fn heading(label: &str) -> Item {
Item {
id: format!("#{label}"),
label: label.into(),
@@ -439,6 +439,11 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
);
}
}
if crate::render_workspace::active(app)
&& let Target::Body { name } = target
{
v.push(crate::render_workspace::assign::menu(app, name));
}
// Several items of the folder selected: they can be grouped.
if let Some(p) = crate::browser::selection_group(app, target) {
v.push(Item::sep());
@@ -1230,6 +1235,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
let p = item.params.clone().unwrap_or(Value::Null);
let bodies = strs(&p, "bodies");
match item.id.as_str() {
"appearance.library" if crate::render_workspace::active(app) => {
app.start("appearance.library");
}
"parts.resize" => {
if let Some(component) = id_of(&p, "component") {
crate::dialogs_part_size::open(app, component);
+2 -1
View File
@@ -329,11 +329,12 @@ enum Scope {
}
fn scope(id: &str) -> Scope {
if id.starts_with("render.") {
if id.starts_with("render.") || matches!(id, "appearance.paint" | "appearance.pick_up") {
return Scope::Render;
}
match solidcraft_engine::find_command(id).map(|c| c.tab) {
Some("SKETCH") => Scope::Sketch,
Some(tab) if tab.starts_with("RENDER") => Scope::Render,
Some("") | None => Scope::Everywhere,
Some(_) => Scope::Model,
}
+14
View File
@@ -1042,6 +1042,11 @@ impl SolveApp {
Ok(reply)
});
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
// An Undo offered by the last toast is stale once another command changed the design.
if self.status_undo && revision != self.session.revision {
self.status_undo = false;
self.status = None;
}
let done = self.command_completed(id, r);
if self.session.revision != revision {
timeline::stop_playback(self);
@@ -1104,6 +1109,9 @@ impl SolveApp {
if id == "view.render_browser" {
render_workspace::apply_browser_controls(self, v);
}
if id == "appearance.favorite" {
render_workspace::assign::favorite_applied(self, v);
}
if id == "view.render_select" {
use render_workspace::RenderSelection;
let selected = match v.get("kind").and_then(Value::as_str) {
@@ -1224,6 +1232,12 @@ impl SolveApp {
sketch_palette::apply_mode(self, id, v);
}
self.finish_command(id, &r);
if render_workspace::active(self)
&& matches!(id, "appearance.assign" | "appearance.paste")
&& let Ok(value) = &r
{
render_workspace::assign::assigned(self, value);
}
r
}
+657
View File
@@ -0,0 +1,657 @@
//! Render-only gesture state. Preview changes the captured live scene, never the document.
use crate::{SolveApp, ui_kit, viewport::Hit};
use egui::Modifiers;
use serde_json::{Value, json};
use solidcraft_engine::{
Sel,
doc::appearance::Look,
render::{FaceAppearance, ProductScene},
};
use std::hash::{Hash, Hasher};
#[derive(Clone, Debug, Default)]
pub(crate) struct State {
pub look: Option<Value>,
pub painting: bool,
pub sampling: bool,
drag: Option<(Value, u64, usize)>,
preview: Option<Preview>,
hover: Option<Sel>,
recent: Vec<Value>,
context: Option<Sel>,
replacement: Option<Replacement>,
replacement_index: usize,
}
#[derive(Clone, Debug)]
struct Replacement {
from: Value,
document: std::sync::Weak<solidcraft_engine::doc::Document>,
revision: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct Preview {
look: Value,
targets: Value,
faces: Vec<(String, usize)>,
bodies: Vec<String>,
}
fn component(app: &SolveApp, sel: &Sel) -> Option<u64> {
match sel {
Sel::Occurrence { path } => app.session.placed_document().path_placement(path).map(|(id, _)| id).filter(|id| *id != 0),
Sel::Body { name } | Sel::Face { body: name, .. } | Sel::Edge { body: name, .. } => {
let state = app.session.world_state();
state.body(name).map(|body| app.session.doc.model_body_component(body)).filter(|id| *id != 0)
}
_ => None,
}
}
/// Build all targets first; the engine applies them atomically with one history entry.
pub(crate) fn targets(app: &SolveApp, selected: &[Sel]) -> Result<Value, String> {
let mut bodies = std::collections::BTreeSet::new();
let mut components = std::collections::BTreeSet::new();
let mut faces = Vec::new();
for sel in selected {
if app.ui.pick_components
&& let Some(id) = component(app, sel)
{
components.insert(id.to_string());
continue;
}
match sel {
Sel::Face { body, point, .. } if !app.ui.pick_bodies && !app.ui.pick_components => {
faces.push(json!({"body":body,"point":point}));
}
Sel::Body { name } => {
bodies.insert(name.clone());
}
Sel::Face { body, .. } | Sel::Edge { body, .. } => {
bodies.insert(body.clone());
}
Sel::Occurrence { path } => {
bodies.extend(app.session.occurrence_bodies(path, true).map_err(|e| e.to_string())?);
}
_ => return Err("selection has no appearance target".into()),
}
}
if bodies.is_empty() && components.is_empty() && faces.is_empty() {
return Err("pick an object first".into());
}
Ok(json!({"bodies":bodies,"components":components,"faces":faces}))
}
fn chain_target(app: &SolveApp, sel: &Sel) -> Result<Value, String> {
match sel {
Sel::Face { body, index, .. } => Ok(json!({"body":body,"face":index})),
Sel::Body { name } | Sel::Edge { body: name, .. } => Ok(json!({"body":name})),
Sel::Occurrence { .. } => component(app, sel).map(|id| json!({"component":id})).ok_or_else(|| "no component at target".into()),
_ => Err("pick an object first".into()),
}
}
fn remember(app: &mut SolveApp, look: Value) {
let recent = &mut app.render_workspace.view.assign.recent;
recent.retain(|l| l != &look);
recent.insert(0, look);
recent.truncate(6);
}
fn apply(app: &mut SolveApp, look: &Value, targets: Value) -> Result<(), String> {
let mut params = look.clone();
let object = params.as_object_mut().ok_or("invalid look")?;
for (key, value) in targets.as_object().into_iter().flatten() {
object.insert(key.clone(), value.clone());
}
app.run("appearance.assign", params)?;
Ok(())
}
pub(crate) fn arm(app: &mut SolveApp, look: Value) -> Result<(), String> {
let look = app.run("appearance.paint", look)?["look"].clone();
app.tool = None;
app.render_workspace.view.result = false;
app.render_workspace.view.assign.look = Some(look);
app.render_workspace.view.assign.painting = true;
app.render_workspace.view.assign.sampling = false;
Ok(())
}
pub(crate) fn tile(app: &mut SolveApp, look: Value, drag: bool) -> Result<(), String> {
let look = app.run("appearance.paint", look)?["look"].clone();
app.render_workspace.view.assign.look = Some(look.clone());
if drag {
let identity = std::sync::Arc::as_ptr(&app.session.doc) as usize;
app.render_workspace.view.assign.drag = Some((look, app.session.revision, identity));
app.render_workspace.view.assign.painting = false;
app.render_workspace.view.assign.sampling = false;
Ok(())
} else if app.session.selection.is_empty() {
arm(app, look)
} else {
apply(app, &look, targets(app, &app.session.selection)?)
}
}
pub(crate) fn stop(app: &mut SolveApp) {
let state = &mut app.render_workspace.view.assign;
state.painting = false;
state.sampling = false;
state.drag = None;
state.preview = None;
state.hover = None;
state.replacement = None;
}
/// The tool owns the first Escape before the shell's ordinary selection clearing.
pub(crate) fn cancel(app: &mut SolveApp) -> bool {
let state = &app.render_workspace.view.assign;
if !super::active(app) || !(state.painting || state.sampling || state.drag.is_some()) {
return false;
}
let _ = app.run("appearance.paint", json!({"done":true}));
stop(app);
true
}
pub(crate) fn start(app: &mut SolveApp, id: &str) -> bool {
if id == "appearance.library" && super::active(app) {
super::library::focus_library(app);
return true;
}
if !matches!(
id,
"appearance.paint"
| "appearance.pick_up"
| "appearance.copy"
| "appearance.paste"
| "appearance.chain"
| "appearance.replace"
| "appearance.favorite"
) {
return false;
}
let result = (|| {
if !super::active(app) {
super::enter(app, true)?;
}
if id == "appearance.paint" {
let look = app.render_workspace.view.assign.look.clone().ok_or_else(|| app.tr("render.choose_look").to_string())?;
arm(app, look)
} else if id == "appearance.pick_up" {
app.run(id, json!({}))?;
stop(app);
app.tool = None;
app.render_workspace.view.assign.sampling = true;
Ok(())
} else if id == "appearance.copy" {
let sel = app.session.selection.last().ok_or("pick an object first")?;
let target = chain_target(app, sel)?;
app.run(id, json!({"target":target}))?;
Ok(())
} else if id == "appearance.paste" {
let target = targets(app, &app.session.selection)?;
app.run(id, target)?;
Ok(())
} else {
let sel = app.session.selection.last().ok_or("pick an object first")?;
let target = chain_target(app, sel)?;
if id == "appearance.chain" {
app.run(id, json!({"target":target}))?;
app.run("view.render_stage", json!({"stage":"look"}))?;
} else {
let look = app.run("appearance.pick_up", json!({"target":target}))?["look"].clone();
if id == "appearance.replace" {
app.render_workspace.view.assign.replacement =
Some(Replacement { from: look, document: std::sync::Arc::downgrade(&app.session.doc), revision: app.session.revision });
app.render_workspace.view.assign.replacement_index = 0;
} else {
let favorite = !is_favorite(app, &look);
app.run(id, json!({"look":look, "favorite":favorite}))?;
}
}
Ok(())
}
})();
if let Err(error) = result {
app.set_status(error, true);
}
true
}
pub(crate) fn candidate(app: &SolveApp, hits: &[Hit], mods: Modifiers) -> Option<(Hit, Sel)> {
if app.render_workspace.view.assign.drag.is_some() {
let hit = hits.iter().find(|h| matches!(h, Hit::Face { .. }))?;
if let Hit::Face { body, index, point } = hit {
let sel = if mods.shift {
Sel::Face { body: body.clone(), index: *index, point: *point }
} else if mods.ctrl || mods.command {
crate::viewport::component_selection(app, body)
} else {
return crate::viewport::candidate(app, std::slice::from_ref(hit));
};
return Some((hit.clone(), sel));
}
}
crate::viewport::candidate(app, hits)
}
fn drop_preview(app: &SolveApp, look: Value, hit: &Hit, sel: &Sel, mods: Modifiers) -> Result<Preview, String> {
let Hit::Face { body, index, point } = hit else {
return Err("no face under cursor".into());
};
let face = mods.shift || (!mods.ctrl && !mods.command && !app.ui.pick_bodies && !app.ui.pick_components);
if face {
return Ok(Preview {
look,
targets: json!({"faces":[{"body":body,"point":point}]}),
faces: vec![(body.clone(), *index)],
bodies: Vec::new(),
});
}
let whole_component = mods.ctrl || mods.command || app.ui.pick_components;
if whole_component && let Some(id) = component(app, sel) {
let bodies = app
.session
.world_state()
.bodies
.iter()
.filter(|b| {
app.session.doc.component_within(app.session.doc.model_body_component(b), id)
&& !app.session.doc.appearances.bodies.contains_key(b.definition_name())
})
.map(|b| b.name.clone())
.collect();
return Ok(Preview { look, targets: json!({"components":[id.to_string()]}), faces: Vec::new(), bodies });
}
Ok(Preview { look, targets: json!({"bodies":[body]}), faces: Vec::new(), bodies: vec![body.clone()] })
}
fn click(app: &mut SolveApp, candidate: Option<&(Hit, Sel)>) -> Result<(), String> {
let Some((hit, sel)) = candidate else {
return Ok(());
};
if app.render_workspace.view.assign.sampling {
// Sample the actual visible face even when the Pick size is Body.
let source = match hit {
Hit::Face { body, index, .. } => json!({"body":body,"face":index}),
_ => chain_target(app, sel)?,
};
let reply = app.run("appearance.pick_up", json!({"target":source}))?;
return arm(app, reply["look"].clone());
}
if app.render_workspace.view.assign.painting
&& let Some(look) = app.render_workspace.view.assign.look.clone()
{
let target = drop_preview(app, look.clone(), hit, sel, Modifiers::NONE)?;
apply(app, &look, target.targets)?;
}
Ok(())
}
pub(crate) fn input(app: &mut SolveApp, ui: &mut egui::Ui, response: &egui::Response, candidate: Option<&(Hit, Sel)>, inside: bool) -> bool {
let active = {
let s = &app.render_workspace.view.assign;
s.painting || s.sampling || s.drag.is_some()
};
if !active {
return false;
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
let _ = app.run("appearance.paint", json!({"done":true}));
stop(app);
app.esc_handled = true;
return true;
}
if let Some((look, revision, identity)) = app.render_workspace.view.assign.drag.clone() {
if revision != app.session.revision
|| identity != std::sync::Arc::as_ptr(&app.session.doc) as usize
|| ui.input(|i| !i.pointer.primary_down() && !i.pointer.button_released(egui::PointerButton::Primary))
{
stop(app);
return true;
}
let mods = ui.input(|i| i.modifiers);
app.render_workspace.view.assign.hover = candidate.filter(|_| inside).map(|(_, sel)| sel.clone());
app.render_workspace.view.assign.preview = candidate.filter(|_| inside).and_then(|(hit, sel)| drop_preview(app, look, hit, sel, mods).ok());
if ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)) {
let preview = app.render_workspace.view.assign.preview.take();
app.render_workspace.view.assign.drag = None;
app.render_workspace.view.assign.hover = None;
if let Some(preview) = preview
&& let Err(error) = apply(app, &preview.look, preview.targets)
{
app.set_status(error, true);
}
}
} else if inside
&& response.clicked_by(egui::PointerButton::Primary)
&& let Err(error) = click(app, candidate)
{
app.set_status(error, true);
}
let state = &app.render_workspace.view.assign;
if (state.painting || state.drag.is_some())
&& let Some(look) = &state.look
{
ui_kit::sphere_cursor(ui, super::library::tile_sphere(look));
}
true
}
pub(crate) fn banner(app: &mut SolveApp, ui: &mut egui::Ui) {
let s = &app.render_workspace.view.assign;
if !s.painting && !s.sampling {
return;
}
let text = if s.sampling {
app.tr("render.pick_up_prompt").to_string()
} else {
crate::i18n::format("render.painting_with", &[("name", s.look.as_ref().and_then(|l| l["name"].as_str()).unwrap_or(""))])
};
ui_kit::text(ui, &text, ui_kit::Text::Inline);
if ui_kit::button(ui, app.tr_label("Done"), ui_kit::Button::Normal).clicked() {
let _ = app.run("appearance.paint", json!({"done":true}));
stop(app);
}
}
pub(crate) fn preview(app: &SolveApp) -> Option<Preview> {
app.render_workspace.view.assign.preview.clone()
}
pub(crate) fn preview_key(app: &SolveApp) -> u64 {
let mut hash = std::collections::hash_map::DefaultHasher::new();
if let Some(p) = &app.render_workspace.view.assign.preview {
p.look.to_string().hash(&mut hash);
p.targets.to_string().hash(&mut hash);
}
hash.finish()
}
pub(crate) fn preview_scene(scene: &mut ProductScene, preview: Option<&Preview>) -> Result<(), String> {
let Some(preview) = preview else {
return Ok(());
};
let color = crate::dialogs_appearance::parse_color(&preview.look["color"]).ok_or("invalid preview look")?;
let look = Look {
name: preview.look["name"].as_str().unwrap_or("").into(),
color,
opacity: preview.look["opacity"].as_f64().unwrap_or(1.0),
pbr: preview.look.get("pbr").filter(|p| !p.is_null()).map(|p| serde_json::from_value(p.clone())).transpose().map_err(|e| e.to_string())?,
};
let surface = look.opaque_surface().map_err(|e| e.to_string())?;
for mesh in &mut scene.meshes {
if preview.bodies.contains(&mesh.name) {
mesh.color = look.color;
mesh.opacity = look.opacity;
mesh.surface = surface;
}
for (_, face) in preview.faces.iter().filter(|(name, _)| name == &mesh.name) {
let face = u32::try_from(*face).map_err(|_| "invalid preview face")?;
mesh.faces.retain(|f| f.face != face);
mesh.faces.push(FaceAppearance { face, color: look.color, opacity: look.opacity, surface, transmission: None });
}
}
Ok(())
}
pub(crate) fn menu(app: &SolveApp, body: &str) -> crate::context_menu::Item {
use crate::context_menu::{Item, cmd};
let selected_face = app
.render_workspace
.view
.assign
.context
.as_ref()
.filter(|s| matches!(s, Sel::Face {body:name,..} if name == body))
.or_else(|| app.session.selection.iter().find(|s| matches!(s,Sel::Face {body:name,..} if name == body)));
let sel = selected_face.cloned().unwrap_or(Sel::Body { name: body.into() });
let targets = targets(app, std::slice::from_ref(&sel)).unwrap_or_else(|_| json!({"bodies":[body]}));
let mut menu = Item::action("appearance.assign", app.tr("cmd.appearance_assign"), "");
menu.children.push(Item::heading(app.tr("render.recent_looks")));
for look in &app.render_workspace.view.assign.recent {
let mut params = look.clone();
if let Some(object) = params.as_object_mut() {
for (k, v) in targets.as_object().into_iter().flatten() {
object.insert(k.clone(), v.clone());
}
}
menu.children.push(cmd(app, "appearance.assign", look["name"].as_str().unwrap_or("")).with(params));
}
menu.children.push(Item::sep());
menu.children.push(cmd(app, "appearance.library", app.tr("render.browse_looks")));
menu.children.push(cmd(app, "appearance.assign", app.tr("cmd.appearance_assign")));
menu.children.push(cmd(app, "appearance.chain", app.tr("cmd.appearance_chain")));
menu.children.push(cmd(app, "appearance.replace", app.tr("render.replace_all")));
menu.children.push(cmd(app, "appearance.pick_up", app.tr("cmd.appearance_pick_up")));
if let Ok(target) = chain_target(app, &sel) {
menu.children.push(cmd(app, "appearance.copy", app.tr("cmd.appearance_copy")).with(json!({"target":target})));
}
menu.children
.push(cmd(app, "appearance.paste", app.tr("cmd.appearance_paste")).with(targets.clone()).on(app.session.appearance_clipboard.is_some()));
menu.children.push(cmd(app, "appearance.assign", app.tr("render.remove_override")).with(targets));
menu
}
/// Reuse the viewport's shared hover drawing, including modifier-specific drop size.
pub(crate) fn hover(app: &SolveApp) -> Option<&Sel> {
app.render_workspace.view.assign.hover.as_ref()
}
pub(crate) fn context(app: &mut SolveApp, candidate: &(Hit, Sel)) {
app.render_workspace.view.assign.context = Some(match &candidate.0 {
Hit::Face { body, index, point } => Sel::Face { body: body.clone(), index: *index, point: *point },
_ => candidate.1.clone(),
});
}
pub(crate) fn assigned(app: &mut SolveApp, result: &Value) {
if let Some(look) = result.get("appearance").filter(|look| !look.is_null()) {
remember(app, look.clone());
app.set_status(crate::i18n::format("render.look_applied", &[("name", look["name"].as_str().unwrap_or(""))]), false);
app.status_undo = true;
}
}
fn favorite_key(look: &Value) -> (String, String) {
("appearances".into(), super::library::appearance_id(look))
}
fn is_favorite(app: &SolveApp, look: &Value) -> bool {
app.preferences.render_library_favorites.contains(&favorite_key(look))
}
pub(crate) fn favorite_applied(app: &mut SolveApp, reply: &Value) {
let key = favorite_key(&reply["look"]);
if reply["favorite"] == true {
if app.preferences.render_library_favorites.len() < 256 {
app.preferences.render_library_favorites.insert(key);
}
} else {
app.preferences.render_library_favorites.remove(&key);
}
super::library::invalidate(app);
}
fn selection_name(app: &SolveApp, sel: &Sel) -> String {
match sel {
Sel::Body { name } | Sel::Face { body: name, .. } | Sel::Edge { body: name, .. } => name.clone(),
Sel::Occurrence { path } => {
path.last().and_then(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id)).map(|o| o.name.clone()).unwrap_or_default()
}
_ => String::new(),
}
}
pub(crate) fn hover_tooltip(app: &mut SolveApp, response: &egui::Response, candidate: Option<&(Hit, Sel)>) {
let Some((_, sel)) = candidate else { return };
let Ok(target) = chain_target(app, sel) else { return };
let Ok(reply) = app.session.execute("appearance.pick_up", &json!({"target":target})) else { return };
let tip =
crate::i18n::format("render.hover_look", &[("object", &selection_name(app, sel)), ("look", reply["look"]["name"].as_str().unwrap_or(""))]);
ui_kit::Container::tooltip(response, &tip);
}
pub(crate) fn selection_bar(app: &mut SolveApp, ui: &mut egui::Ui, free: egui::Rect) {
crate::shell::register_panel(app, "render:selection", None, None);
if super::result_active(app) || app.render_workspace.view.stage != super::Stage::Look {
return;
}
let Some(sel) = app.session.selection.last().cloned() else { return };
let Ok(target) = chain_target(app, &sel) else { return };
let Some(bounds) = crate::viewport::selection_bounds(app) else { return };
let Some(viewport) = app.viewport.rect else { return };
let proj = crate::viewport::projection(app, viewport);
let mut screen = egui::Rect::NOTHING;
for x in [bounds.min.x, bounds.max.x] {
for y in [bounds.min.y, bounds.max.y] {
for z in [bounds.min.z, bounds.max.z] {
if let Some(p) = proj.to_screen(solidcraft_engine::geom::Vec3::new(x, y, z)).filter(|p| p.is_finite()) {
screen.extend_with(p);
}
}
}
}
if !screen.is_positive() || !screen.intersects(free) {
return;
}
let Ok(reply) = app.session.execute("appearance.pick_up", &json!({"target":target})) else { return };
let favorite = is_favorite(app, &reply["look"]);
let labels = [app.tr("cmd.appearance_chain"), app.tr("render.replace_all"), app.tr("cmd.appearance_favorite"), app.tr("ui.hide")];
let actions = ["chain", "reorder", "star", "suppress"]
.iter()
.zip(&labels)
.enumerate()
.map(|(index, (icon, label))| ui_kit::StageAction {
icon: Some(icon),
label,
enabled: index != 2 || favorite || app.preferences.render_library_favorites.len() < 256,
primary: index == 2 && favorite,
reason: None,
})
.collect::<Vec<_>>();
let anchor = egui::pos2(
screen.center().x.clamp(free.left(), free.right()),
(screen.top() - crate::theme::Tokens::get().space_12).clamp(free.top(), free.bottom()),
);
let ctx = ui.ctx().clone();
let pill = ui_kit::Container::selection_card(&ctx, egui::Id::new("render_selection"), anchor, free, |ui| {
ui_kit::selection_actions(ui, &selection_name(app, &sel), &actions)
});
crate::scenario::publish_rect("render:selection", pill.response.rect);
crate::shell::register_panel(app, "render:selection", Some(pill.response.rect), None);
match pill.inner {
Some(0) => app.start("appearance.chain"),
Some(1) => app.start("appearance.replace"),
Some(2) => app.start("appearance.favorite"),
Some(3) => {
let params = match &sel {
Sel::Occurrence { path } => json!({"paths":[path], "visible":false}),
_ => json!({"items":[selection_name(app, &sel)], "visible":false}),
};
let _ = app.run("browser.visibility", params);
}
Some(4) => {
let target = match &sel {
Sel::Occurrence { path } => crate::context_menu::Target::Occurrence { path: path.clone() },
_ => crate::context_menu::Target::Body { name: selection_name(app, &sel) },
};
crate::context_menu::open_for(app, pill.response.rect.left_bottom(), target);
}
_ => {}
}
}
/// Existing appearance commands exposed alongside the shared inheritance widget.
pub(crate) fn selection_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(sel) = app.session.selection.last().cloned() else { return };
let Ok(target) = chain_target(app, &sel) else { return };
if let Ok(chain) = app.session.execute("appearance.chain", &json!({"target":target})) {
let steps = chain["steps"].as_array().map(Vec::as_slice).unwrap_or_default();
let looks =
std::array::from_fn(|index| steps.get(index).and_then(|s| s.get("look")).filter(|l| !l.is_null()).map(super::library::tile_sphere));
let active = match chain["active"].as_str() {
Some("face") => ui_kit::LookStep::Face,
Some("body") => ui_kit::LookStep::Body,
Some("component") => ui_kit::LookStep::Component,
_ => ui_kit::LookStep::Material,
};
if ui_kit::look_chain(
ui,
"render:look_chain",
[app.tr("render.pick_face"), app.tr("render.pick_body"), app.tr("render.pick_component"), app.tr("render.look_material")],
looks,
active,
)
.is_some()
{
app.start("appearance.assign");
}
}
ui_kit::dropdown_menu(ui, "render_selection_actions", app.tr("cmd.appearance_assign"), |ui| {
for (id, label) in
[("appearance.assign", "cmd.appearance_assign"), ("appearance.replace", "render.replace_all"), ("appearance.copy", "cmd.appearance_copy")]
{
if let Some(command) = solidcraft_engine::find_command(id) {
let info = command.info(&app.session);
if ui_kit::command_menu_button(
ui,
id,
app.tr(label),
command.icon,
&crate::keymap::display_for(app, id).unwrap_or_default(),
info.enabled,
)
.clicked()
{
app.start(id);
ui.close();
}
}
}
if ui_kit::command_menu_button(ui, "render_remove_override", app.tr("render.remove_override"), "delete", "", true).clicked() {
if let Ok(targets) = targets(app, &app.session.selection) {
let _ = app.run("appearance.assign", targets);
}
ui.close();
}
});
}
pub(crate) fn replacement_dialog(app: &mut SolveApp, ctx: &egui::Context) {
let Some(pending) = app.render_workspace.view.assign.replacement.clone() else { return };
if pending.revision != app.session.revision || !pending.document.ptr_eq(&std::sync::Arc::downgrade(&app.session.doc)) {
app.render_workspace.view.assign.replacement = None;
return;
}
let from = pending.from;
let entries = app.session.execute("appearance.library", &json!({})).ok().and_then(|v| v["appearances"].as_array().cloned()).unwrap_or_default();
let entries: Vec<_> = entries.into_iter().filter(|entry| entry["available"] != false).collect();
let mut selected = app.render_workspace.view.assign.replacement_index;
let title = app.tr("render.replace_all").to_string();
let (closed, action) = ui_kit::Confirm::new(egui::Id::new("render_replace_look"), "reorder", &title)
.actions(&title, app.tr_label("Cancel"), None)
.show(ctx, !entries.is_empty(), |ui| {
ui_kit::text(ui, from["name"].as_str().unwrap_or(""), ui_kit::Text::Inline);
let names: Vec<_> = entries.iter().map(|entry| entry["name"].as_str().unwrap_or("")).collect();
ui_kit::choice_row(ui, app.tr("render.replace_with"), "render_replace_with", &mut selected, &names);
});
app.render_workspace.view.assign.replacement_index = selected;
if action == ui_kit::Action::Ok {
if let Some(to) = entries.get(selected) {
let to = to["id"].as_str().or_else(|| to["name"].as_str()).map(|name| json!(name));
if let Some(to) = to
&& let Err(error) = app.run("appearance.replace", json!({"from":from,"to":to}))
{
app.set_status(error, true);
}
}
app.render_workspace.view.assign.replacement = None;
} else if closed || action == ui_kit::Action::Cancel {
app.render_workspace.view.assign.replacement = None;
}
}
pub(crate) fn replacement_open(app: &SolveApp) -> bool {
app.render_workspace.view.assign.replacement.is_some()
}
+8
View File
@@ -113,6 +113,10 @@ fn stage_tools(app: &mut SolveApp, ui: &mut egui::Ui) {
if let Some(command) = solidcraft_engine::find_command(id) {
let info = crate::render_workspace::interactive_command_info(app, command);
let response = ui_kit::compact_icon_label_button(ui, app.tr(label), icon, info.enabled);
crate::scenario::publish_target(&format!("render:stage:{id}"), response.rect);
let hint = crate::keymap::display_for(app, id)
.map_or_else(|| app.tr_label(command.label).to_string(), |key| format!("{} ({key})", app.tr_label(command.label)));
let response = response.on_hover_text(hint);
if response.clicked() {
app.start(id);
}
@@ -121,6 +125,7 @@ fn stage_tools(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
}
crate::render_workspace::assign::banner(app, ui);
ui_kit::StageBar::separator(ui);
let note = ui_kit::StageBar::note(ui, app.tr("render.stage_preview_note")).on_hover_text(app.tr("render.preview_lighting_help"));
crate::scenario::publish_rect("render:preview_note", note.rect);
@@ -190,10 +195,13 @@ pub(crate) fn overlays(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::shell::register_panel(app, "render:progress-pill", None, None);
}
crate::render_workspace::environment_overlay(app, ui, viewport);
crate::render_workspace::assign::selection_bar(app, ui, free);
crate::render_workspace::assign::replacement_dialog(app, ui.ctx());
crate::navigation_bar_view::show(app, ui, viewport);
if !focused(app) {
shots_pill(app, ui, free);
}
crate::viewport::transient_messages(app, ui, viewport);
}
/// Shared kit chip at the Browser's remembered side, below the stage bar.
+30 -7
View File
@@ -39,6 +39,9 @@ mod inspector;
#[path = "render_workspace_library.rs"]
mod library;
#[path = "render_ui/assign.rs"]
pub(crate) mod assign;
#[path = "render_workspace_materials.rs"]
mod materials;
@@ -273,6 +276,7 @@ pub enum RenderSelection {
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
#[derive(Clone, Debug)]
pub struct ViewState {
pub(crate) assign: assign::State,
job_shot: Option<String>,
save_shot_name: Option<String>,
pub name: String,
@@ -335,6 +339,7 @@ impl ImageMethod {
impl Default for ViewState {
fn default() -> Self {
Self {
assign: assign::State::default(),
job_shot: None,
save_shot_name: None,
name: "Studio".into(),
@@ -583,6 +588,7 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
app.render_workspace.start_deferred_path_host();
} else {
assign::stop(app);
if let Some((camera, perspective)) = app.render_workspace.design_camera.take() {
app.cam = camera;
app.ui.up_axis = camera.up;
@@ -1783,6 +1789,7 @@ pub fn preview_size(executor: Option<&ExecutorDescriptor>, full: (u64, u64)) ->
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.render_workspace.view.result {
app.render_workspace.view.environment_ring = environment::RingView::default();
assign::stop(app);
show_result(app, ui);
return;
}
@@ -1807,14 +1814,26 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
.filter(|_| input.inside && !navigating)
.map(|p| crate::viewport::pick_cached(app, &crate::viewport::projection(app, rect), p))
.unwrap_or_default();
let candidate = crate::viewport::candidate(app, &hits);
let candidate = assign::candidate(app, &hits, input.mods);
app.viewport.hover = candidate.as_ref().map(|(hit, _)| hit.clone());
if response.clicked_by(egui::PointerButton::Primary) && input.inside && !navigating {
let assigning = assign::input(app, ui, &response, candidate.as_ref(), input.inside && !navigating);
if !assigning && input.inside && !navigating {
assign::hover_tooltip(app, &response, candidate.as_ref());
}
if !assigning && response.clicked_by(egui::PointerButton::Primary) && input.inside && !navigating {
let (add, toggle) = crate::tool_modifiers::selection_click(
crate::tool_modifiers::Held::from_modifiers(input.mods),
app.preferences.selection_shift_add_only,
);
crate::viewport::select(app, candidate.map(|(_, sel)| sel), add, toggle);
crate::viewport::select(app, candidate.as_ref().map(|(_, sel)| sel.clone()), add, toggle);
}
if response.secondary_clicked()
&& let Some((crate::viewport::Hit::Face { body, .. }, _)) = &candidate
{
if let Some(candidate) = &candidate {
assign::context(app, candidate);
}
crate::context_menu::open_for(app, input.hover.unwrap_or(rect.center()), crate::context_menu::Target::Body { name: body.clone() });
}
} else {
app.viewport.hover = None;
@@ -1854,9 +1873,13 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let mut visibility = std::collections::hash_map::DefaultHasher::new();
app.session.visibility.hidden_bodies.hash(&mut visibility);
app.session.visibility.hidden_occurrences.hash(&mut visibility);
let signature = (app.session.revision, Arc::as_ptr(&app.session.world_state()) as usize, visibility.finish());
app.render_workspace
.prepare_preview(signature, || solidcraft_engine::product_scene::extract(&app.session).map_err(|error| error.to_string()))?;
let signature = (app.session.revision, Arc::as_ptr(&app.session.world_state()) as usize, visibility.finish() ^ assign::preview_key(app));
let preview = assign::preview(app);
app.render_workspace.prepare_preview(signature, || {
let mut scene = solidcraft_engine::product_scene::extract(&app.session).map_err(|error| error.to_string())?;
assign::preview_scene(&mut scene, preview.as_ref())?;
Ok(scene)
})?;
let scene = app.render_workspace.scene.as_ref().ok_or(crate::i18n::tr("render.error_no_scene"))?;
request.features = scene.required_features();
solidcraft_engine::render::resolve_request(&[app.session.render_backend()], &request).map_err(|e| e.to_string())?;
@@ -2005,7 +2028,7 @@ pub(crate) fn interactive_command_info(app: &SolveApp, command: &solidcraft_engi
info
}
pub(crate) fn start_interactive(app: &mut SolveApp, id: &str) -> bool {
let handled = ribbon::start(app, id);
let handled = assign::start(app, id) || ribbon::start(app, id);
// Opening Appearance Advanced must still reach its atmosphere controls after leaving Wear.
if handled
&& id == "render.aging.describe"
@@ -564,6 +564,7 @@ fn shot_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
});
}
fn appearance_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
super::assign::selection_controls(app, ui);
if selection_appearance::controls(app, ui) {
return;
}
@@ -1253,6 +1254,7 @@ pub(super) fn confirmation_open(app: &SolveApp) -> bool {
|| app.render_workspace.view.save_shot_name.is_some()
|| materials::confirmation_open(app)
|| library::review_open(app)
|| super::assign::replacement_open(app)
}
pub(super) fn reveal(app: &mut SolveApp, section: &'static str) {
app.render_workspace.view.inspector.reveal = Some(section);
+67 -70
View File
@@ -371,7 +371,7 @@ fn choose_in_stage(app: &mut SolveApp, view: &mut View, kind: &str, id: &str) ->
fn look_signature(entry: &Value) -> Value {
json!([entry["name"], entry["color"], entry["opacity"], entry["pbr"]])
}
fn appearance_id(entry: &Value) -> String {
pub(super) fn appearance_id(entry: &Value) -> String {
entry["id"].as_str().map(str::to_owned).unwrap_or_else(|| look_signature(entry).to_string())
}
fn describe(app: &mut SolveApp, kind: &str, id: &str) -> Result<Value, String> {
@@ -947,38 +947,6 @@ fn applicable(entry: &Value, selected: &[Value], used: &[Value]) -> bool {
})
}
fn apply_look(app: &mut SolveApp, entry: &Value) -> Result<(), String> {
use solidcraft_engine::Sel;
let mut bodies = Vec::new();
let mut faces = Vec::new();
let mut components = Vec::new();
for sel in &app.session.selection {
match sel {
Sel::Body { name } => bodies.push(name.clone()),
Sel::Face { body, point, .. } => faces.push(json!({"body":body,"point":point})),
Sel::Occurrence { path } => {
if let Some(occurrence) = path.last().and_then(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id)) {
components.push(occurrence.component.to_string());
}
}
_ => {}
}
}
if bodies.is_empty() && faces.is_empty() && components.is_empty() {
return Ok(());
}
let mut params = if let Some(id) = entry["id"].as_str() {
json!({"appearance":id})
} else {
json!({"name":entry["name"],"color":entry["color"],"opacity":entry["opacity"],"pbr":entry["pbr"]})
};
params["bodies"] = json!(bodies);
params["faces"] = json!(faces);
params["components"] = json!(components);
app.run("appearance.assign", params)?;
Ok(())
}
fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
let mut view = std::mem::take(&mut app.render_workspace.view.library);
let document_changed =
@@ -1131,6 +1099,7 @@ fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
let identities: Vec<_> = entries.iter().map(|(_, id, _)| format!("render:library:{id}")).collect();
let layout = ui_kit::CatalogLayout::new(ui, "render:library:catalog", 0);
let mut selected = None;
let mut dragged = None;
// Keep each catalog/collection's independent scroll position.
ui.push_id((&view.kind, view.collection), |ui| {
layout.body(ui, |ui| {
@@ -1152,7 +1121,18 @@ fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
badge: Some(tile_badge(app, entry)),
})
.collect();
selected = ui_kit::sphere_grid(ui, &tiles);
if applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_applicable"), ui_kit::Text::Inline);
(selected, dragged) = ui_kit::sphere_grid_drag(ui, tiles.get(..applicable_count).unwrap_or_default());
}
if applicable_count < tiles.len() {
if applicable_count > 0 {
ui_kit::text(ui, app.tr("render.library_other"), ui_kit::Text::Inline);
}
let (other, other_drag) = ui_kit::sphere_grid_drag(ui, tiles.get(applicable_count..).unwrap_or_default());
selected = selected.or(other.map(|i| i + applicable_count));
dragged = dragged.or(other_drag.map(|i| i + applicable_count));
}
} else {
for (index, ((kind, id, entry), tip)) in entries.iter().zip(&tips).enumerate() {
if index == 0 && applicable_count > 0 {
@@ -1222,48 +1202,54 @@ fn browse_contents(app: &mut SolveApp, ui: &mut egui::Ui, stage_card: bool) {
});
}
}
if let Some((kind, id, entry)) = selected.and_then(|index| entries.get(index)) {
let result = if stage_card { choose_in_stage(app, &mut view, kind, id) } else { choose(app, &mut view, kind, id) }.and_then(|()| {
if !stage_card {
return Ok(());
}
if kind == "appearances" {
return apply_look(app, entry);
}
if kind == "selene_appearances" {
let bodies: Vec<_> = app
.session
.selection
.iter()
.filter_map(|selection| match selection {
solidcraft_engine::Sel::Body { name } => Some(name.clone()),
_ => None,
})
.collect();
if !bodies.is_empty() {
return review::open(
app,
"uniform",
entry["name"].as_str().unwrap_or(id).into(),
view.details.clone().unwrap_or_default(),
review::Apply::Command {
id: "render.library.appearance.apply".into(),
params: json!({"id":id,"variant":"uniform_opaque","bodies":bodies}),
},
);
for (index, drag) in [(selected, false), (dragged, true)] {
if let Some((kind, id, entry)) = index.and_then(|index| entries.get(index)) {
let result = if stage_card { choose_in_stage(app, &mut view, kind, id) } else { choose(app, &mut view, kind, id) }.and_then(|()| {
if !stage_card {
return Ok(());
}
if !app.session.selection.is_empty() {
return Err(app.tr("render.material_choose_body").to_string());
if kind == "appearances" {
if entry["available"] == false {
return Err(app.tr("render.library_blocked").to_string());
}
let params = if let Some(id) = entry["id"].as_str() { json!({"appearance":id}) } else { preview_parameters(entry) };
return super::assign::tile(app, params, drag);
}
if kind == "selene_appearances" && !drag {
let bodies: Vec<_> = app
.session
.selection
.iter()
.filter_map(|selection| match selection {
solidcraft_engine::Sel::Body { name } => Some(name.clone()),
_ => None,
})
.collect();
if !bodies.is_empty() {
return review::open(
app,
"uniform",
entry["name"].as_str().unwrap_or(id).into(),
view.details.clone().unwrap_or_default(),
review::Apply::Command {
id: "render.library.appearance.apply".into(),
params: json!({"id":id,"variant":"uniform_opaque","bodies":bodies}),
},
);
}
if !app.session.selection.is_empty() {
return Err(app.tr("render.material_choose_body").to_string());
}
}
Ok(())
});
if let Err(error) = result {
app.set_status(error, true);
}
Ok(())
});
if let Err(error) = result {
app.set_status(error, true);
}
}
if stage_card {
ui_kit::catalog_footer_with_tip(ui, app.tr("render.stage_layout.appearance_note"), app.tr("render.stage_panels.paint_pending"));
ui_kit::catalog_footer_with_tip(ui, app.tr("render.stage_layout.appearance_note"), app.tr("render.assign_help"));
if view.kind == "materials" {
ui_kit::text(ui, app.tr("render.library_specification_only"), ui_kit::Text::Note("appearance:reference:reason"));
}
@@ -1682,6 +1668,17 @@ fn availability_label<'a>(app: &'a SolveApp, status: Option<&str>) -> &'a str {
})
}
/// Focus the existing library card from the viewport Appearance menu.
pub(super) fn focus_library(app: &mut SolveApp) {
let view = &mut app.render_workspace.view.library;
view.collection = 0;
switch_kind(view, "appearances");
}
pub(super) fn invalidate(app: &mut SolveApp) {
app.render_workspace.view.library.loaded = false;
}
#[cfg(test)]
mod studio_browser_tests {
use super::*;
@@ -3975,6 +3975,29 @@ fn render_workspace_hosts_float_and_shield_input_in_both_ribbon_layouts() {
}
}
#[test]
fn render_workspace_assignment_footer_fits_stacked_card_with_shots_shown_and_hidden() {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = layout;
h.call("ui.resize", json!({"width":1600,"height":1000}));
enter(&mut h.app, true).unwrap();
for shown in [true, false] {
h.app.run("view.shots_strip", json!({"visible":shown})).unwrap();
h.frames(4);
let browser = h.app.shell.workspace_panels["render:browser"].0;
let appearances = h.app.shell.workspace_panels["render:appearances"].0;
// Assignment help is the shared footer's tooltip; its visible anchor stays in the card.
let footer = crate::scenario::rect_of("catalog:footer").unwrap();
assert!(appearances.contains_rect(footer), "{layout:?}, Shots {shown}: footer {footer:?} outside {appearances:?}");
let gap = Tokens::get().space_8;
assert!((appearances.top() - browser.bottom() - gap).abs() < 1.0);
let bounds = crate::shell::panel_bounds(&h.app, h.app.viewport.rect.unwrap());
assert!((bounds.bottom() - appearances.bottom() - gap).abs() < 1.0, "{layout:?}, Shots {shown}: {appearances:?}, {bounds:?}");
}
}
}
#[test]
fn render_workspace_card_grips_swap_sides_and_resize_without_design_edits() {
for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] {
@@ -5424,3 +5447,364 @@ fn output_viewer_has_sections_without_pages_or_footer_and_selected_row_routes_to
assert!(crate::scenario::rect_of("render:output-width").is_some(), "selected Output keeps its editor when the stage changes");
}
}
#[test]
fn render_assign_gestures_selection_sizes_are_atomic_and_undoable() {
use solidcraft_engine::Sel;
let (mut h, occurrence) = render_pick_sample();
for mode in ["body", "face", "component"] {
h.app.run("select.filter", json!({"mode":mode})).unwrap();
let at = product_pick_point(&h);
h.step(&json!({"click":[at.x,at.y]})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
assign::tile(&mut h.app, json!({"appearance":"ABS - Blue"}), false).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
match mode {
"body" => assert_eq!(h.app.session.doc.appearances.bodies["Plate"].name, "ABS - Blue"),
"face" => assert!(h.app.session.doc.appearances.faces.iter().any(|f| f.look.name == "ABS - Blue")),
_ => {
let id = h.app.session.doc.path_placement(&[occurrence]).unwrap().0;
assert_eq!(h.app.session.doc.appearances.components[&id].name, "ABS - Blue");
}
}
assert!(h.app.status_undo);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before);
}
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Plate".into() }, Sel::Body { name: "missing".into() }];
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
assert!(assign::tile(&mut h.app, json!({"appearance":"ABS - Red"}), false).is_err());
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
}
#[test]
fn render_assign_gestures_paint_sample_copy_paste_escape_and_failed_pick() {
let (mut h, _) = render_pick_sample();
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
h.app.run("select.clear", json!({})).unwrap();
let before = h.app.session.doc.clone();
assign::tile(&mut h.app, json!({"appearance":"ABS - Red"}), false).unwrap();
assert!(h.app.render_workspace.view.assign.painting);
assert!(Arc::ptr_eq(&before, &h.app.session.doc));
h.frames(3);
let at = product_pick_point(&h);
let undo = h.app.session.undo.len();
h.step(&json!({"click":[at.x,at.y]})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1);
h.step(&json!({"click":[at.x,at.y]})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 2);
h.step(&json!({"widget":"Undo"})).unwrap();
assert_eq!(h.app.session.undo.len(), undo + 1, "toast Undo must not paint through to the canvas");
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!h.app.render_workspace.view.assign.painting);
h.app.run("appearance.assign", json!({"body":"Plate","faces":[[20,20,8]],"appearance":"ABS - Blue"})).ok();
h.app.start("appearance.pick_up");
let candidate = crate::viewport::pick(&h.app, &crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap()), at)
.into_iter()
.find(|hit| matches!(hit, crate::viewport::Hit::Face { .. }))
.unwrap();
let source = match candidate {
crate::viewport::Hit::Face { body, index, .. } => Some((body, index)),
_ => None,
};
let (body, index) = source.unwrap();
let expected = h.app.run("appearance.pick_up", json!({"target":{"body":body,"face":index}})).unwrap()["look"].clone();
h.step(&json!({"click":[at.x,at.y]})).unwrap();
assert!(h.app.render_workspace.view.assign.painting);
assert_eq!(h.app.render_workspace.view.assign.look, Some(expected.clone()));
h.step(&json!({"widget":"Done"})).unwrap();
assert!(!h.app.render_workspace.view.assign.painting);
h.app.start("appearance.paint");
h.step(&json!({"key":"Escape"})).unwrap();
h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: "Plate".into() }];
h.app.start("appearance.copy");
h.app.run("appearance.assign", json!({"bodies":["Plate"],"appearance":"ABS - Yellow"})).unwrap();
let undo = h.app.session.undo.len();
h.app.start("appearance.paste");
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.appearances.bodies["Plate"].name, "ABS - Red");
h.app.start("appearance.pick_up");
let before = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
// Empty view: a failed pick neither paints nor exits sampling.
let free = crate::render_ui::canvas(&h.app, h.app.viewport.rect.unwrap());
let empty = egui::pos2(free.right() - 20.0, free.bottom() - 50.0);
h.step(&json!({"click":[empty.x,empty.y]})).unwrap();
assert!(h.app.render_workspace.view.assign.sampling);
assert!(Arc::ptr_eq(&before, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!h.app.render_workspace.view.assign.sampling);
}
#[test]
fn render_assign_gestures_drag_preview_and_release_use_no_document_draft() {
let (mut h, _) = render_pick_sample();
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
let at = product_pick_point(&h);
let document = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
assign::tile(&mut h.app, json!({"appearance":"ABS - Blue"}), true).unwrap();
h.frame_with(vec![
egui::Event::PointerMoved(at),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
]);
h.frames(2);
assert!(assign::preview(&h.app).is_some());
let mut scene = solidcraft_engine::product_scene::extract(&h.app.session).unwrap();
let before = scene.meshes.iter().map(|m| m.color).collect::<Vec<_>>();
assign::preview_scene(&mut scene, assign::preview(&h.app).as_ref()).unwrap();
assert_ne!(scene.meshes.iter().map(|m| m.color).collect::<Vec<_>>(), before);
assert!(Arc::ptr_eq(&document, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
h.frame_with(vec![egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
}]);
h.frames(2);
assert!(assign::preview(&h.app).is_none());
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.appearances.bodies["Plate"].name, "ABS - Blue");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), document.to_json());
}
#[test]
fn render_assign_gestures_shortcuts_are_scoped_and_commands_are_not_stubs() {
let mut h = Harness::new();
assert_eq!(crate::keymap::command_for(&h.app, "I"), Some("inspect.measure"));
enter(&mut h.app, true).unwrap();
assert_eq!(crate::keymap::command_for(&h.app, "I"), Some("appearance.pick_up"));
assert_eq!(crate::keymap::command_for(&h.app, "B"), Some("appearance.paint"));
for (id, key) in [("appearance.copy", "Ctrl+Alt+C"), ("appearance.paste", "Ctrl+Alt+V")] {
assert_eq!(crate::keymap::command_for(&h.app, key), Some(id));
}
for id in ["appearance.paint", "appearance.pick_up", "appearance.copy", "appearance.paste"] {
assert!(solidcraft_engine::find_command(id).unwrap().planned.is_none());
}
let before = h.app.session.doc.clone();
h.app.run("appearance.paint", json!({"appearance":"ABS - Red"})).unwrap();
h.app.run("appearance.pick_up", json!({})).unwrap();
assert!(!h.app.render_workspace.view.assign.painting && !h.app.render_workspace.view.assign.sampling);
assert!(h.app.dialog.is_none());
assert!(Arc::ptr_eq(&before, &h.app.session.doc));
}
#[test]
fn render_assign_gestures_grid_drag_modifiers_cancel_and_context_menu() {
let (mut h, occurrence) = render_pick_sample();
h.app.run("appearance.assign", json!({"bodies":["Plate"],"name":"Gesture blue","color":"#1234dd"})).unwrap();
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
h.app.run("select.clear", json!({})).unwrap();
h.step(&json!({"click":{"handle":"render:library-scope:in-design"}})).unwrap();
h.frames(3);
let id = json!(["Gesture blue", "#1234dd", 1.0, null]).to_string();
let source = crate::scenario::rect_of(&format!("render:library:{id}")).unwrap().center();
let at = product_pick_point(&h);
let doc = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
h.frame_with(vec![egui::Event::PointerMoved(source)]);
h.frame_with(vec![egui::Event::PointerButton {
pos: source,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
}]);
h.frame_with(vec![egui::Event::PointerMoved(source + egui::vec2(20.0, 0.0))]);
h.frame_with(vec![egui::Event::PointerMoved(at)]);
h.frames(2);
assert!(assign::preview(&h.app).is_some(), "grid drag must arm a live preview over geometry");
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
for mods in [egui::Modifiers::SHIFT, egui::Modifiers::CTRL, egui::Modifiers::COMMAND] {
h.frame_with(vec![egui::Event::ModifiersChanged(mods)]);
h.frames(2);
let hover = assign::hover(&h.app).unwrap();
if mods.shift {
assert!(matches!(hover, solidcraft_engine::Sel::Face { .. }));
} else {
assert_eq!(hover, &solidcraft_engine::Sel::Occurrence { path: vec![occurrence] });
}
assert!(assign::preview(&h.app).is_some());
}
h.frame_with(vec![
egui::Event::ModifiersChanged(egui::Modifiers::SHIFT),
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::SHIFT },
]);
h.frames(2);
assert_eq!(h.app.session.undo.len(), undo + 1);
assert_eq!(h.app.session.doc.appearances.faces.last().unwrap().look.name, "Gesture blue");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), doc.to_json());
assign::tile(&mut h.app, json!({"appearance":"ABS - Red"}), true).unwrap();
h.step(&json!({"move":[at.x,at.y]})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert!(assign::preview(&h.app).is_none());
assert_eq!(h.app.session.doc.to_json(), doc.to_json());
let menu = assign::menu(&h.app, "Plate");
for id in ["appearance.library", "appearance.pick_up", "appearance.copy", "appearance.paste", "appearance.assign"] {
assert!(menu.children.iter().any(|item| item.id == id), "missing menu action {id}");
}
let browse = menu.children.iter().find(|item| item.id == "appearance.library").unwrap();
crate::context_menu::run_item(&mut h.app, browse, at);
assert!(h.app.dialog.is_none());
assert!(!h.app.render_workspace.view.assign.painting);
}
#[test]
fn render_assign_gestures_labels_translate_in_all_five_catalogs() {
for language in ["en", "ja", "zh", "de", "ko"] {
let locale = crate::i18n::Locale::for_language(language);
for id in [
"cmd.appearance_copy",
"cmd.appearance_paste",
"render.assign_help",
"render.browse_looks",
"render.choose_look",
"render.look_applied",
"render.painting_with",
"render.pick_up_prompt",
"render.recent_looks",
"render.remove_override",
"cmd.appearance_favorite",
"render.replace_all",
"render.replace_with",
"render.look_material",
"render.hover_look",
] {
assert_ne!(locale.text(id), id, "{language}: {id}");
}
assert!(locale.format("render.painting_with", &[("name", "Sample")]).contains("Sample"));
}
}
#[test]
fn render_assign_gestures_stage_tools_and_selection_actions_use_commands() {
let (mut h, _) = render_pick_sample();
h.app.run("appearance.assign", json!({"bodies":["Plate"],"appearance":"ABS - Blue"})).unwrap();
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body {name:"Plate".into()}]})).unwrap();
h.frames(4);
let document = h.app.session.doc.clone();
let undo = h.app.session.undo.len();
let tools = crate::scenario::rect_of("render:stage_tools").unwrap();
for id in ["appearance.paint", "appearance.pick_up"] {
assert!(tools.contains_rect(crate::scenario::rect_of(&format!("render:stage:{id}")).unwrap()));
assert!(crate::workspace::destination(solidcraft_engine::find_command(id).unwrap()).is_none());
}
let pill = crate::scenario::rect_of("render:selection").unwrap();
assert!((pill.height() - 34.0).abs() < 1.0, "{pill:?}");
assert!(pill.width() < 200.0, "{pill:?}");
for index in 0..4 {
assert!(pill.contains_rect(crate::scenario::rect_of(&format!("selection:action:{index}")).unwrap()));
}
h.step(&json!({"click":{"handle":"render:stage:appearance.pick_up"}})).unwrap();
assert!(h.app.render_workspace.view.assign.sampling);
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"click":{"handle":"selection:action:0"}})).unwrap();
assert!(crate::scenario::rect_of("render:look_chain").is_some());
h.step(&json!({"click":{"handle":"selection:action:2"}})).unwrap();
assert_eq!(h.app.preferences.render_library_favorites.len(), 1);
h.step(&json!({"click":{"handle":"selection:action:2"}})).unwrap();
assert!(h.app.preferences.render_library_favorites.is_empty());
assert!(Arc::ptr_eq(&document, &h.app.session.doc));
assert_eq!(h.app.session.undo.len(), undo);
h.step(&json!({"click":{"handle":"selection:action:1"}})).unwrap();
assert!(inspector::confirmation_open(&h.app));
assert!(Arc::ptr_eq(&document, &h.app.session.doc));
h.step(&json!({"click":{"handle":"dialog:Replace with"}})).unwrap();
h.step(&json!({"widget":"Copper"})).unwrap();
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
assert!(!inspector::confirmation_open(&h.app));
assert_eq!(h.app.session.doc.appearances.bodies["Plate"].name, "Copper");
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body {name:"Plate".into()}]})).unwrap();
h.frames(3);
h.step(&json!({"click":{"handle":"selection:action:3"}})).unwrap();
assert!(!h.app.session.body_visible("Plate"));
h.app.run("edit.undo", json!({})).unwrap();
h.app.run("view.render_stage", json!({"stage":"light"})).unwrap();
h.frames(3);
assert!(crate::scenario::rect_of("render:selection").is_none());
assert!(crate::scenario::rect_of("render:stage:appearance.paint").is_none());
}
#[test]
fn render_assign_gestures_replacement_draft_cannot_cross_a_document_edit() {
let (mut h, _) = render_pick_sample();
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body {name:"Plate".into()}]})).unwrap();
h.app.start("appearance.replace");
assert!(assign::replacement_open(&h.app));
h.app.run("appearance.assign", json!({"bodies":["Plate"],"appearance":"ABS - Red"})).unwrap();
h.frames(3);
assert!(!assign::replacement_open(&h.app));
assert_eq!(h.app.session.doc.appearances.bodies["Plate"].name, "ABS - Red");
}
#[test]
fn render_assign_gestures_geometry_and_theme_evidence() {
let previous = crate::theme::is_dark();
for dark in [false, true] {
let mut h = Harness::new();
h.app.run("solid.cylinder", json!({"body_name":"Boss","radius":10,"height":20})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Boss"],"appearance":"Aluminum - Anodized Blue"})).unwrap();
h.app.run("solid.sphere", json!({"body_name":"Knob","center":[0,0,28],"radius":8})).unwrap();
h.app.run("appearance.assign", json!({"bodies":["Knob"],"appearance":"Copper"})).unwrap();
enter(&mut h.app, true).unwrap();
h.app.run("select.filter", json!({"mode":"body"})).unwrap();
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body {name:"Boss".into()}]})).unwrap();
h.app.pending_fit = None;
h.app.ui.dark = dark;
let ctx = crate::fonts::test_context();
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
let mut textures = std::collections::HashMap::new();
for frame in 0..48 {
crate::scenario::clear_handles();
let events = h
.app
.viewport
.rect
.and_then(|rect| crate::viewport::projection(&h.app, rect).to_screen(solidcraft_engine::geom::Vec3::new(0.0, 0.0, 28.0)))
.filter(|_| frame == 1)
.map(|at| vec![egui::Event::PointerMoved(at)])
.unwrap_or_default();
let mut output =
ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), time: Some(frame as f64 / 60.0), events, ..Default::default() }, |ui| {
h.app.logic(ui.ctx());
h.app.ui(ui);
});
crate::ui_kit::test_textures_update(&mut textures, &output.textures_delta);
output.textures_delta.clear();
if frame == 47 {
assert!(
crate::scenario::rect_of("kit:tooltip").is_some(),
"the model hover uses the standard kit tooltip; hover: {:?}, pointer: {:?}",
h.app.viewport.hover,
ctx.pointer_hover_pos()
);
let tooltip = crate::scenario::rect_of("kit:tooltip").unwrap();
assert!(tooltip.distance_to_pos(ctx.pointer_hover_pos().unwrap()) < Tokens::get().control_height, "{tooltip:?}");
let pill = crate::scenario::rect_of("render:selection").unwrap();
assert!((pill.height() - 34.0).abs() < 1.0, "{pill:?}");
assert!((150.0..170.0).contains(&pill.width()), "{pill:?}");
let tools = crate::scenario::rect_of("render:stage_tools").unwrap();
for id in ["appearance.paint", "appearance.pick_up"] {
assert!(tools.contains_rect(crate::scenario::rect_of(&format!("render:stage:{id}")).unwrap()));
}
if let Ok(dir) = std::env::var("SOLIDCRAFT_GESTURES_EVIDENCE") {
std::fs::create_dir_all(&dir).unwrap();
let image = crate::ui_kit::test_raster(&ctx, output.shapes, output.pixels_per_point, bounds, &textures);
image.save(std::path::Path::new(&dir).join(format!("look-controls-{}.png", if dark { "dark" } else { "light" }))).unwrap();
}
}
}
}
crate::theme::set_dark(previous);
}
+11 -2
View File
@@ -143,10 +143,19 @@ pub(crate) fn panel_bounds(app: &SolveApp, mut available: Rect) -> Rect {
available
}
/// One source of occlusion for navigation, picking, overlays and fit.
/// One source of occlusion for navigation, picking and overlays.
pub(crate) fn covered_rects(app: &SolveApp) -> Vec<Rect> {
occluding_rects(app, false)
}
/// Selection controls follow the projected model, so they must not influence its fit.
pub(crate) fn fit_rects(app: &SolveApp) -> Vec<Rect> {
occluding_rects(app, true)
}
fn occluding_rects(app: &SolveApp, fitting: bool) -> Vec<Rect> {
let mut rects = Vec::new();
rects.extend(app.shell.workspace_panels.values().map(|(rect, _)| *rect));
rects.extend(app.shell.workspace_panels.iter().filter(|(id, _)| !fitting || **id != "render:selection").map(|(_, (rect, _))| *rect));
if crate::render_workspace::active(app) || Container::for_layout(app.ui.layout, Surface::Panel).floating() {
rects.extend(app.shell.cards.iter().copied());
rects.extend(app.shell.panels.iter().copied());
+4
View File
@@ -2287,6 +2287,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.last_escape = None;
continue;
}
if crate::render_workspace::assign::cancel(app) {
app.last_escape = None;
continue;
}
// Two quick presses in a sketch finish it (keeping it, like Finish Sketch);
// the first one has already cancelled whatever was active.
let now = ctx.input(|i| i.time);
+34
View File
@@ -604,6 +604,40 @@ pub(crate) fn compact_icon_label_button(ui: &mut Ui, label: &str, icon: &str, en
icon_label_presentation(ui, label, Some(icon), enabled, false, false, true)
}
/// A name chip and quiet actions in a selection card. Feature code supplies command data only.
pub(crate) fn selection_actions(ui: &mut Ui, name: &str, actions: &[super::StageAction<'_>]) -> Option<usize> {
let t = Tokens::get();
let accent = crate::theme::TabAccent::current(ui.ctx());
ui.spacing_mut().interact_size.y = t.control_height - t.space_2;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = t.space_2;
let font = crate::fonts::semibold(t.font_small);
let width = ui.painter().layout_no_wrap(name.into(), font.clone(), accent.ink).size().x.min(t.form_label_width) + t.space_12;
let (rect, name_response) = ui.allocate_exact_size(vec2(width, t.control_height - t.space_2), egui::Sense::click());
name_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name));
super::text::paint_text(ui, rect.shrink2(vec2(t.space_6, 0.0)), name, font, accent.ink, false);
scenario::publish_target("selection:name", rect);
let mut clicked = name_response.clicked().then_some(actions.len());
for (index, action) in actions.iter().enumerate() {
let response = ui
.add_enabled_ui(action.enabled, |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(t.control_height - t.space_2, t.control_height - t.space_2), egui::Sense::hover());
icon_button_colored(ui, rect, action.icon.unwrap_or("inspect"), action.label, action.primary.then_some(accent.ink))
})
.inner;
scenario::publish_target(&format!("selection:action:{index}"), response.rect);
if response.clicked() {
clicked = Some(index);
}
if let Some(reason) = action.reason {
response.on_disabled_hover_text(reason);
}
}
clicked
})
.inner
}
fn icon_label_presentation(ui: &mut Ui, label: &str, icon: Option<&str>, enabled: bool, primary: bool, danger: bool, dense: bool) -> Response {
let t = Tokens::get();
let width = icon_label_width(ui, label, icon, primary);
+31
View File
@@ -115,6 +115,37 @@ impl Container {
container.show_frame(ui, frame, ui.max_rect(), |ui| super::scope(ui, add))
}
/// Compact selection actions above canvas geometry, using the navigation host policy.
pub(crate) fn selection_card<R>(
ctx: &Context,
id: Id,
anchor: egui::Pos2,
bounds: egui::Rect,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> egui::InnerResponse<R> {
let container = Self::new(ctx, Surface::Navigation);
let t = Tokens::get();
container.area(id).movable(false).pivot(egui::Align2::CENTER_BOTTOM).fixed_pos(anchor).constrain_to(bounds).order(egui::Order::Middle).show(
ctx,
|ui| {
let frame =
container.frame(ctx, t.dialog_body, Side::Left).corner_radius(t.space_24 as u8).shadow(t.shell_shadow()).inner_margin(t.space_3);
container.show_frame(ui, frame, bounds, |ui| super::scope(ui, add)).inner
},
)
}
/// Standard content tooltip, including the same host and padding as library samples.
pub(crate) fn tooltip(response: &egui::Response, text: &str) {
let ctx = &response.ctx;
let mut tip = egui::Tooltip::for_enabled(response).at_pointer();
tip.popup = tip.popup.frame(Self::new(ctx, Surface::Toast).tooltip_frame(ctx));
tip.show(|ui| {
ui.label(text);
crate::scenario::publish_rect("kit:tooltip", ui.min_rect());
});
}
/// Declare contacts from shell geometry; styling remains entirely in the container.
pub fn joined(mut self, edges: DockEdges) -> Self {
self.joined = Some(edges);
+31
View File
@@ -241,6 +241,15 @@ const ELEMENTS: &[Element] = &[
[TreeAction { icon: "lookat", hover_only: true, label: "Solo", reason: Some("Not available yet"), target: "gallery:tree:solo" }];
tree_row(ui, ui.id().with("tree_actions"), &Row { label: "Light", actions: &actions, ..Default::default() });
}),
element!("selection_actions", |ui, _| {
let actions = [
StageAction { icon: Some("chain"), label: "Appearance Chain", enabled: true, primary: false, reason: None },
StageAction { icon: Some("reorder"), label: "Replace All", enabled: true, primary: false, reason: None },
StageAction { icon: Some("star"), label: "Favourite Look", enabled: true, primary: true, reason: None },
StageAction { icon: Some("suppress"), label: "Hide", enabled: true, primary: false, reason: None },
];
selection_actions(ui, "Boss", &actions);
}),
element!("catalog_scope", |ui, state| {
catalog_scope(ui, |ui| {
search_field(ui, ui.id().with("catalog_search"), &mut state.value, "Search appearances");
@@ -351,6 +360,28 @@ const ELEMENTS: &[Element] = &[
});
sphere_grid(ui, &tiles);
}),
element!("sphere_grid_drag", |ui, _| {
let tiles = [SphereTile {
id: "gallery:drag-grid",
name: "Copper",
sphere: Sphere { color: Tokens::get().render_accent, kind: FinishKind::Metal, opacity: None },
selected: true,
users: 1,
tip: "Drag to apply",
badge: None,
}];
sphere_grid_drag(ui, &tiles);
}),
element!("sphere_cursor", |ui, _| {
let sphere = Sphere { color: Tokens::get().render_accent, kind: FinishKind::Satin, opacity: None };
sphere_thumbnail(ui, "gallery:cursor-sample", sphere, Tokens::get().control_height);
sphere_cursor(ui, sphere);
}),
element!("note_height", |ui, _| {
let label = "A wrapped note reserves its measured height in a bounded card.";
let _height = note_height(ui, label);
text(ui, label, Text::Note("gallery:measured-note"));
}),
element!("sphere_tile", "gallery:tile", Some("gallery:tile"), |ui, _| {
SphereTile {
id: "gallery:tile",
+12 -1
View File
@@ -654,6 +654,17 @@ pub(crate) enum Text<'a> {
Value(&'a str, bool),
}
fn note_galley(ui: &Ui, text: &str) -> std::sync::Arc<egui::Galley> {
let t = Tokens::get();
ui.painter().layout(text.to_owned(), egui::FontId::proportional(t.font_small), t.form_dim, dimension(ui.available_width()))
}
/// Reserve the actual wrapped note height when budgeting a bounded card's scroll area.
#[cfg(test)]
pub(crate) fn note_height(ui: &Ui, text: &str) -> f32 {
note_galley(ui, text).size().y + Tokens::get().space_4 * 2.0
}
pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response {
if crate::prefs_search::querying(ui) {
let heading = matches!(style, Text::Heading(_));
@@ -710,7 +721,7 @@ pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response {
.inner
} else if matches!(style, Text::Hint(_) | Text::Note(_) | Text::MonoNote(_)) {
let width = dimension(ui.available_width());
let galley = ui.painter().layout(text.to_owned(), font, ink, width);
let galley = if matches!(style, Text::Note(_)) { note_galley(ui, text) } else { ui.painter().layout(text.to_owned(), font, ink, width) };
let padding = if matches!(style, Text::Note(_) | Text::MonoNote(_)) { t.space_4 } else { 0.0 };
let (rect, response) = ui.allocate_exact_size(vec2(width, galley.size().y + padding * 2.0), egui::Sense::hover());
ui.painter().galley(egui::pos2(rect.left(), rect.top() + padding), galley, ink);
+6 -6
View File
@@ -5,8 +5,6 @@ mod actions;
mod assistant;
mod assistant_entry;
mod checks;
#[cfg(test)]
pub(crate) use checks::tests::{raster as test_raster, textures_update as test_textures_update};
mod choice;
mod color;
mod confirm;
@@ -50,6 +48,8 @@ pub(crate) use assistant::*;
pub(crate) use assistant_entry::*;
#[cfg(test)]
pub(crate) use checks::tests::{raster as raster_ui, textures_update as update_test_textures};
#[cfg(test)]
pub(crate) use checks::tests::{raster as test_raster, textures_update as test_textures_update};
pub(crate) use checks::*;
pub(crate) use choice::*;
pub(crate) use color::*;
@@ -69,7 +69,9 @@ pub use name_list::{NameList, NameRow, name_list};
pub(crate) use navigation::*;
pub(crate) use navigation_menu::*;
pub(crate) use navigation_settings::*;
pub(crate) use panel::{ComputeStatus, FeatureOptions, Panel, Part, WindowPage, WorkspaceOptions, panel_header_hover};
pub(crate) use panel::{
ComputeStatus, FeatureOptions, InspectorAction, Panel, Part, WindowPage, WorkspaceOptions, inspector_header, panel_header_hover,
};
pub use panel::{DialogButtons, Window};
#[cfg(test)]
pub(crate) use panel::{collapsible_header, header};
@@ -85,7 +87,7 @@ pub(crate) use shortcuts::*;
#[cfg(test)]
pub(crate) use sphere::sphere_preview;
pub use sphere::{FinishKind, Sphere, sphere_thumbnail};
pub(crate) use sphere::{SphereTile, sphere_grid, sphere_preview_with_source};
pub(crate) use sphere::{SphereTile, sphere_cursor, sphere_grid, sphere_grid_drag, sphere_preview_with_source};
pub(crate) use stages::{StageAction, StageBar};
pub(crate) use status::*;
pub(crate) use table::*;
@@ -116,5 +118,3 @@ pub(crate) fn fits_steadily(ui: &egui::Ui, key: impl std::hash::Hash + std::fmt:
ui.data_mut(|data| data.insert_temp(id, fits));
fits
}
pub(crate) use panel::{InspectorAction, inspector_header};
+28 -6
View File
@@ -256,7 +256,7 @@ impl SphereTile<'_> {
let line_height = ui.fonts_mut(|fonts| fonts.row_height(&crate::fonts::semibold(t.font_small)));
let height = t.space_16 + side + t.space_6 + line_height * 2.0 + t.space_3;
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
let response = ui.interact(rect, ui.make_persistent_id(self.id), Sense::click());
let response = ui.interact(rect, ui.make_persistent_id(self.id), Sense::click_and_drag());
let cell = rect;
ui.painter().rect_filled(cell, t.radius_xl, t.control_fill);
ui.painter().rect_stroke(cell, t.radius_xl, t.control_border_stroke(t.dialog_rule), egui::StrokeKind::Inside);
@@ -304,23 +304,45 @@ impl SphereTile<'_> {
}
}
/// Noninteractive cursor sphere, shared by drag-and-drop and paint tools.
pub(crate) fn sphere_cursor(ui: &mut egui::Ui, sphere: Sphere) {
if let Some(pointer) = ui.input(|i| i.pointer.hover_pos()) {
let t = Tokens::get();
let rect = Rect::from_min_size(pointer + vec2(t.space_12, t.space_12), vec2(t.control_height, t.control_height));
let mut ghost =
ui.new_child(egui::UiBuilder::new().layer_id(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("sphere_cursor"))).max_rect(rect));
ghost.set_clip_rect(ui.ctx().content_rect());
paint_sphere(&ghost, rect, sphere);
}
}
/// Fixed-column catalog composition. Tile geometry and gaps stay with the kit.
pub(crate) fn sphere_grid(ui: &mut Ui, tiles: &[SphereTile<'_>]) -> Option<usize> {
sphere_grid_drag(ui, tiles).0
}
/// Grid click and drag-start events; callers own the drag payload and drop policy.
pub(crate) fn sphere_grid_drag(ui: &mut Ui, tiles: &[SphereTile<'_>]) -> (Option<usize>, Option<usize>) {
let t = Tokens::get();
let mut clicked = None;
let mut dragged = None;
ui.spacing_mut().item_spacing = vec2(t.space_8, t.space_8);
for (row, chunk) in tiles.chunks(3).enumerate() {
ui.columns(3, |columns| {
for (column, tile) in chunk.iter().enumerate() {
if let Some(ui) = columns.get_mut(column)
&& tile.show(ui).clicked()
{
clicked = Some(row * 3 + column);
if let Some(ui) = columns.get_mut(column) {
let response = tile.show(ui);
if response.clicked() {
clicked = Some(row * 3 + column);
}
if response.drag_started() {
dragged = Some(row * 3 + column);
}
}
}
});
}
clicked
(clicked, dragged)
}
#[cfg(test)]
+24 -19
View File
@@ -3009,7 +3009,7 @@ fn hit_candidate(app: &SolveApp, h: &Hit) -> Option<(Hit, Sel)> {
/// Use the Browser's exact occurrence identity, including nested/repeated placements.
/// Root bodies have no occurrence row, so retain their body identity.
fn component_selection(app: &SolveApp, body: &str) -> Sel {
pub(crate) fn component_selection(app: &SolveApp, body: &str) -> Sel {
let doc = app.session.placed_document();
let state = app.session.world_state();
if let Some(b) = state.body(body) {
@@ -3281,6 +3281,7 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 {
app.session.active_component.hash(&mut h);
app.session.active_occurrence_path.hash(&mut h);
app.ui.visual_style.hash(&mut h);
crate::render_workspace::assign::preview_key(app).hash(&mut h);
app.ui.pick_bodies.hash(&mut h);
app.ui.pick_components.hash(&mut h);
crate::render_workspace::active(app).hash(&mut h);
@@ -3527,13 +3528,14 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
}
}
// Component hover expands through the same occurrence selection as a Browser row.
let component_hover = app
.viewport
.hover
.as_ref()
.filter(|h| app.ui.pick_components && matches!(h, Hit::Face { .. }))
.and_then(|h| hit_candidate(app, h))
.map(|(_, s)| displayed_selection(app, &s));
let component_hover = crate::render_workspace::assign::hover(app).map(|sel| displayed_selection(app, sel)).or_else(|| {
app.viewport
.hover
.as_ref()
.filter(|h| app.ui.pick_components && matches!(h, Hit::Face { .. }))
.and_then(|h| hit_candidate(app, h))
.map(|(_, s)| displayed_selection(app, &s))
});
let hover = app.viewport.hover.as_ref().filter(|_| component_hover.is_none());
let size = origin_size(app.cam.half_height());
if !product {
@@ -4833,7 +4835,7 @@ const STATUS_TOAST_MS: f64 = 6000.0;
/// A single visual pattern for ongoing work, the existing six-second status, the 3D mouse
/// calibration offer and the stand-up note, stacked from the bottom of the free view. Returns the toasts' rects.
fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Vec<Rect> {
pub(crate) fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Vec<Rect> {
// Nothing to show or time: an idle window skips the layout. The offer is checked too, as its clock
// runs from the frames `due` sees.
if app.status.is_none()
@@ -4895,21 +4897,24 @@ fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Vec<
if let Some((msg, at, error)) = app.status.clone() {
let shown_ms = crate::now_ms() - at;
if shown_ms < STATUS_TOAST_MS {
if let Some(r) = paint_toast(ui, free, msg, error, false) {
let undo = app.status_undo;
let actions = if undo { vec![(crate::i18n::tr_label("Undo"), ToastKind::Secondary)] } else { Vec::new() };
if let Some((r, clicked, _)) = paint_toast_with(ui, free, msg, ToastLead::Dot(error), &actions) {
if undo && clicked == Some(0) {
app.status = None;
app.status_undo = false;
let _ = app.run("edit.undo", json!({}));
}
obstacles.push(r);
crate::scenario::publish_rect("toast", r);
free.max.y = r.top() - t.space_8;
shown.push(r);
// Open the run's messages from the tokenised status toast.
crate::scenario::publish_handle("status:chip", r.center());
let hint = if app.status_undo { crate::i18n::tr_label("Undo") } else { crate::i18n::tr("messages.chip_hint") };
if ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(hint).clicked() {
if app.status_undo {
app.status = None;
let _ = app.run("edit.undo", json!({}));
} else {
app.messages.open = true;
}
if !undo
&& ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked()
{
app.messages.open = true;
}
}
// One frame when it is due to go, not a frame every half second while it shows.
@@ -5811,7 +5816,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli
pub(crate) fn refresh_layout(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(rect) = app.viewport.rect {
let docks = crate::shell::covered_rects(app);
let docks = crate::shell::fit_rects(app);
let mut content = crate::shell::content_rect(app, rect);
if crate::render_ui::focused(app) {
// Focus releases dock reservations while keeping controls below the stage bar.
-6
View File
@@ -323,9 +323,6 @@ const LEGACY_RENDER_SECTIONS: &[Section] = &[
"render.library.surface.prepare",
"render.library.surface.unbind",
"render.library.physical.prepare",
"appearance.paint",
"appearance.pick_up",
"appearance.replace",
],
},
Section {
@@ -370,9 +367,6 @@ const LEGACY_RENDER_SECTIONS: &[Section] = &[
/// Compact source labels for painted big buttons only. Menus, search and tooltips
/// retain the engine label. Route these through `tr_label`, like other UI labels.
pub const RIBBON_LABELS: &[(&str, &str)] = &[
("appearance.paint", "Paint"),
("appearance.pick_up", "Pick Up"),
("appearance.replace", "Replace"),
("render.batch", "All Shots"),
("render.cancel", "Cancel"),
("render.export", "Export Image"),
+36 -6
View File
@@ -12,7 +12,12 @@ fn window_widths(h: &Harness) -> Vec<(egui::Id, f32)> {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order == egui::Order::Middle && l.id != egui::Id::new("sc_browser_card"))
// Workspace hosts are not command windows; Shots has wide expanded and small collapsed forms.
.filter(|l| {
l.order == egui::Order::Middle
&& ![egui::Id::new("sc_browser_card"), egui::Id::new("sc_render_shots_strip"), egui::Id::new("sc_render_shots_pill")]
.contains(&l.id)
})
.filter_map(|l| m.area_rect(l.id).map(|r| (l.id, r.width())))
.collect()
})
@@ -43,6 +48,32 @@ fn fresh() -> Harness {
h
}
#[test]
fn render_strip_is_excluded_while_real_windows_are_still_measured() {
let mut h = fresh();
assert_eq!(h.call("ui.workspace", json!({"workspace":"render"}))["ok"], true);
h.app.help.about = true;
h.frames(6);
let strip = egui::Id::new("sc_render_shots_strip");
let width = h.ctx.memory(|m| m.area_rect(strip)).expect("visible Shots strip").width();
assert!(width > DIALOG_MAX_W, "the workspace strip should span the canvas: {width}");
let windows = window_widths(&h);
assert!(!windows.iter().any(|(id, _)| *id == strip));
assert!(windows.iter().any(|(id, _)| *id == egui::Id::new("sc_about")), "real About window is still measured: {windows:?}");
assert_eq!(h.call("engine.execute", json!({"command":"view.shots_strip","params":{"visible":false}}))["ok"], true);
h.frames(6);
let pill = egui::Id::new("sc_render_shots_pill");
let width = h.ctx.memory(|m| m.area_rect(pill)).expect("visible Shots restore control").width();
assert!(width < DIALOG_MIN_W, "the restore control is intentionally smaller than a dialog: {width}");
let windows = window_widths(&h);
assert!(!windows.iter().any(|(id, _)| *id == pill));
assert!(windows.iter().any(|(id, _)| *id == egui::Id::new("sc_about")), "real window remains measured with Shots collapsed: {windows:?}");
assert_eq!(h.call("ui.start", json!({"command":"file.export_view_dxf"}))["ok"], true);
h.frames(6);
let dialog = solidcraft_ui_egui::scenario::rect_of("dialog:outer").expect("DXF dialog has an outer rectangle");
assert!(dialog.width() >= DIALOG_MIN_W - 4.0, "actual DXF dialog remains at least the dialog minimum: {dialog:?}");
}
#[test]
fn command_dialogs_shrink_to_their_content_within_limits() {
let mut h = fresh();
@@ -64,11 +95,10 @@ fn command_dialogs_shrink_to_their_content_within_limits() {
}
}
// The docked dialog shrinks to its content: most dialogs stay well under the maximum.
if h.app.dialog.is_some() {
for (_, w) in window_widths(&h) {
if w < DIALOG_MIN_W - slack {
bad.push(format!("{}: {w} px is narrower than {DIALOG_MIN_W}", spec.id));
}
if let Some(rect) = solidcraft_ui_egui::scenario::rect_of("dialog:outer").filter(|_| h.app.dialog.is_some()) {
let w = rect.width();
if w < DIALOG_MIN_W - slack {
bad.push(format!("{}: active dialog {w} px is narrower than {DIALOG_MIN_W}", spec.id));
}
}
h.app.dialog = None;
+13
View File
@@ -440,6 +440,19 @@ GPU overlay on the live product view. The product scene and its upload stay unch
CPU image jobs, exported PNGs, retained images and the Render Result canvas never
contain this overlay; the Pick control is disabled on the result canvas.
Look's tools row contains **Paint** (B) and **Pick Up** (I). A library click applies
to the current selection or starts painting when nothing is selected; dragging
previews the look until release. Esc or Done ends painting or sampling. Copy Look
and Paste Look use Ctrl/Cmd+Alt+C and Ctrl/Cmd+Alt+V.
The selection pill uses shared kit chrome and follows the projected selection.
Its actions open the look chain, Replace All, toggle a library favourite and hide
the selection. The name opens the selection menu. Assign, Replace All, Copy Look
and Remove are available there and in the Appearance Inspector; commands also
remain available to search and scripts. Replace All asks for the replacement in a
shared confirmation and applies one undoable command. A changed document cancels
that pending request. Hover uses the shared tooltip with the object and look names.
## Browser scene focus
The shared Browser lists Environment (with its source), Lights, saved Shots,
+4
View File
@@ -50,6 +50,10 @@ rules. Its compass is 110 pt; the 48 pt Shots strip clears the docks by 8 pt.
scans new crates/apps automatically, including macro bodies and alias chains. See the [container audit and migration inventory](ui-container-audit.md).
- Colours, typography, dimensions and spacing come from `theme::Tokens`.
- `Container::selection_card` extends the navigation host with a bounded, rounded
selection pill. `selection_actions` composes its name chip and quiet icon actions;
callers supply command data and dispatch the chosen action. `Container::tooltip`
shares the library samples' tooltip frame and padding.
- Sliders only via `ui_kit::slider`; `Slider` options change behaviour, never appearance.
Raw sliders and duplicate track/thumb painters are forbidden in the UI and apps.
- `scope(ui, add)` supplies form styling. `row(ui, label, add)` aligns labels and