mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
* 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:
@@ -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))
|
||||
}
|
||||
@@ -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, ¶ms).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", ¶ms).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);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user