Merge fix/636

This commit is contained in:
bflatastic
2026-10-17 14:04:59 +07:00
5 changed files with 346 additions and 7 deletions
+104 -3
View File
@@ -297,6 +297,10 @@ struct Inst<'a> {
path: Vec<String>,
overrides: Rc<Overrides<'a>>,
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
/// Slot property GUID → private slot-content node GUID.
slot_contents: Rc<BTreeMap<String, String>>,
/// Layers imported from a slot's independent content subtree.
slot_content: bool,
/// Mains being expanded (an instance inside its own main stops there).
symbols: Vec<String>,
}
@@ -770,7 +774,13 @@ impl<'a> Mapper<'a> {
// Private provenance for the .fig importer; ordinary REST import ignores it.
o.insert(
crate::rest::FIG_SOURCE_FIELD.into(),
json!({"origin": id, "expanded": inst.is_some(), "wireFrame": own == "FRAME", "wireInstance": own == "INSTANCE"}),
json!({
"origin": id,
"expanded": inst.is_some(),
"wireFrame": own == "FRAME",
"wireInstance": own == "INSTANCE",
"slotContent": inst.is_some_and(|i| i.slot_content),
}),
);
o.insert("type".into(), json!(ty));
copy_str(&mut o, &view, "name", "name");
@@ -799,6 +809,16 @@ impl<'a> Mapper<'a> {
self.component(&mut o, &view, &rest_id);
}
self.prop_refs(&mut o, &view, inst);
let slot_content = if own == "FRAME" {
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned()))
} else {
None
};
if slot_content.is_some()
&& let Some(source) = o.get_mut(crate::rest::FIG_SOURCE_FIELD).and_then(Value::as_object_mut)
{
source.insert("slotOverride".into(), json!(true));
}
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
let mut kids = Vec::new();
if ty == "INSTANCE" {
@@ -810,6 +830,14 @@ impl<'a> Mapper<'a> {
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
}
}
} else if let Some(content) = slot_content {
let mut content_inst = inst.cloned();
if let Some(content_inst) = &mut content_inst {
content_inst.slot_content = true;
}
for c in self.children(&content) {
kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
}
} else {
// Inside an instance a layer's children are its main's: they follow it from the main's
// size to its own.
@@ -1338,6 +1366,11 @@ impl<'a> Mapper<'a> {
None => continue,
},
"VARIANT" => json!({"type": "VARIANT"}),
"SLOT" => {
self.warn_non_default_slot_config(d);
self.warn_dropped_slot_preferred_values(d);
json!({"type": "SLOT"})
}
_ => {
self.warn(format!("{} component properties are not supported and were skipped", ty.to_ascii_lowercase()));
continue;
@@ -1354,6 +1387,34 @@ impl<'a> Mapper<'a> {
}
}
/// Preferred slot values are source component keys, but this mapper has no proven way to map
/// those keys to file GUIDs. They are dropped instead of guessing native NodeIds.
fn warn_dropped_slot_preferred_values(&mut self, def: &Kv<'a>) {
let entries = def.get("preferredValues").and_then(|v| v.get("instanceSwapValues")).map(Kv::list).unwrap_or_default();
if !entries.is_empty() {
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
}
}
fn warn_non_default_slot_config(&mut self, def: &Kv<'a>) {
let Some(config) = def.get("slotPropConfig") else { return };
let non_default = [
("stretchChildOnInsert", Some(false)),
("displayByDefault", Some(false)),
("minChildren", None),
("maxChildren", None),
("allowPreferredValuesOnly", Some(false)),
]
.into_iter()
.any(|(field, bool_default)| {
let Some(value) = config.get(field) else { return false };
if let Some(default) = bool_default { value.bool() != Some(default) } else { value.f64() != Some(0.0) }
});
if non_default {
self.warn("non-default slot settings are not represented and were skipped");
}
}
/// A layer's component property references, and inside an instance the values they take.
fn prop_refs(&mut self, o: &mut Obj, v: &View<'a>, inst: Option<&Inst<'a>>) {
let mut refs = Obj::new();
@@ -1362,6 +1423,7 @@ impl<'a> Mapper<'a> {
Some("VISIBLE") => "visible",
Some("TEXT_DATA") => "characters",
Some("OVERRIDDEN_SYMBOL_ID") => "mainComponent",
Some("SLOT_CONTENT_ID") => "slot",
_ => continue,
};
let Some(def) =
@@ -1369,7 +1431,11 @@ impl<'a> Mapper<'a> {
else {
continue;
};
if let Some((name, _)) = self.prop_defs.get(&def) {
// Expanded frames must follow their main frame's source link. A direct slot reference
// makes the engine resolve ownership against the instance, where the property is not defined.
if !(field == "slot" && inst.is_some())
&& let Some((name, _)) = self.prop_defs.get(&def)
{
refs.insert(field.into(), json!(name));
}
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
@@ -1501,15 +1567,50 @@ impl<'a> Mapper<'a> {
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth), row.ordinal));
}
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
all_props.entry(d).or_insert(val);
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
continue;
}
let var_value = a.get("varValue");
if var_value.and_then(|v| v.get("dataType")).and_then(Kv::str) != Some("SLOT_CONTENT_ID") {
self.warn("malformed slot content assignment was skipped");
continue;
}
let target = var_value.and_then(|v| v.get("value")).and_then(|v| v.get("slotContentIdValue")).and_then(|v| v.get("guid")).and_then(guid);
let Some(target) = target else {
self.warn("malformed slot content assignment was skipped");
continue;
};
if target == "4294967295:4294967295" {
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
continue;
}
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
slot_contents.entry(def).or_insert(target);
}
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
Some((Inst { root, path: own_path, overrides: Rc::new(overrides), props: Rc::new(all_props), symbols }, sym))
Some((
Inst {
root,
path: own_path,
overrides: Rc::new(overrides),
props: Rc::new(all_props),
slot_contents: Rc::new(slot_contents),
slot_content: false,
symbols,
},
sym,
))
}
fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
+2
View File
@@ -21,6 +21,8 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
#[cfg(test)]
mod tests;
+225
View File
@@ -0,0 +1,225 @@
//! Component slot content from `.fig` files: SLOT definitions, the frames that reference them and
//! the content subtrees that instances assign, on synthetic messages.
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn node(local: u32, kind: &'static str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", Kv::Str("Synthetic".into()))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("!".into()))])));
}
all.append(&mut fields);
o(all)
}
fn mapped(msg: &Kv<'_>) -> Value {
to_rest(msg, "Synthetic resources").0
}
fn named_id<'b>(v: &'b Value, id: &str) -> Option<&'b Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| named_id(v, id))
}
fn changes<'a>(msg: &'a mut Kv<'static>) -> &'a mut Vec<Kv<'static>> {
let Kv::Object(fields) = msg else { panic!() };
let (_, Kv::Array(rows)) = fields.iter_mut().find(|(key, _)| *key == "nodeChanges").unwrap() else { panic!() };
rows
}
fn slot_message(content: Option<u32>, preferred: bool) -> Kv<'static> {
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
let mut instance_fields = vec![("symbolData", o(vec![("symbolID", g(10))]))];
if let Some(content) = content {
instance_fields.push((
"componentPropAssignments",
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])]),
));
}
let mut definition = vec![("id", g(70)), ("name", Kv::Str("Content".into())), ("type", Kv::Enum("SLOT"))];
if preferred {
definition.push((
"preferredValues",
o(vec![("instanceSwapValues", Kv::Array(vec![o(vec![("type", Kv::Enum("COMPONENT")), ("key", Kv::Str("unresolved-key".into()))])]))]),
));
}
o(vec![(
"nodeChanges",
Kv::Array(vec![
node(1, "DOCUMENT", None, vec![]),
node(2, "CANVAS", Some(1), vec![]),
node(3, "CANVAS", Some(1), vec![("internalOnly", Kv::Bool(true))]),
node(10, "SYMBOL", Some(2), vec![("componentPropDefs", Kv::Array(vec![o(definition)]))]),
node(12, "FRAME", Some(10), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
node(13, "RECTANGLE", Some(12), vec![]),
node(20, "INSTANCE", Some(2), instance_fields),
node(30, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
node(31, "RECTANGLE", Some(30), vec![]),
node(32, "ELLIPSE", Some(30), vec![]),
node(33, "FRAME", Some(3), vec![("isSlotContent", Kv::Bool(true))]),
]),
)])
}
#[test]
fn slot_definition_reference_and_arbitrary_override_children_are_imported() {
let rest = mapped(&slot_message(Some(30), false));
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
let main_slot = named_id(&rest["document"], "9:12").unwrap();
assert_eq!(main_slot["componentPropertyReferences"]["slot"], "Content#9:70");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert!(slot.get("componentPropertyReferences").is_none());
assert_eq!(slot[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
let kids = slot["children"].as_array().unwrap();
assert_eq!(kids.len(), 2);
assert_eq!(kids[0]["id"], "I9:20;9:31");
assert_eq!(kids[1]["id"], "I9:20;9:32");
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let main = doc
.node(*doc.pages.first().unwrap())
.unwrap()
.children
.iter()
.find_map(|id| doc.node(*id).filter(|n| n.name == "Synthetic" && matches!(n.kind, figcraft_doc::NodeKind::Component { .. })))
.unwrap();
assert!(
matches!(&main.kind, figcraft_doc::NodeKind::Component { props, .. } if matches!(props.get("Content"), Some(figcraft_doc::PropDef::Slot { preferred }) if preferred.is_empty()))
);
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let slot = doc.node(instance.children[0]).unwrap();
assert!(!slot.prop_refs.contains_key("slot"));
assert_eq!(slot.source, Some(main.children[0]));
assert!(slot.overrides.contains("slot"));
assert_eq!(slot.children.len(), 2);
assert!(slot.children.iter().all(|id| doc.node(*id).is_some_and(|child| child.source.is_none())));
let (public, _) = crate::rest::import(&rest, &crate::rest::no_images).unwrap();
let public_instance = public.ids().filter_map(|id| public.node(id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).unwrap();
let public_slot = public.node(public_instance.children[0]).unwrap();
assert!(!public_slot.overrides.contains("slot"));
}
#[test]
fn nested_instance_in_own_slot_content_keeps_its_own_source_tree() {
let mut msg = slot_message(Some(30), false);
let rows = changes(&mut msg);
let slot_frame = rows.iter_mut().find(|row| row.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(31.0)).unwrap();
let Kv::Object(fields) = slot_frame else { panic!() };
fields.iter_mut().find(|(key, _)| *key == "type").unwrap().1 = Kv::Enum("FRAME");
rows.extend([
node(34, "INSTANCE", Some(31), vec![("symbolData", o(vec![("symbolID", g(40))]))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![]),
node(42, "RECTANGLE", Some(41), vec![]),
]);
let rest = mapped(&msg);
let (doc, warnings) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let outer = doc
.ids()
.filter_map(|id| doc.node(id))
.find(|n| {
matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })
&& !doc.ancestors(n.id).iter().any(|a| doc.node(*a).is_some_and(|p| matches!(p.kind, figcraft_doc::NodeKind::Instance { .. })))
})
.unwrap();
let slot = doc.node(outer.children[0]).unwrap();
let own_frame = slot.children.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap();
assert!(own_frame.source.is_none());
let nested = slot
.children
.iter()
.filter_map(|id| doc.node(*id))
.flat_map(|n| n.children.iter())
.filter_map(|id| doc.node(*id))
.find(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))
.unwrap();
let figcraft_doc::NodeKind::Instance { component, .. } = nested.kind else { panic!("not an instance") };
assert_eq!(nested.source, Some(component));
let main_frame = doc.node(doc.node(component).unwrap().children[0]).unwrap();
let expanded_frame = doc.node(nested.children[0]).unwrap();
assert_eq!(expanded_frame.source, Some(main_frame.id));
assert_eq!(expanded_frame.children.len(), 1);
assert_eq!(doc.node(expanded_frame.children[0]).unwrap().source, Some(main_frame.children[0]));
}
#[test]
fn outer_slot_assignment_wins_over_a_nested_instances_own() {
let mut msg = slot_message(Some(30), false);
let assignment = |content: u32| {
Kv::Array(vec![o(vec![
("defID", g(70)),
("value", o(vec![])),
(
"varValue",
o(vec![
("value", o(vec![("slotContentIdValue", o(vec![("guid", g(content))]))])),
("dataType", Kv::Enum("SLOT_CONTENT_ID")),
("resolvedDataType", Kv::Enum("SLOT_CONTENT_ID")),
]),
),
])])
};
// The nested instance inside the main assigns the empty slot content; the outer instance's content wins.
let slot_ref = o(vec![
("variableField", Kv::Enum("SLOT_CONTENT_ID")),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(70))]))]))])),
]);
changes(&mut msg).extend([
node(14, "INSTANCE", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), ("componentPropAssignments", assignment(33))]),
node(40, "SYMBOL", Some(2), vec![]),
node(41, "FRAME", Some(40), vec![("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![slot_ref]))]))]),
]);
let rest = mapped(&msg);
let slot = named_id(&rest["document"], "I9:20;9:14;9:41").unwrap();
assert_eq!(slot["children"].as_array().map_or(0, Vec::len), 2, "{slot}");
}
#[test]
fn unassigned_slot_keeps_default_and_bad_content_target_warns() {
let rest = mapped(&slot_message(None, false));
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
assert!(slot.get("overrides").is_none());
let (rest, warnings) = to_rest(&slot_message(Some(999), false), "Synthetic");
assert!(warnings.iter().any(|w| w.contains("slot content assignment target is missing or is not slot content")), "{warnings:?}");
let slot = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(slot["children"].as_array().unwrap().len(), 1);
let (rest, warnings) = to_rest(&slot_message(Some(33), true), "Synthetic");
let main = named_id(&rest["document"], "9:10").unwrap();
assert_eq!(main["componentPropertyDefinitions"]["Content#9:70"]["type"], "SLOT");
assert!(warnings.iter().any(|w| w.contains("preferred slot component keys cannot be resolved")), "{warnings:?}");
let empty = named_id(&rest["document"], "I9:20;9:12").unwrap();
assert_eq!(empty["children"].as_array().map_or(0, Vec::len), 0);
assert_eq!(empty[crate::rest::FIG_SOURCE_FIELD]["slotOverride"], true);
}
+8 -3
View File
@@ -17,6 +17,7 @@ struct Origin<'a> {
rest: &'a str,
guid: &'a str,
expanded: bool,
slot_content: bool,
wire_frame: bool,
wire_instance: bool,
component: Option<&'a str>,
@@ -75,7 +76,8 @@ impl<'a> Sources<'a> {
&& meta.get("wireInstance").and_then(Value::as_bool) == Some(true)
&& node.get("type").and_then(Value::as_str) == Some("INSTANCE");
let component = wire_instance.then(|| node.get("componentId").and_then(Value::as_str).filter(|id| guid(id))).flatten();
self.nodes.insert(id, Origin { rest, guid: origin, expanded, wire_frame, wire_instance, component });
let slot_content = meta.get("slotContent").and_then(Value::as_bool).unwrap_or(false);
self.nodes.insert(id, Origin { rest, guid: origin, expanded, slot_content, wire_frame, wire_instance, component });
}
fn origin(&self, id: NodeId) -> Option<Origin<'a>> {
@@ -211,6 +213,9 @@ impl<'a> Sources<'a> {
let found = (|| {
let meta = self.origin(id)?;
let _ = doc.node(id)?;
if meta.slot_content {
return if matches!(doc.node(id)?.kind, NodeKind::Instance { .. }) { self.root_source(id, doc) } else { None };
}
if !meta.expanded {
return self.root_source(id, doc);
}
@@ -269,8 +274,8 @@ impl<'a> Sources<'a> {
}
let mut missing = self.rejected;
for (&id, meta) in &self.nodes {
let needs_source =
(meta.expanded && !self.owned_layer(id, doc, ids)) || doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }));
let needs_source = (!meta.slot_content && meta.expanded && !self.owned_layer(id, doc, ids))
|| doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. }));
if !needs_source {
continue;
}
+7 -1
View File
@@ -451,6 +451,10 @@ impl<'a> Ctx<'a> {
let id = self.doc.alloc_id();
let label = kind.label();
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
{
n.overrides.insert("slot".into());
}
n.transform = local(space.world, world);
self.common(&mut n, j, parent_mode);
let (container, follows, mode) = (n.kind.is_container(), n.kind.bounds_follow_children(), n.layout.as_ref().map(|l| l.mode));
@@ -605,7 +609,8 @@ impl<'a> Ctx<'a> {
n.child_layout = props::child_layout(j, n.layout.as_ref().map(|l| l.mode), parent_mode);
n.exports = props::export_settings(j);
for (field, prop) in j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten() {
if let (Some(p), true) = (prop.as_str(), matches!(field.as_str(), "visible" | "characters" | "mainComponent")) {
let supported = matches!(field.as_str(), "visible" | "characters" | "mainComponent") || (field == "slot" && self.fig_sources.is_some());
if let (Some(p), true) = (prop.as_str(), supported) {
n.prop_refs.insert(field.clone(), p.to_string());
}
}
@@ -709,6 +714,7 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
_ => {
self.warn("unknown component property types were skipped");
continue;