Squash of 25 work commits (Sep 1–12):237da90render: the sprite lane hands the screen draw back the way it found it895b9a7particles: an emitter can ride a body's own frame914471fai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets3fcccf3sim: the whole world is implicitly editable, and one seam says where the ground is5692fabsim: the landform world proves itself — walker through the tunnel includedf4af6dfsim: terrain knows who changed it — a plan layer over player historyadbb078render: a water volume can be physics without a picturec17480frender: a non-rigid body may carry its own orientationacad401sim: an agent with no route holds and retries instead of walking into the wall22b2bf9sim + chat: the composed world surface takes a map floor; the chat gets plan toolsfaf8112web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the webd3472ebtsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant4946c9esim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap4317f58sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks9791279render: support rigged models and custom materials across viewers3ce2792sim: use deck geometry for collision and sensing c8b79ff Add portable PBR, rig and soft-body authoring support 108d423 Add transactional polygon modeling and editable asset documents 38f4d3b Refine editable modeling and firm yarn character behavior 08a2637 sim: add entity-owned lights and vehicle headlights 00d96a7 render: add clustered lights, local shadows and incremental GI cf916af render: import glTF asset extensions and wire clustered GI c6d2cca model: cut transaction memory and raise capacity limits 41af47a raytrace: add a CPU probe-ray BVH budget example c963d0a libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
824 lines
31 KiB
Rust
824 lines
31 KiB
Rust
use makepad_model::*;
|
|
use std::collections::BTreeMap;
|
|
fn apply(d: &mut Document, name: &str, ops: Vec<SurfaceOperation>) {
|
|
d.apply(
|
|
Transaction {
|
|
request_id: name.into(),
|
|
expected: d.head(),
|
|
operations: ops.into_iter().map(Operation::Surface).collect(),
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
fn document() -> Document {
|
|
let mut d = Document::new(Limits::default()).unwrap();
|
|
d.apply(
|
|
Transaction {
|
|
request_id: "mesh".into(),
|
|
expected: d.head(),
|
|
operations: vec![
|
|
Operation::Plane {
|
|
object: "low".into(),
|
|
size: [2., 2.],
|
|
},
|
|
Operation::Plane {
|
|
object: "high".into(),
|
|
size: [2., 2.],
|
|
},
|
|
],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
d
|
|
}
|
|
fn layer(id: &str, ch: SurfaceChannel, color: [u8; 4], limits: &Limits) -> SurfaceOperation {
|
|
SurfaceOperation::Layer {
|
|
material: 0,
|
|
channel: ch,
|
|
layer: SurfaceLayer::new(id, RgbaImage::new(8, 8, color, limits).unwrap()),
|
|
}
|
|
}
|
|
fn read_png(loaded: &makepad_gltf::LoadedGltf, texture: usize) -> RgbaImage {
|
|
let index = loaded.document.textures_slice()[texture].source.unwrap();
|
|
RgbaImage::from_png(
|
|
&makepad_gltf::load_image_bytes(loaded, index).unwrap(),
|
|
&Limits::default(),
|
|
)
|
|
.unwrap()
|
|
}
|
|
#[test]
|
|
fn factor_only_conversion_preserves_legacy_pixels_and_explicit_layer_removal() {
|
|
let mut d = document();
|
|
d.apply(Transaction {
|
|
request_id: "legacy-paint".into(), expected: d.head(),
|
|
operations: vec![Operation::TextureSolid { material: 0, width: 4, height: 4, color: [90, 70, 45] }],
|
|
}, None).unwrap();
|
|
let original = d.materials()[&0].base_color_png.clone();
|
|
let factors = SurfaceMaterial { roughness: 0.3, ..Default::default() };
|
|
apply(&mut d, "surface-factors", vec![SurfaceOperation::Material { material: 0, value: factors.clone() }]);
|
|
assert_eq!(d.surface().materials[&0].channels[&SurfaceChannel::BaseColor][0].id, "legacy");
|
|
let loaded = makepad_gltf::load_gltf_from_bytes(&d.compile(None).unwrap().glb, None).unwrap();
|
|
for primitive in loaded.document.meshes_slice().iter().flat_map(|mesh| &mesh.primitives) {
|
|
let material = &loaded.document.materials_slice()[primitive.material.unwrap()];
|
|
let pbr = material.pbr_metallic_roughness.as_ref().unwrap();
|
|
let image = read_png(&loaded, pbr.base_color_texture.as_ref().unwrap().index);
|
|
assert!(image.pixels.chunks_exact(4).all(|pixel| pixel == [90, 70, 45, 255]));
|
|
}
|
|
assert_eq!(d.materials()[&0].base_color_png, original);
|
|
apply(&mut d, "remove-paint", vec![SurfaceOperation::RemoveLayer {
|
|
material: 0, channel: SurfaceChannel::BaseColor, layer: "legacy".into(),
|
|
}, SurfaceOperation::Material { material: 0, value: factors }]);
|
|
assert!(d.surface().materials[&0].channels[&SurfaceChannel::BaseColor].is_empty(),
|
|
"factor edits must not resurrect an explicitly removed legacy layer");
|
|
}
|
|
|
|
#[test]
|
|
fn derivation_seeds_a_legacy_bitmap_without_overwriting_its_pixels() {
|
|
let mut d = document();
|
|
let limits = d.limits().clone();
|
|
let mut source = RgbaImage::new(8, 8, [0, 0, 0, 255], &limits).unwrap();
|
|
for (index, pixel) in source.pixels.chunks_exact_mut(4).enumerate() {
|
|
pixel[..3].fill((index / 8 * 30) as u8);
|
|
}
|
|
d.apply(Transaction {
|
|
request_id: "legacy-ramp".into(), expected: d.head(),
|
|
operations: vec![Operation::SetMaterial { material: 0, value: Material {
|
|
base_color_png: source.to_png(&limits).unwrap(), ..Default::default()
|
|
}}],
|
|
}, None).unwrap();
|
|
let recipe = SurfaceDerivation { source_channel: SurfaceChannel::BaseColor, source_layer: None,
|
|
strength: 0.05, roughness_min: 0.2, roughness_max: 0.9, metallic: 0., wrap: false };
|
|
apply(&mut d, "derive-from-legacy", vec![SurfaceOperation::Derive {
|
|
material: 0, channel: SurfaceChannel::Normal, layer: "relief".into(), recipe,
|
|
}]);
|
|
let material = &d.surface().materials[&0];
|
|
assert_eq!(material.channels[&SurfaceChannel::BaseColor][0].image, source);
|
|
assert!(material.channels[&SurfaceChannel::Normal][0].image.pixels[(3 * 8 + 3) * 4 + 1] < 128);
|
|
let bytes = d.to_snapshot_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&bytes, limits, None).unwrap();
|
|
assert_eq!(reopened.surface(), d.surface());
|
|
let loaded = makepad_gltf::load_gltf_from_bytes(&reopened.compile(None).unwrap().glb, None).unwrap();
|
|
let primitive = &loaded.document.meshes_slice()[0].primitives[0];
|
|
let material = &loaded.document.materials_slice()[primitive.material.unwrap()];
|
|
let pbr = material.pbr_metallic_roughness.as_ref().unwrap();
|
|
assert_eq!(read_png(&loaded, pbr.base_color_texture.as_ref().unwrap().index), source);
|
|
assert!(read_png(&loaded, material.normal_texture.as_ref().unwrap().index).pixels[(3 * 8 + 3) * 4 + 1] < 128);
|
|
}
|
|
|
|
#[test]
|
|
fn rgba_roundtrip_linear_mips_normal_filter_and_coverage_dilation() {
|
|
let l = Limits::default();
|
|
let mut image = RgbaImage::new(3, 1, [0; 4], &l).unwrap();
|
|
image.pixels = vec![255, 0, 0, 255, 0, 0, 255, 0, 255, 0, 0, 255];
|
|
assert_eq!(
|
|
RgbaImage::from_png(&image.to_png(&l).unwrap(), &l).unwrap(),
|
|
image
|
|
);
|
|
let mip = image
|
|
.mip_chain(ImageEncoding::Srgb, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
assert_eq!(mip[0].pixels, vec![255, 0, 0, 170]);
|
|
let mut image = RgbaImage::new(2, 1, [255; 4], &l).unwrap();
|
|
image.pixels[..4].copy_from_slice(&[0, 0, 0, 255]);
|
|
let mip = image
|
|
.mip_chain(ImageEncoding::Srgb, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
assert!((mip[0].pixels[0] as i32 - 188).abs() <= 1);
|
|
let mut image = RgbaImage::new(2, 1, [128, 128, 255, 255], &l).unwrap();
|
|
image.pixels[..4].copy_from_slice(&[255, 128, 128, 255]);
|
|
let mip = image
|
|
.mip_chain(ImageEncoding::Normal, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
let n = &mip[0].pixels;
|
|
assert!(n[0] > 210 && n[2] > 210);
|
|
let mut image = RgbaImage::new(5, 1, [0; 4], &l).unwrap();
|
|
image.pixels[..4].copy_from_slice(&[20, 40, 60, 255]);
|
|
image
|
|
.dilate(4, true, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
assert_eq!(&image.pixels[16..], [20, 40, 60, 0]);
|
|
}
|
|
#[test]
|
|
fn layers_masks_pattern_stroke_reorder_and_source_history_roundtrip() {
|
|
let mut d = document();
|
|
let limits = d.limits().clone();
|
|
apply(
|
|
&mut d,
|
|
"layers",
|
|
vec![
|
|
layer("base", SurfaceChannel::BaseColor, [0, 0, 255, 255], &limits),
|
|
layer(
|
|
"paint",
|
|
SurfaceChannel::BaseColor,
|
|
[255, 0, 0, 255],
|
|
&limits,
|
|
),
|
|
],
|
|
);
|
|
apply(
|
|
&mut d,
|
|
"mask",
|
|
vec![SurfaceOperation::Mask {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
mask: Some(vec![128; 64]),
|
|
}],
|
|
);
|
|
let mixed = d.surface().materials[&0]
|
|
.flatten(
|
|
SurfaceChannel::BaseColor,
|
|
d.limits(),
|
|
&mut mesh::Context::default(),
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!((mixed.pixels[0] as i32 - 188).abs() < 2 && mixed.pixels[2] > 180);
|
|
apply(
|
|
&mut d,
|
|
"pattern",
|
|
vec![SurfaceOperation::Pattern {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
pattern: SurfacePattern {
|
|
kind: PatternKind::Checker,
|
|
color_a: [1., 0., 0., 1.],
|
|
color_b: [0., 1., 0., 1.],
|
|
scale: [4., 4.],
|
|
seed: 19,
|
|
},
|
|
}],
|
|
);
|
|
apply(
|
|
&mut d,
|
|
"stroke",
|
|
vec![
|
|
SurfaceOperation::Stroke {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
points: vec![[0., 0.5], [1., 0.5]],
|
|
radius: 0.15,
|
|
hardness: 1.,
|
|
opacity: 1.,
|
|
color: [1.; 4],
|
|
mask: false,
|
|
},
|
|
SurfaceOperation::Mips {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
},
|
|
],
|
|
);
|
|
let l = &d.surface().materials[&0].channels[&SurfaceChannel::BaseColor][1];
|
|
assert_eq!(&l.image.pixels[(3 * 8 + 4) * 4..][..4], [255; 4]);
|
|
assert!(l.pattern.is_none());
|
|
assert_eq!(l.mips.last().unwrap().width, 1);
|
|
let before = d.to_bytes(None).unwrap();
|
|
let head = d.head();
|
|
apply(
|
|
&mut d,
|
|
"move",
|
|
vec![SurfaceOperation::MoveLayer {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "base".into(),
|
|
index: 1,
|
|
}],
|
|
);
|
|
d.undo(d.head(), None).unwrap();
|
|
assert_eq!(d.head().content, head.content);
|
|
d.redo(d.head(), None).unwrap();
|
|
let restored = Document::from_bytes(&before, Limits::default(), None).unwrap();
|
|
assert_eq!(restored.to_bytes(None).unwrap(), before);
|
|
assert_eq!(
|
|
restored.surface().materials,
|
|
d.surface()
|
|
.materials
|
|
.clone()
|
|
.into_iter()
|
|
.map(|(id, mut m)| {
|
|
m.channels
|
|
.get_mut(&SurfaceChannel::BaseColor)
|
|
.unwrap()
|
|
.reverse();
|
|
(id, m)
|
|
})
|
|
.collect()
|
|
);
|
|
}
|
|
#[test]
|
|
fn pbr_all_channels_factors_mips_vertex_colors_and_tangents_survive_export() {
|
|
let mut d = document();
|
|
let ids = d
|
|
.object("low")
|
|
.unwrap()
|
|
.vertices()
|
|
.iter()
|
|
.map(|v| v.id)
|
|
.collect();
|
|
let m = SurfaceMaterial {
|
|
base_color: [0.4, 0.6, 0.8, 0.5],
|
|
metallic: 0.7,
|
|
roughness: 0.3,
|
|
normal_scale: 0.8,
|
|
occlusion_strength: 0.6,
|
|
emissive: [0.2, 0.4, 0.6],
|
|
emissive_strength: 12.,
|
|
alpha: SurfaceAlpha::Mask,
|
|
alpha_cutoff: 0.2,
|
|
double_sided: true,
|
|
..Default::default()
|
|
};
|
|
let l = d.limits().clone();
|
|
apply(
|
|
&mut d,
|
|
"pbr",
|
|
vec![
|
|
SurfaceOperation::Material {
|
|
material: 0,
|
|
value: m,
|
|
},
|
|
layer("color", SurfaceChannel::BaseColor, [200, 120, 40, 100], &l),
|
|
layer(
|
|
"mr",
|
|
SurfaceChannel::MetallicRoughness,
|
|
[0, 80, 190, 255],
|
|
&l,
|
|
),
|
|
layer("normal", SurfaceChannel::Normal, [128, 128, 255, 255], &l),
|
|
layer("ao", SurfaceChannel::Occlusion, [100, 100, 100, 255], &l),
|
|
layer("emit", SurfaceChannel::Emissive, [40, 80, 120, 255], &l),
|
|
SurfaceOperation::VertexPaint {
|
|
object: "low".into(),
|
|
vertices: ids,
|
|
color: [0.1, 0.2, 0.3, 0.4],
|
|
opacity: 1.,
|
|
},
|
|
],
|
|
);
|
|
let source = d.to_bytes(None).unwrap();
|
|
let compiled = d.compile(None).unwrap();
|
|
assert_eq!(source, d.to_bytes(None).unwrap());
|
|
let loaded = makepad_gltf::load_gltf_from_bytes(&compiled.glb, None).unwrap();
|
|
for primitive in &loaded.document.meshes_slice()[0].primitives {
|
|
let m = &loaded.document.materials_slice()[primitive.material.unwrap()];
|
|
let p = m.pbr_metallic_roughness.as_ref().unwrap();
|
|
assert_eq!(p.base_color_factor, Some([0.4, 0.6, 0.8, 0.5]));
|
|
assert_eq!(p.metallic_factor, Some(0.7));
|
|
assert_eq!(p.roughness_factor, Some(0.3));
|
|
assert_eq!(m.alpha_mode.as_deref(), Some("MASK"));
|
|
assert_eq!(m.double_sided, Some(true));
|
|
assert_eq!(m.alpha_cutoff, Some(0.2));
|
|
assert_eq!(
|
|
read_png(&loaded, p.base_color_texture.as_ref().unwrap().index).pixels[3],
|
|
100
|
|
);
|
|
assert_eq!(
|
|
read_png(
|
|
&loaded,
|
|
p.metallic_roughness_texture.as_ref().unwrap().index
|
|
)
|
|
.pixels[1],
|
|
80
|
|
);
|
|
assert_eq!(
|
|
read_png(&loaded, m.occlusion_texture.as_ref().unwrap().index).pixels[0],
|
|
100
|
|
);
|
|
assert_eq!(
|
|
read_png(&loaded, m.emissive_texture.as_ref().unwrap().index).pixels[2],
|
|
120
|
|
);
|
|
assert!(m.normal_texture.is_some());
|
|
assert!(format!("{:?}", m.extensions).contains("emissiveStrength"));
|
|
assert!(format!("{:?}", m.extras).contains("makepadMips"));
|
|
assert!(primitive.attributes.contains_key("TANGENT"));
|
|
assert!(primitive.attributes.contains_key("COLOR_0"));
|
|
}
|
|
for image in loaded.document.images_slice() {
|
|
assert!(image.uri.is_none());
|
|
assert!(image.buffer_view.is_some());
|
|
}
|
|
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
|
|
assert_eq!(reopened.compile(None).unwrap().glb, compiled.glb);
|
|
}
|
|
#[test]
|
|
fn cancellation_and_budget_failures_leave_complete_surface_unchanged() {
|
|
let l = Limits::default();
|
|
let mut state = SurfaceState::default();
|
|
let legacy = BTreeMap::from([(0, Material::default())]);
|
|
let objects = BTreeMap::new();
|
|
state
|
|
.apply(
|
|
&layer("paint", SurfaceChannel::BaseColor, [0, 0, 255, 255], &l),
|
|
&objects,
|
|
&legacy,
|
|
&l,
|
|
&mut mesh::Context::default(),
|
|
)
|
|
.unwrap();
|
|
let before = state.clone();
|
|
let op = SurfaceOperation::Stroke {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
points: vec![[0., 0.], [1., 1.]],
|
|
radius: 1.,
|
|
hardness: 1.,
|
|
opacity: 1.,
|
|
color: [1.; 4],
|
|
mask: false,
|
|
};
|
|
let calls = std::cell::Cell::new(0);
|
|
let cancel = || {
|
|
calls.set(calls.get() + 1);
|
|
calls.get() > 4
|
|
};
|
|
assert!(state
|
|
.apply(
|
|
&op,
|
|
&objects,
|
|
&legacy,
|
|
&l,
|
|
&mut mesh::Context::new(l.mesh.clone(), Some(&cancel))
|
|
)
|
|
.is_err());
|
|
assert_eq!(state, before);
|
|
let mut tiny = l.clone();
|
|
tiny.mesh.max_work = 5;
|
|
assert!(state
|
|
.apply(
|
|
&op,
|
|
&objects,
|
|
&legacy,
|
|
&tiny,
|
|
&mut mesh::Context::new(tiny.mesh.clone(), None)
|
|
)
|
|
.is_err());
|
|
assert_eq!(state, before);
|
|
let bad = SurfaceOperation::Mask {
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
mask: Some(vec![0]),
|
|
};
|
|
assert!(state
|
|
.apply(&bad, &objects, &legacy, &l, &mut mesh::Context::default())
|
|
.is_err());
|
|
assert_eq!(state, before);
|
|
}
|
|
#[test]
|
|
fn normal_ao_color_transfer_projected_stroke_and_reconcile() {
|
|
let mut d = document();
|
|
let l = d.limits().clone();
|
|
let mut ops = vec![layer(
|
|
"paint",
|
|
SurfaceChannel::BaseColor,
|
|
[30, 100, 220, 255],
|
|
&l,
|
|
)];
|
|
for (channel, name) in [
|
|
(SurfaceChannel::Normal, "normals"),
|
|
(SurfaceChannel::Occlusion, "occlusion"),
|
|
(SurfaceChannel::BaseColor, "transfer"),
|
|
] {
|
|
ops.push(SurfaceOperation::Bake {
|
|
source: "high".into(),
|
|
target: "low".into(),
|
|
material: 0,
|
|
channel,
|
|
layer: name.into(),
|
|
width: 8,
|
|
height: 8,
|
|
max_distance: 1.,
|
|
ao_samples: 8,
|
|
ao_distance: 1.,
|
|
dilation: 1,
|
|
});
|
|
}
|
|
apply(&mut d, "bake", ops);
|
|
let materials = &d.surface().materials[&0];
|
|
assert!(materials.channels[&SurfaceChannel::Normal][0]
|
|
.image
|
|
.pixels
|
|
.chunks_exact(4)
|
|
.all(|p| p[2] > 250));
|
|
assert!(materials.channels[&SurfaceChannel::Occlusion][0]
|
|
.image
|
|
.pixels
|
|
.chunks_exact(4)
|
|
.all(|p| p[0] == 255));
|
|
assert_eq!(
|
|
&materials.channels[&SurfaceChannel::BaseColor][1]
|
|
.image
|
|
.pixels[..4],
|
|
[30, 100, 220, 255]
|
|
);
|
|
let mesh = d.object("low").unwrap();
|
|
let normal = mesh
|
|
.triangulate(&mut mesh::Context::default())
|
|
.unwrap()
|
|
.vertices[0]
|
|
.normal;
|
|
let origin = normal.map(|v| v * 2.);
|
|
let direction = normal.map(|v| -v);
|
|
apply(
|
|
&mut d,
|
|
"project",
|
|
vec![SurfaceOperation::ProjectedStroke {
|
|
object: "low".into(),
|
|
material: 0,
|
|
channel: SurfaceChannel::BaseColor,
|
|
layer: "paint".into(),
|
|
origin,
|
|
direction,
|
|
radius: 0.5,
|
|
depth: 3.,
|
|
hardness: 1.,
|
|
opacity: 1.,
|
|
color: [1., 0., 0., 1.],
|
|
mask: false,
|
|
}],
|
|
);
|
|
assert!(
|
|
d.surface().materials[&0].channels[&SurfaceChannel::BaseColor][0]
|
|
.image
|
|
.pixels
|
|
.chunks_exact(4)
|
|
.any(|p| p == [255, 0, 0, 255])
|
|
);
|
|
let vertices = d
|
|
.object("low")
|
|
.unwrap()
|
|
.vertices()
|
|
.iter()
|
|
.map(|v| v.id)
|
|
.collect();
|
|
apply(
|
|
&mut d,
|
|
"vertex",
|
|
vec![SurfaceOperation::VertexPaint {
|
|
object: "low".into(),
|
|
vertices,
|
|
color: [0.2, 0.4, 0.6, 1.],
|
|
opacity: 1.,
|
|
}],
|
|
);
|
|
let before = BTreeMap::from([("low".into(), d.object("low").unwrap().clone())]);
|
|
let mut after = before.clone();
|
|
let mesh = after.get_mut("low").unwrap();
|
|
mesh.subdivide(1, &mut mesh::Context::default()).unwrap();
|
|
let mut state = d.surface().clone();
|
|
state
|
|
.reconcile(&before, &after, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
assert!(state.vertex_colors.len() > d.surface().vertex_colors.len());
|
|
assert!(state.vertex_colors.values().all(|c| c
|
|
.iter()
|
|
.zip([0.2, 0.4, 0.6, 1.])
|
|
.all(|(x, y)| (*x - y).abs() < 1e-10)));
|
|
after.clear();
|
|
state
|
|
.reconcile(&before, &after, &l, &mut mesh::Context::default())
|
|
.unwrap();
|
|
assert!(state.vertex_colors.is_empty());
|
|
}
|
|
#[test]
|
|
fn strict_surface_json_unknown_fields_bad_payloads_and_all_operation_codecs() {
|
|
let l = Limits::default();
|
|
for text in [
|
|
r#"{"op":"surface_material","material":0,"metallic":2}"#,
|
|
r#"{"op":"surface_layer","material":0,"channel":"base_color","layer":"x","width":2,"height":2,"rgba_hex":"FF"}"#,
|
|
r#"{"op":"surface_mips","material":0,"channel":"base_color","layer":"x","surprise":1}"#,
|
|
r#"{"op":"surface_unknown"}"#,
|
|
] {
|
|
let value = json::parse(text.as_bytes()).unwrap();
|
|
assert!(SurfaceOperation::parse(&value, &l).is_err());
|
|
}
|
|
let mut d = document();
|
|
for (i,text) in [r#"{"op":"surface_material","material":0,"metallic":0.5,"alpha":"blend"}"#,r#"{"op":"surface_layer","material":0,"channel":"base_color","layer":"x","width":2,"height":2,"color":[1,0,0,0.5]}"#,r#"{"op":"surface_pattern","material":0,"channel":"base_color","layer":"x","pattern":{"kind":"noise","color_a":[1,0,0,1],"color_b":[0,1,0,1],"seed":"42"}}"#,r#"{"op":"surface_mask","material":0,"channel":"base_color","layer":"x","mask_hex":"ff0080ff"}"#,r#"{"op":"surface_dilate","material":0,"channel":"base_color","layer":"x","iterations":1}"#,r#"{"op":"surface_mips","material":0,"channel":"base_color","layer":"x"}"#].iter().enumerate() {let value=json::parse(text.as_bytes()).unwrap();apply(&mut d,&format!("json{i}"),vec![SurfaceOperation::parse(&value,&l).unwrap().unwrap()]);}
|
|
let bytes = d.to_bytes(None).unwrap();
|
|
assert_eq!(
|
|
Document::from_bytes(&bytes, l, None)
|
|
.unwrap()
|
|
.to_bytes(None)
|
|
.unwrap(),
|
|
bytes
|
|
);
|
|
}
|
|
#[test]
|
|
fn skinned_pbr_keeps_distinct_materials_uvs_skin_and_animation() {
|
|
let mut d = Document::new(Limits::default()).unwrap();
|
|
d.apply(
|
|
Transaction {
|
|
request_id: "body".into(),
|
|
expected: d.head(),
|
|
operations: vec![Operation::Cube {
|
|
object: "body".into(),
|
|
size: [1.; 3],
|
|
}],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let face = d.object("body").unwrap().faces()[0].id;
|
|
d.apply(
|
|
Transaction {
|
|
request_id: "rig".into(),
|
|
expected: d.head(),
|
|
operations: vec![
|
|
Operation::SetMaterial {
|
|
material: 1,
|
|
value: Material::default(),
|
|
},
|
|
Operation::AssignMaterial {
|
|
object: "body".into(),
|
|
faces: vec![face],
|
|
material: 1,
|
|
},
|
|
Operation::SetSkeleton {
|
|
skeleton: Skeleton {
|
|
joints: vec![Joint {
|
|
name: "root".into(),
|
|
parent: None,
|
|
translation: [0.; 3],
|
|
}],
|
|
},
|
|
},
|
|
Operation::AutoWeights {
|
|
object: "body".into(),
|
|
},
|
|
Operation::SetClip {
|
|
clip: AnimationClip {
|
|
name: "idle".into(),
|
|
channels: vec![AnimationChannel {
|
|
joint: 0,
|
|
path: AnimationPath::Translation,
|
|
keys: vec![
|
|
Keyframe {
|
|
time: 0.,
|
|
value: [0.; 4],
|
|
},
|
|
Keyframe {
|
|
time: 1.,
|
|
value: [0., 0.1, 0., 0.],
|
|
},
|
|
],
|
|
}],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let l = d.limits().clone();
|
|
let mut other = layer("blue", SurfaceChannel::BaseColor, [0, 0, 255, 255], &l);
|
|
if let SurfaceOperation::Layer { material, .. } = &mut other {
|
|
*material = 1;
|
|
}
|
|
apply(
|
|
&mut d,
|
|
"paint",
|
|
vec![
|
|
layer("red", SurfaceChannel::BaseColor, [255, 0, 0, 255], &l),
|
|
other,
|
|
],
|
|
);
|
|
let source = d.to_bytes(None).unwrap();
|
|
let out = d.compile(None).unwrap();
|
|
let loaded = makepad_gltf::load_gltf_from_bytes(&out.glb, None).unwrap();
|
|
let mesh = &loaded.document.meshes_slice()[0];
|
|
assert_eq!(mesh.primitives.len(), 2);
|
|
assert_ne!(mesh.primitives[0].material, mesh.primitives[1].material);
|
|
let colors: Vec<_> = mesh
|
|
.primitives
|
|
.iter()
|
|
.map(|p| {
|
|
assert!(p.attributes.contains_key("JOINTS_0"));
|
|
assert!(p.attributes.contains_key("WEIGHTS_0"));
|
|
let mat = &loaded.document.materials_slice()[p.material.unwrap()];
|
|
let tex = mat
|
|
.pbr_metallic_roughness
|
|
.as_ref()
|
|
.unwrap()
|
|
.base_color_texture
|
|
.as_ref()
|
|
.unwrap();
|
|
read_png(&loaded, tex.index).pixels[..4].to_vec()
|
|
})
|
|
.collect();
|
|
assert_eq!(colors, vec![vec![255, 0, 0, 255], vec![0, 0, 255, 255]]);
|
|
assert_eq!(loaded.document.skins.as_ref().unwrap().len(), 1);
|
|
assert_eq!(loaded.document.animations.as_ref().unwrap().len(), 1);
|
|
assert_eq!(
|
|
Document::from_bytes(&source, l, None)
|
|
.unwrap()
|
|
.compile(None)
|
|
.unwrap()
|
|
.glb,
|
|
out.glb
|
|
);
|
|
assert_eq!(d.to_bytes(None).unwrap(), source);
|
|
}
|
|
#[test]
|
|
fn ao_rays_detect_overhang_and_hdr_emissive_transfer_retains_strength() {
|
|
let l = Limits::default();
|
|
let positions = [
|
|
[-1., 0., -1.],
|
|
[-1., 0., 1.],
|
|
[1., 0., 1.],
|
|
[1., 0., -1.],
|
|
[-1., 0.3, -1.],
|
|
[-1., 0.3, 1.],
|
|
[1., 0.3, 1.],
|
|
[1., 0.3, -1.],
|
|
];
|
|
let uv = vec![[0., 0.], [0., 1.], [1., 1.], [1., 0.]];
|
|
let high = mesh::Mesh::from_polygons(
|
|
&positions,
|
|
&[
|
|
mesh::Polygon {
|
|
vertices: vec![0, 1, 2, 3],
|
|
uvs: uv.clone(),
|
|
material: 0,
|
|
},
|
|
mesh::Polygon {
|
|
vertices: vec![7, 6, 5, 4],
|
|
uvs: uv.clone(),
|
|
material: 0,
|
|
},
|
|
],
|
|
&mut mesh::Context::default(),
|
|
)
|
|
.unwrap();
|
|
let low = mesh::Mesh::from_polygons(
|
|
&positions[..4],
|
|
&[mesh::Polygon {
|
|
vertices: vec![0, 1, 2, 3],
|
|
uvs: uv,
|
|
material: 0,
|
|
}],
|
|
&mut mesh::Context::default(),
|
|
)
|
|
.unwrap();
|
|
let objects = BTreeMap::from([("low".into(), low), ("high".into(), high)]);
|
|
let legacy = BTreeMap::from([(0, Material::default())]);
|
|
let mut s = SurfaceState::default();
|
|
s.materials.insert(
|
|
0,
|
|
SurfaceMaterial {
|
|
emissive: [0.25, 0.5, 1.],
|
|
emissive_strength: 30.,
|
|
..Default::default()
|
|
},
|
|
);
|
|
for (channel, name) in [
|
|
(SurfaceChannel::Occlusion, "ao"),
|
|
(SurfaceChannel::Emissive, "emission"),
|
|
] {
|
|
s.apply(
|
|
&SurfaceOperation::Bake {
|
|
source: "high".into(),
|
|
target: "low".into(),
|
|
material: 0,
|
|
channel,
|
|
layer: name.into(),
|
|
width: 8,
|
|
height: 8,
|
|
max_distance: 0.1,
|
|
ao_samples: 32,
|
|
ao_distance: 2.,
|
|
dilation: 0,
|
|
},
|
|
&objects,
|
|
&legacy,
|
|
&l,
|
|
&mut mesh::Context::default(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
let ao = &s.materials[&0].channels[&SurfaceChannel::Occlusion][0].image;
|
|
assert!(ao.pixels.chunks_exact(4).any(|p| p[0] < 64));
|
|
let mat = &s.materials[&0];
|
|
assert_eq!(mat.emissive_strength, 30.);
|
|
let emit = mat
|
|
.flatten(SurfaceChannel::Emissive, &l, &mut mesh::Context::default())
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(emit.pixels[0] > 130 && emit.pixels[1] > 180 && emit.pixels[2] == 255);
|
|
}
|
|
|
|
#[test]
|
|
fn bitmap_procedural_stack_derives_pbr_snapshots_and_roundtrips_export() {
|
|
let mut d = document();
|
|
let limits = d.limits().clone();
|
|
let png = RgbaImage::new(16, 16, [180, 140, 90, 255], &limits).unwrap().to_png(&limits).unwrap();
|
|
let hex: String = png.iter().map(|byte| format!("{byte:02x}")).collect();
|
|
let bitmap = json::parse(format!(r#"{{"op":"surface_layer","material":0,"channel":"base_color","layer":"generated-paint","png_hex":"{hex}"}}"#).as_bytes()).unwrap();
|
|
apply(&mut d, "bitmap", vec![SurfaceOperation::parse(&bitmap, &limits).unwrap().unwrap()]);
|
|
for (index, kind) in [PatternKind::Gradient, PatternKind::Perlin, PatternKind::Fbm, PatternKind::Yarn].into_iter().enumerate() {
|
|
let id = format!("detail-{index}");
|
|
let mut layer = SurfaceLayer::new(&id, RgbaImage::new(16, 16, [255; 4], &limits).unwrap());
|
|
layer.blend = SurfaceBlend::Multiply;
|
|
layer.opacity = 0.35;
|
|
apply(&mut d, &format!("pattern-{index}"), vec![
|
|
SurfaceOperation::Layer { material: 0, channel: SurfaceChannel::BaseColor, layer },
|
|
SurfaceOperation::Pattern { material: 0, channel: SurfaceChannel::BaseColor, layer: id,
|
|
pattern: SurfacePattern { kind, color_a: [0.2, 0.2, 0.2, 1.], color_b: [1.; 4], scale: [2., 2.], seed: 918 } },
|
|
]);
|
|
}
|
|
let original_layers = d.surface().materials[&0].channels[&SurfaceChannel::BaseColor].clone();
|
|
let mut derivations = Vec::new();
|
|
for (channel, layer) in [("normal", "height-normal"), ("metallic_roughness", "height-roughness")] {
|
|
let json = json::parse(format!(r#"{{"op":"surface_derive","material":0,"source_channel":"base_color","channel":"{channel}","layer":"{layer}","strength":0.05,"metallic":0.7}}"#).as_bytes()).unwrap();
|
|
derivations.push(SurfaceOperation::parse(&json, &limits).unwrap().unwrap());
|
|
}
|
|
apply(&mut d, "derive", derivations.clone());
|
|
let m = &d.surface().materials[&0];
|
|
assert_eq!(m.channels[&SurfaceChannel::BaseColor], original_layers);
|
|
assert_eq!(m.metallic, 1.);
|
|
assert_eq!(m.roughness, 1.);
|
|
assert!(m.channels[&SurfaceChannel::Normal][0].derived.is_some());
|
|
let normal = m.flatten(SurfaceChannel::Normal, &limits, &mut mesh::Context::default()).unwrap().unwrap();
|
|
let rough = m.flatten(SurfaceChannel::MetallicRoughness, &limits, &mut mesh::Context::default()).unwrap().unwrap();
|
|
assert!(normal.pixels.chunks_exact(4).any(|p| p[0] != 128 || p[1] != 128));
|
|
assert!(rough.pixels.chunks_exact(4).all(|p| (51..=230).contains(&p[1]) && p[2] == 179));
|
|
assert!(rough.pixels.chunks_exact(4).map(|p| p[1]).any(|r| r != rough.pixels[1]));
|
|
let source = d.to_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&source, limits.clone(), None).unwrap();
|
|
assert_eq!(reopened.to_bytes(None).unwrap(), source);
|
|
assert_eq!(reopened.surface(), d.surface());
|
|
let glb = d.compile(None).unwrap().glb;
|
|
assert_eq!(reopened.compile(None).unwrap().glb, glb);
|
|
let loaded = makepad_gltf::load_gltf_from_bytes(&glb, None).unwrap();
|
|
let material = loaded.document.materials_slice().iter().find(|m| m.normal_texture.is_some()).unwrap();
|
|
assert_eq!(read_png(&loaded, material.normal_texture.as_ref().unwrap().index), normal);
|
|
assert_eq!(read_png(&loaded, material.pbr_metallic_roughness.as_ref().unwrap().metallic_roughness_texture.as_ref().unwrap().index), rough);
|
|
let old_snapshot = d.surface().materials[&0].channels[&SurfaceChannel::Normal][0].image.clone();
|
|
apply(&mut d, "change-height", vec![SurfaceOperation::Pattern { material: 0, channel: SurfaceChannel::BaseColor, layer: "detail-3".into(),
|
|
pattern: SurfacePattern { kind: PatternKind::Yarn, color_a: [0.; 4], color_b: [0.; 4], scale: [2., 2.], seed: 11 } }]);
|
|
assert_eq!(d.surface().materials[&0].channels[&SurfaceChannel::Normal][0].image, old_snapshot, "derived output is an explicit snapshot");
|
|
apply(&mut d, "derive-again", derivations);
|
|
assert_ne!(d.surface().materials[&0].channels[&SurfaceChannel::Normal][0].image, old_snapshot);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_rejects_invalid_recipes_and_rolls_back_unknown_sources() {
|
|
let limits = Limits::default();
|
|
for extra in ["\"strength\":17", "\"roughness_min\":0.9,\"roughness_max\":0.2", "\"wrap\":\"yes\"", "\"source_layer\":\"\"", "\"unexpected\":1"] {
|
|
let json = json::parse(format!(r#"{{"op":"surface_derive","material":0,"source_channel":"base_color","channel":"normal","layer":"n",{extra}}}"#).as_bytes()).unwrap();
|
|
assert!(SurfaceOperation::parse(&json, &limits).is_err());
|
|
}
|
|
let mut d = document();
|
|
let before = d.to_bytes(None).unwrap();
|
|
let json = json::parse(br#"{"op":"surface_derive","material":0,"source_channel":"base_color","source_layer":"missing","channel":"normal","layer":"n"}"#).unwrap();
|
|
let operation = SurfaceOperation::parse(&json, &limits).unwrap().unwrap();
|
|
assert!(d.apply(Transaction { request_id: "bad-source".into(), expected: d.head(), operations: vec![Operation::Surface(operation)] }, None).is_err());
|
|
assert_eq!(d.to_bytes(None).unwrap(), before);
|
|
}
|