makepad/libs/model/tests/surface.rs
Admin d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: 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>
2026-09-15 13:40:31 +02:00

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);
}