Skip to main content

pfx_bake/
detail.rs

1use std::collections::VecDeque;
2use std::fs;
3use std::path::Path;
4
5use pfx_materials::{At, Material, resolve};
6use serde_json::{Value, json};
7use sha2::{Digest, Sha256};
8
9pub mod atlas;
10pub mod ktx2;
11
12pub const LIMIT: u32 = 4096;
13pub const FLOOR: u32 = 64;
14
15const NEAR: f32 = 0.4;
16const FAR: f32 = 6.0;
17
18pub struct Input<'a> {
19    pub positions: &'a [[f32; 3]],
20    pub normals: &'a [[f32; 3]],
21    pub tangents: &'a [[f32; 4]],
22    pub uvs: &'a [[f32; 2]],
23    pub uvs1: Option<&'a [[f32; 2]]>,
24    pub indices: &'a [u32],
25    pub material: &'a Material,
26}
27
28impl Input<'_> {
29    pub fn raster(&self) -> &[[f32; 2]] {
30        self.uvs1.unwrap_or(self.uvs)
31    }
32
33    pub fn uv_set(&self) -> u32 {
34        u32::from(self.uvs1.is_some())
35    }
36}
37
38pub struct View {
39    pub matrix: [[f32; 4]; 4],
40    pub size: [u32; 2],
41}
42
43impl View {
44    pub fn look(camera: &crate::recipe::DetailView) -> Self {
45        let eye = camera.position;
46        let forward = unit(sub(camera.target, eye));
47        let right = unit(cross(forward, [0.0, 1.0, 0.0]));
48        let up = cross(right, forward);
49        let view = [
50            [right[0], up[0], -forward[0], 0.0],
51            [right[1], up[1], -forward[1], 0.0],
52            [right[2], up[2], -forward[2], 0.0],
53            [-dot(right, eye), -dot(up, eye), dot(forward, eye), 1.0],
54        ];
55        let tan_y = (camera.fov_y_deg.to_radians() * 0.5).tan();
56        let tan_x = tan_y * camera.width as f32 / camera.height as f32;
57        let projection = [
58            [1.0 / tan_x, 0.0, 0.0, 0.0],
59            [0.0, 1.0 / tan_y, 0.0, 0.0],
60            [camera.shift[0], camera.shift[1], FAR / (NEAR - FAR), -1.0],
61            [0.0, 0.0, FAR * NEAR / (NEAR - FAR), 0.0],
62        ];
63        let mut matrix = [[0.0; 4]; 4];
64        for (column, out) in matrix.iter_mut().enumerate() {
65            for (row, value) in out.iter_mut().enumerate() {
66                *value = (0..4).map(|k| projection[k][row] * view[column][k]).sum();
67            }
68        }
69        Self {
70            matrix,
71            size: [camera.width, camera.height],
72        }
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct Part {
78    pub path: String,
79    pub node: String,
80    pub size: u32,
81    pub crop: Crop,
82    pub page: u32,
83    pub bytes: u64,
84    pub input_hash: String,
85    pub uv_set: u32,
86}
87
88#[derive(Clone, Debug, PartialEq)]
89pub struct Report {
90    pub parts: Vec<Part>,
91    pub total_bytes: u64,
92    pub vram_bytes: u64,
93    pub decoded_vram_bytes: u64,
94    pub full_bytes: u64,
95    pub page: [u32; 2],
96    pub pages: u32,
97    pub psnr: [Option<f64>; 3],
98}
99
100pub fn map_bytes(size: u32) -> u64 {
101    3 * 4 * u64::from(size) * u64::from(size)
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
105pub struct Crop {
106    pub x: u32,
107    pub y: u32,
108    pub width: u32,
109    pub height: u32,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct Region {
114    pub size: u32,
115    pub crop: Crop,
116    pub uv_set: u32,
117    pub base: Vec<u8>,
118    pub roughness: Vec<u8>,
119    pub normal: Vec<u8>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct Maps {
124    pub size: u32,
125    pub base: Vec<u8>,
126    pub roughness: Vec<u8>,
127    pub normal: Vec<u8>,
128}
129
130fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
131    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
132}
133
134fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
135    [
136        a[1] * b[2] - a[2] * b[1],
137        a[2] * b[0] - a[0] * b[2],
138        a[0] * b[1] - a[1] * b[0],
139    ]
140}
141
142fn unit(v: [f32; 3]) -> [f32; 3] {
143    let length = dot(v, v).sqrt();
144    if length > 1e-12 {
145        v.map(|x| x / length)
146    } else {
147        [0.0, 0.0, 1.0]
148    }
149}
150
151fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
152    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
153}
154
155fn mix3(values: [[f32; 3]; 3], weight: [f32; 3]) -> [f32; 3] {
156    std::array::from_fn(|axis| {
157        (0..3)
158            .map(|corner| values[corner][axis] * weight[corner])
159            .sum()
160    })
161}
162
163fn mix2(values: [[f32; 2]; 3], weight: [f32; 3]) -> [f32; 2] {
164    std::array::from_fn(|axis| {
165        (0..3)
166            .map(|corner| values[corner][axis] * weight[corner])
167            .sum()
168    })
169}
170
171fn project(view: &View, position: [f32; 3]) -> Option<[f32; 2]> {
172    let clip: [f32; 4] = std::array::from_fn(|axis| {
173        view.matrix[3][axis]
174            + (0..3)
175                .map(|component| view.matrix[component][axis] * position[component])
176                .sum::<f32>()
177    });
178    if clip[3] <= 0.0 || !clip[3].is_finite() {
179        return None;
180    }
181    Some([
182        (clip[0] / clip[3] * 0.5 + 0.5) * view.size[0] as f32,
183        (0.5 - clip[1] / clip[3] * 0.5) * view.size[1] as f32,
184    ])
185}
186
187pub fn resolution(input: &Input<'_>, view: &View, largest: u32) -> u32 {
188    let largest = largest.clamp(FLOOR, LIMIT);
189    let mut need = FLOOR as f32;
190    for triangle in input.indices.chunks_exact(3) {
191        let ids: [usize; 3] = std::array::from_fn(|corner| triangle[corner] as usize);
192        let Some(screen) = ids
193            .map(|id| project(view, input.positions[id]))
194            .into_iter()
195            .collect::<Option<Vec<_>>>()
196        else {
197            continue;
198        };
199        for edge in [(0, 1), (1, 2), (2, 0)] {
200            let a = edge.0;
201            let b = edge.1;
202            let uv = input.raster()[ids[a]];
203            let next = input.raster()[ids[b]];
204            let du = ((uv[0] - next[0]).powi(2) + (uv[1] - next[1]).powi(2)).sqrt();
205            let ds = ((screen[a][0] - screen[b][0]).powi(2)
206                + (screen[a][1] - screen[b][1]).powi(2))
207            .sqrt();
208            if du > 1e-5 {
209                need = need.max(ds / du);
210            }
211        }
212    }
213    (need.ceil() as u32)
214        .clamp(FLOOR, largest)
215        .next_power_of_two()
216        .min(largest)
217}
218
219fn edge(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 {
220    (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0])
221}
222
223fn byte(value: f32) -> u8 {
224    (value.clamp(0.0, 1.0) * 255.0).round() as u8
225}
226
227fn set_texel(map: &mut [u8], index: usize, value: [u8; 4]) {
228    map[index * 4..index * 4 + 4].copy_from_slice(&value);
229}
230
231fn texel(map: &[u8], index: usize) -> [u8; 4] {
232    map[index * 4..index * 4 + 4].try_into().unwrap()
233}
234
235pub fn repeats(input: &Input<'_>, size: u32) -> bool {
236    let uvs = input.raster();
237    if size == 0
238        || uvs.len() != input.positions.len()
239        || uvs.is_empty()
240        || uvs
241            .iter()
242            .flatten()
243            .any(|v| !v.is_finite() || *v < -0.1 || *v > 1.1)
244    {
245        return true;
246    }
247    let mut owner = vec![u32::MAX; (size * size) as usize];
248    for (number, triangle) in input.indices.chunks_exact(3).enumerate() {
249        let Some(uv) = triangle
250            .iter()
251            .map(|&id| uvs.get(id as usize).copied())
252            .collect::<Option<Vec<_>>>()
253        else {
254            return true;
255        };
256        let area = edge(uv[0], uv[1], uv[2]);
257        if area.abs() < 1e-12 {
258            continue;
259        }
260        let min: [u32; 2] = std::array::from_fn(|axis| {
261            (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * size as f32 - 0.5)
262                .ceil()
263                .max(0.0) as u32
264        });
265        let max: [u32; 2] = std::array::from_fn(|axis| {
266            (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * size as f32 - 0.5)
267                .floor()
268                .min(size as f32 - 1.0) as u32
269        });
270        for y in min[1]..=max[1] {
271            for x in min[0]..=max[0] {
272                let at = [
273                    (x as f32 + 0.5) / size as f32,
274                    (y as f32 + 0.5) / size as f32,
275                ];
276                let weight = [
277                    edge(uv[1], uv[2], at) / area,
278                    edge(uv[2], uv[0], at) / area,
279                    edge(uv[0], uv[1], at) / area,
280                ];
281                if weight.iter().all(|&v| v > 1e-5) {
282                    let index = (y * size + x) as usize;
283                    if owner[index] != u32::MAX && owner[index] != number as u32 {
284                        return true;
285                    }
286                    owner[index] = number as u32;
287                }
288            }
289        }
290    }
291    false
292}
293
294fn check_input(input: &Input<'_>, size: u32) -> Result<(), String> {
295    if size == 0 || size > LIMIT || !size.is_power_of_two() {
296        return Err(format!(
297            "detail size must be a power of two through {LIMIT}"
298        ));
299    }
300    if input.positions.len() != input.normals.len()
301        || input.positions.len() != input.tangents.len()
302        || input.positions.len() != input.uvs.len()
303        || input.positions.len() != input.raster().len()
304        || input
305            .indices
306            .iter()
307            .any(|&i| i as usize >= input.positions.len())
308    {
309        return Err("detail mesh attributes are incomplete".into());
310    }
311    Ok(())
312}
313
314pub fn crop(input: &Input<'_>, size: u32, padding: u32) -> Result<Crop, String> {
315    check_input(input, size)?;
316    let scale = size as f32;
317    let mut low = [i64::MAX; 2];
318    let mut high = [i64::MIN; 2];
319    for triangle in input.indices.chunks_exact(3) {
320        let uv = [0, 1, 2].map(|corner| input.raster()[triangle[corner] as usize]);
321        if edge(uv[0], uv[1], uv[2]).abs() < 1e-12 {
322            continue;
323        }
324        let min = std::array::from_fn::<_, 2, _>(|axis| {
325            (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * scale - 0.5)
326                .ceil()
327                .max(0.0) as u32
328        });
329        let max = std::array::from_fn::<_, 2, _>(|axis| {
330            (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * scale - 0.5)
331                .floor()
332                .min(scale - 1.0) as u32
333        });
334        if min[0] > max[0] || min[1] > max[1] {
335            continue;
336        }
337        for axis in 0..2 {
338            low[axis] = low[axis].min(i64::from(min[axis]));
339            high[axis] = high[axis].max(i64::from(max[axis]));
340        }
341    }
342    if low.iter().zip(&high).any(|(low, high)| low > high) {
343        return Err("detail mesh has no UV coverage".into());
344    }
345    let align = i64::from(atlas::ALIGN);
346    let start = low.map(|value| (value - i64::from(padding)).max(0) / align * align);
347    let end = high.map(|value| {
348        ((value + i64::from(padding) + 1 + align - 1) / align * align).min(i64::from(size))
349    });
350    Ok(Crop {
351        x: start[0] as u32,
352        y: start[1] as u32,
353        width: (end[0] - start[0]) as u32,
354        height: (end[1] - start[1]) as u32,
355    })
356}
357
358pub fn bake(input: &Input<'_>, size: u32, padding: u32) -> Result<Maps, String> {
359    let region = bake_crop(
360        input,
361        size,
362        padding,
363        Crop {
364            x: 0,
365            y: 0,
366            width: size,
367            height: size,
368        },
369    )?;
370    Ok(Maps {
371        size,
372        base: region.base,
373        roughness: region.roughness,
374        normal: region.normal,
375    })
376}
377
378pub fn bake_crop(input: &Input<'_>, size: u32, padding: u32, crop: Crop) -> Result<Region, String> {
379    check_input(input, size)?;
380    if crop.width == 0
381        || crop.height == 0
382        || crop.x + crop.width > size
383        || crop.y + crop.height > size
384    {
385        return Err("detail crop lies outside the map".into());
386    }
387    let count = (crop.width * crop.height) as usize;
388    let mut region = Region {
389        size,
390        crop,
391        uv_set: input.uv_set(),
392        base: vec![0; count * 4],
393        roughness: vec![0; count * 4],
394        normal: vec![0; count * 4],
395    };
396    let mut covered = vec![false; count];
397    let scale = size as f32;
398    for triangle in input.indices.chunks_exact(3) {
399        let ids: [usize; 3] = std::array::from_fn(|corner| triangle[corner] as usize);
400        let uv = ids.map(|id| input.raster()[id]);
401        let area = edge(uv[0], uv[1], uv[2]);
402        if area.abs() < 1e-12 {
403            continue;
404        }
405        let min = std::array::from_fn::<_, 2, _>(|axis| {
406            (uv.iter().map(|p| p[axis]).fold(f32::INFINITY, f32::min) * scale - 0.5)
407                .ceil()
408                .max(0.0) as u32
409        });
410        let max = std::array::from_fn::<_, 2, _>(|axis| {
411            (uv.iter().map(|p| p[axis]).fold(f32::NEG_INFINITY, f32::max) * scale - 0.5)
412                .floor()
413                .min(scale - 1.0) as u32
414        });
415        let min = [min[0].max(crop.x), min[1].max(crop.y)];
416        let max = [
417            max[0].min(crop.x + crop.width - 1),
418            max[1].min(crop.y + crop.height - 1),
419        ];
420        for y in min[1]..=max[1] {
421            for x in min[0]..=max[0] {
422                let at_uv = [(x as f32 + 0.5) / scale, (y as f32 + 0.5) / scale];
423                let weight = [
424                    edge(uv[1], uv[2], at_uv) / area,
425                    edge(uv[2], uv[0], at_uv) / area,
426                    edge(uv[0], uv[1], at_uv) / area,
427                ];
428                if weight.iter().any(|&value| value < -1e-5) {
429                    continue;
430                }
431                let index = ((y - crop.y) * crop.width + x - crop.x) as usize;
432                if covered[index] {
433                    continue;
434                }
435                let position = mix3(ids.map(|id| input.positions[id]), weight);
436                let normal = unit(mix3(ids.map(|id| input.normals[id]), weight));
437                let tangent = ids.map(|id| input.tangents[id]);
438                let tangent_xyz = unit(mix3(tangent.map(|v| [v[0], v[1], v[2]]), weight));
439                let tangent_xyz = unit(sub(
440                    tangent_xyz,
441                    normal.map(|v| v * dot(normal, tangent_xyz)),
442                ));
443                let handedness: f32 = (0..3).map(|i| tangent[i][3] * weight[i]).sum();
444                let bitangent = cross(normal, tangent_xyz).map(|v| v * handedness.signum());
445                let surface_uv = match input.uvs1 {
446                    Some(_) => mix2(ids.map(|id| input.uvs[id]), weight),
447                    None => at_uv,
448                };
449                let resolved = resolve(
450                    input.material,
451                    0.0,
452                    &At {
453                        position,
454                        uv: surface_uv,
455                        normal,
456                        edge: 0.0,
457                        time: 0.0,
458                    },
459                );
460                let mapped = [
461                    dot(resolved.normal, tangent_xyz),
462                    dot(resolved.normal, bitangent),
463                    dot(resolved.normal, normal),
464                ];
465                set_texel(
466                    &mut region.base,
467                    index,
468                    resolved.material.base.map(byte).map_with_alpha(),
469                );
470                let rough = byte(resolved.material.roughness);
471                set_texel(&mut region.roughness, index, [rough, rough, rough, 255]);
472                set_texel(
473                    &mut region.normal,
474                    index,
475                    [
476                        byte(mapped[0] * 0.5 + 0.5),
477                        byte(mapped[1] * 0.5 + 0.5),
478                        byte(mapped[2] * 0.5 + 0.5),
479                        255,
480                    ],
481                );
482                covered[index] = true;
483            }
484        }
485    }
486    if !covered.iter().any(|&value| value) {
487        return Err("detail mesh has no UV coverage".into());
488    }
489    pad(&mut region, &mut covered, padding);
490    Ok(region)
491}
492
493trait Alpha {
494    fn map_with_alpha(self) -> [u8; 4];
495}
496
497impl Alpha for [u8; 3] {
498    fn map_with_alpha(self) -> [u8; 4] {
499        [self[0], self[1], self[2], 255]
500    }
501}
502
503fn pad(region: &mut Region, covered: &mut [bool], padding: u32) {
504    let width = region.crop.width as usize;
505    let height = region.crop.height as usize;
506    let mut queue = VecDeque::new();
507    for (index, &filled) in covered.iter().enumerate() {
508        if filled {
509            queue.push_back((index, index, 0u32));
510        }
511    }
512    while let Some((index, source, distance)) = queue.pop_front() {
513        if distance >= padding {
514            continue;
515        }
516        let x = index % width;
517        let y = index / width;
518        for next in [
519            (x > 0).then_some(index.wrapping_sub(1)),
520            (x + 1 < width).then_some(index + 1),
521            (y > 0).then_some(index.wrapping_sub(width)),
522            (y + 1 < height).then_some(index + width),
523        ]
524        .into_iter()
525        .flatten()
526        {
527            if covered[next] {
528                continue;
529            }
530            covered[next] = true;
531            let base = texel(&region.base, source);
532            let roughness = texel(&region.roughness, source);
533            let normal = texel(&region.normal, source);
534            set_texel(&mut region.base, next, base);
535            set_texel(&mut region.roughness, next, roughness);
536            set_texel(&mut region.normal, next, normal);
537            queue.push_back((next, source, distance + 1));
538        }
539    }
540}
541
542fn digest(bytes: &[u8]) -> String {
543    let hash = Sha256::digest(bytes);
544    hash.iter().map(|byte| format!("{byte:02x}")).collect()
545}
546
547pub fn input_hash(input: &Input<'_>, size: u32, padding: u32) -> Result<String, String> {
548    let mut h = Sha256::new();
549    h.update(b"pito-engine-detail-v1");
550    h.update(env!("CARGO_PKG_VERSION"));
551    h.update(include_str!("detail.rs"));
552    h.update(size.to_le_bytes());
553    h.update(padding.to_le_bytes());
554    for position in input.positions {
555        for value in position {
556            h.update(value.to_bits().to_le_bytes());
557        }
558    }
559    for normal in input.normals {
560        for value in normal {
561            h.update(value.to_bits().to_le_bytes());
562        }
563    }
564    for tangent in input.tangents {
565        for value in tangent {
566            h.update(value.to_bits().to_le_bytes());
567        }
568    }
569    for uv in input.uvs {
570        for value in uv {
571            h.update(value.to_bits().to_le_bytes());
572        }
573    }
574    if let Some(uvs1) = input.uvs1 {
575        h.update(b"uvs1");
576        for uv in uvs1 {
577            for value in uv {
578                h.update(value.to_bits().to_le_bytes());
579            }
580        }
581    }
582    for index in input.indices {
583        h.update(index.to_le_bytes());
584    }
585    h.update(serde_json::to_vec(input.material).map_err(|e| e.to_string())?);
586    Ok(h.finalize()
587        .iter()
588        .map(|byte| format!("{byte:02x}"))
589        .collect())
590}
591
592pub fn write(path: &Path, hash: &str, maps: &Maps) -> Result<(), String> {
593    if path.exists() {
594        return Err(format!("{} already exists", path.display()));
595    }
596    fs::create_dir_all(path).map_err(|e| e.to_string())?;
597    let files = [
598        ("base.rgba", &maps.base),
599        ("roughness.rgba", &maps.roughness),
600        ("normal.rgba", &maps.normal),
601    ];
602    let entries = files
603        .iter()
604        .map(|(name, data)| {
605            fs::write(path.join(name), data).map_err(|e| e.to_string())?;
606            Ok(json!({"path": name, "bytes": data.len(), "sha256": digest(data)}))
607        })
608        .collect::<Result<Vec<Value>, String>>()?;
609    let manifest = serde_json::to_vec_pretty(&json!({
610        "schema_version": 1,
611        "input_hash": hash,
612        "size": maps.size,
613        "format": "rgba8-linear",
614        "files": entries,
615    }))
616    .map_err(|e| e.to_string())?;
617    fs::write(path.join("manifest.json"), &manifest).map_err(|e| e.to_string())?;
618    fs::write(path.join("manifest.sha256"), digest(&manifest)).map_err(|e| e.to_string())?;
619    read(path)?;
620    Ok(())
621}
622
623pub fn read(path: &Path) -> Result<(String, Maps), String> {
624    let manifest = fs::read(path.join("manifest.json")).map_err(|e| e.to_string())?;
625    let expected = fs::read_to_string(path.join("manifest.sha256")).map_err(|e| e.to_string())?;
626    if expected != digest(&manifest) {
627        return Err("detail manifest checksum differs".into());
628    }
629    let value: Value = serde_json::from_slice(&manifest).map_err(|e| e.to_string())?;
630    if value["schema_version"] != 1 || value["format"] != "rgba8-linear" {
631        return Err("unsupported detail artifact".into());
632    }
633    let hash = value["input_hash"]
634        .as_str()
635        .ok_or("detail input hash is missing")?;
636    let size = value["size"].as_u64().ok_or("detail size is missing")?;
637    let size = u32::try_from(size).map_err(|_| "detail size is invalid")?;
638    if size == 0 || size > LIMIT || !size.is_power_of_two() {
639        return Err("detail size is invalid".into());
640    }
641    let entries = value["files"]
642        .as_array()
643        .ok_or("detail files are missing")?;
644    if entries.len() != 3 {
645        return Err("detail needs three maps".into());
646    }
647    let mut maps = Vec::new();
648    for (entry, name) in entries
649        .iter()
650        .zip(["base.rgba", "roughness.rgba", "normal.rgba"])
651    {
652        if entry["path"] != name {
653            return Err("detail map name differs".into());
654        }
655        let bytes = fs::read(path.join(name)).map_err(|e| e.to_string())?;
656        if bytes.len() != (size * size * 4) as usize
657            || entry["bytes"].as_u64() != Some(bytes.len() as u64)
658            || entry["sha256"].as_str() != Some(digest(&bytes).as_str())
659        {
660            return Err(format!("detail {name} checksum differs"));
661        }
662        maps.push(bytes);
663    }
664    Ok((
665        hash.to_owned(),
666        Maps {
667            size,
668            base: maps.remove(0),
669            roughness: maps.remove(0),
670            normal: maps.remove(0),
671        },
672    ))
673}
674
675struct Candidate<'a> {
676    index: u32,
677    node: String,
678    geometry: pfx_geom::mesh::Mesh,
679    uvs1: Option<Vec<[f32; 2]>>,
680    material: &'a Material,
681    size: u32,
682    hash: String,
683}
684
685impl Candidate<'_> {
686    fn input(&self) -> Input<'_> {
687        Input {
688            positions: &self.geometry.positions,
689            normals: &self.geometry.normals,
690            tangents: &self.geometry.tangents,
691            uvs: &self.geometry.uvs,
692            uvs1: self.uvs1.as_deref(),
693            indices: &self.geometry.indices,
694            material: self.material,
695        }
696    }
697}
698
699pub fn report(atlas: &atlas::Atlas, summary: &atlas::Summary) -> Report {
700    let parts: Vec<Part> = atlas
701        .parts
702        .iter()
703        .map(|part| Part {
704            path: format!("part-{:04}", part.index),
705            node: part.node.clone(),
706            size: part.density,
707            crop: part.crop,
708            page: part.page,
709            bytes: atlas.part_bytes(part),
710            input_hash: part.input_hash.clone(),
711            uv_set: part.uv_set,
712        })
713        .collect();
714    Report {
715        full_bytes: parts.iter().map(|part| map_bytes(part.size)).sum(),
716        parts,
717        total_bytes: summary.total_bytes,
718        vram_bytes: summary.vram_bytes,
719        decoded_vram_bytes: atlas.decoded_vram_bytes(),
720        page: [atlas.width, atlas.height],
721        pages: atlas.pages(),
722        psnr: summary.psnr,
723    }
724}
725
726fn replaceable(path: &Path) -> Result<(), String> {
727    let mut files = vec!["manifest.json".to_string(), "manifest.sha256".to_string()];
728    let mut folders = Vec::new();
729    if let Ok((_, atlas)) = atlas::read(path) {
730        files.extend(atlas::file_names(atlas.pages() as usize));
731    } else {
732        atlas::load(path)?;
733        let manifest: Value = serde_json::from_slice(
734            &fs::read(path.join("manifest.json")).map_err(|e| e.to_string())?,
735        )
736        .map_err(|e| e.to_string())?;
737        for part in manifest["parts"].as_array().into_iter().flatten() {
738            folders.push(part["path"].as_str().unwrap_or_default().to_owned());
739        }
740    }
741    let part_files = [
742        "manifest.json",
743        "manifest.sha256",
744        "base.rgba",
745        "roughness.rgba",
746        "normal.rgba",
747    ];
748    for entry in fs::read_dir(path).map_err(|e| e.to_string())? {
749        let entry = entry.map_err(|e| e.to_string())?;
750        let name = entry.file_name().to_string_lossy().into_owned();
751        let kind = entry.file_type().map_err(|e| e.to_string())?;
752        let expected = if kind.is_file() {
753            files.contains(&name)
754        } else if kind.is_dir() && folders.contains(&name) {
755            fs::read_dir(entry.path())
756                .map_err(|e| e.to_string())?
757                .all(|inner| {
758                    inner.is_ok_and(|inner| {
759                        part_files.contains(&inner.file_name().to_string_lossy().as_ref())
760                            && inner.file_type().is_ok_and(|kind| kind.is_file())
761                    })
762                })
763        } else {
764            false
765        };
766        if !expected {
767            return Err(format!("{} contains non-artifact files", path.display()));
768        }
769    }
770    Ok(())
771}
772
773fn mesh_node_name<'a>(mesh: &'a pfx_load::Mesh, node: &'a pfx_load::Node) -> Option<&'a str> {
774    let group = node.group?;
775    Some(if node.name.is_empty() {
776        mesh.groups[group as usize].name.as_str()
777    } else {
778        node.name.as_str()
779    })
780}
781
782pub fn bake_scene(
783    folder: &Path,
784    scene_file: &str,
785    materials: &[Material],
786    recipe: &crate::recipe::DetailRecipe,
787    out: &Path,
788    force: bool,
789) -> Result<Report, String> {
790    use pfx_geom::mesh::Mesh as Geometry;
791    use pfx_load::Mesh;
792
793    recipe.check()?;
794    let view = recipe.view.as_ref().map(View::look);
795    let root = folder.canonicalize().map_err(|e| e.to_string())?;
796    let bytes = crate::recipe::read_inside(&root, scene_file)?;
797    let animated: std::collections::BTreeSet<usize> = gltf::Gltf::from_slice(&bytes)
798        .map_err(|e| e.to_string())?
799        .animations()
800        .flat_map(|animation| {
801            animation
802                .channels()
803                .map(|channel| channel.target().node().index())
804        })
805        .collect();
806    let mesh = Mesh::parse_with(&bytes, |uri| {
807        let base = Path::new(scene_file).parent().unwrap_or(Path::new(""));
808        let name = base.join(uri).to_string_lossy().into_owned();
809        crate::recipe::read_inside(&root, &name)
810    })?;
811    if let Some(missing) = recipe.sizes.keys().find(|name| {
812        !mesh
813            .nodes
814            .iter()
815            .any(|node| mesh_node_name(&mesh, node) == Some(name.as_str()))
816    }) {
817        return Err(format!(
818            "detail size names {missing:?}, which is not a mesh node"
819        ));
820    }
821    if let Some(missing) = recipe.parts.iter().find(|name| {
822        !mesh
823            .nodes
824            .iter()
825            .any(|node| mesh_node_name(&mesh, node) == Some(name.as_str()))
826    }) {
827        return Err(format!(
828            "detail parts names {missing:?}, which is not a mesh node"
829        ));
830    }
831    let mut candidates = Vec::new();
832    let mut stack: Vec<u32> = mesh.roots.iter().rev().copied().collect();
833    let mut part_index = 0u32;
834    while let Some(index) = stack.pop() {
835        let node = mesh
836            .nodes
837            .get(index as usize)
838            .ok_or("detail node is missing")?;
839        stack.extend(node.children.iter().rev().copied());
840        let Some(group_index) = node.group else {
841            continue;
842        };
843        let group = &mesh.groups[group_index as usize];
844        let name = if node.name.is_empty() {
845            &group.name
846        } else {
847            &node.name
848        };
849        let world = mesh.world(index).ok_or("invalid detail node transform")?;
850        for primitive in &group.primitives {
851            let this_part = part_index;
852            part_index += 1;
853            let mut ancestor = Some(index);
854            let mut animated_part = false;
855            while let Some(node_index) = ancestor {
856                if animated.contains(&(node_index as usize)) {
857                    animated_part = true;
858                    break;
859                }
860                ancestor = mesh.nodes[node_index as usize].parent;
861            }
862            if animated_part || recipe.dynamic_nodes.iter().any(|dynamic| dynamic == name) {
863                println!("detail bake: {name} keeps procedural detail because it moves");
864                continue;
865            }
866            if !recipe.parts.is_empty() && recipe.parts.iter().all(|part| part != name) {
867                continue;
868            }
869            let material = materials
870                .get(primitive.material.unwrap_or(materials.len() as u32 - 1) as usize)
871                .ok_or("detail material is missing")?;
872            if !material.layers.iter().any(|layer| layer.amplitude != 0.0) {
873                continue;
874            }
875            let positions: Vec<[f32; 3]> = primitive
876                .positions
877                .iter()
878                .map(|point| {
879                    std::array::from_fn(|axis| {
880                        world[3][axis] + (0..3).map(|k| world[k][axis] * point[k]).sum::<f32>()
881                    })
882                })
883                .collect();
884            let a = |row: usize, column: usize| world[column][row];
885            let cofactor = |row: usize, column: usize| {
886                let (r0, r1) = ((row + 1) % 3, (row + 2) % 3);
887                let (c0, c1) = ((column + 1) % 3, (column + 2) % 3);
888                a(r0, c0) * a(r1, c1) - a(r0, c1) * a(r1, c0)
889            };
890            let determinant: f32 = (0..3)
891                .map(|column| a(0, column) * cofactor(0, column))
892                .sum();
893            let sign = if determinant < 0.0 { -1.0 } else { 1.0 };
894            let normals: Vec<[f32; 3]> = primitive
895                .normals
896                .iter()
897                .map(|normal| {
898                    let transformed: [f32; 3] = std::array::from_fn(|row| {
899                        (0..3)
900                            .map(|column| cofactor(row, column) * normal[column] * sign)
901                            .sum()
902                    });
903                    let length = transformed.iter().map(|v| v * v).sum::<f32>().sqrt();
904                    transformed.map(|v| v / length.max(1e-20))
905                })
906                .collect();
907            let normals = if normals.is_empty() {
908                let mut flat = vec![[0.0; 3]; positions.len()];
909                for triangle in primitive.indices.chunks_exact(3) {
910                    let [a, b, c] =
911                        [triangle[0], triangle[1], triangle[2]].map(|id| positions[id as usize]);
912                    let face = cross(sub(b, a), sub(c, a));
913                    for &id in triangle {
914                        for axis in 0..3 {
915                            flat[id as usize][axis] += face[axis];
916                        }
917                    }
918                }
919                flat.into_iter().map(unit).collect()
920            } else {
921                normals
922            };
923            let geometry = Geometry::new(
924                positions,
925                normals,
926                Vec::new(),
927                primitive.uvs.clone(),
928                primitive.indices.clone(),
929            );
930            let mut candidate = Candidate {
931                index: this_part,
932                node: name.to_owned(),
933                geometry,
934                uvs1: primitive.uvs1.clone(),
935                material,
936                size: 0,
937                hash: String::new(),
938            };
939            let input = candidate.input();
940            let size = match (recipe.sizes.get(name), &view) {
941                (Some(&size), _) => size,
942                (None, Some(view)) => resolution(&input, view, recipe.size),
943                (None, None) => recipe.size,
944            };
945            if recipe.padding > size / 4 {
946                return Err(format!(
947                    "detail padding {} is more than a quarter of {name}'s {size}² map",
948                    recipe.padding
949                ));
950            }
951            if repeats(&input, size) {
952                println!(
953                    "detail bake: {name} keeps procedural detail because its UVs repeat or are missing"
954                );
955                continue;
956            }
957            if input.uvs1.is_some() {
958                println!("detail bake: {name} bakes through TEXCOORD_1");
959            }
960            let hash = input_hash(&input, size, recipe.padding)?;
961            candidate.size = size;
962            candidate.hash = hash;
963            candidates.push(candidate);
964        }
965    }
966    if candidates.is_empty() {
967        println!("detail bake: no static part with unique UVs has procedural detail to bake");
968        return Ok(Report {
969            parts: Vec::new(),
970            total_bytes: 0,
971            vram_bytes: 0,
972            decoded_vram_bytes: 0,
973            full_bytes: 0,
974            page: [0, 0],
975            pages: 0,
976            psnr: [None; 3],
977        });
978    }
979    let keys: Vec<(u32, &str, &str)> = candidates
980        .iter()
981        .map(|candidate| {
982            (
983                candidate.index,
984                candidate.node.as_str(),
985                candidate.hash.as_str(),
986            )
987        })
988        .collect();
989    let hash = atlas::hash(&keys, recipe.page, recipe.padding);
990    if out.exists() {
991        if let Ok((summary, saved)) = atlas::read(out)
992            && summary.input_hash == hash
993        {
994            println!(
995                "detail bake: {} unchanged, {} parts on {} pages",
996                out.display(),
997                saved.parts.len(),
998                saved.pages()
999            );
1000            return Ok(report(&saved, &summary));
1001        }
1002        if !force {
1003            return Err(format!(
1004                "{} already exists; use --force to replace it",
1005                out.display()
1006            ));
1007        }
1008        replaceable(out)?;
1009    }
1010    let mut pieces = Vec::with_capacity(candidates.len());
1011    for candidate in &candidates {
1012        let input = candidate.input();
1013        let crop = crop(&input, candidate.size, recipe.padding)?;
1014        let region = bake_crop(&input, candidate.size, recipe.padding, crop)?;
1015        println!(
1016            "detail bake: {} part-{:04} {}² crop {}×{} at ({}, {}) through TEXCOORD_{}",
1017            candidate.node,
1018            candidate.index,
1019            candidate.size,
1020            crop.width,
1021            crop.height,
1022            crop.x,
1023            crop.y,
1024            input.uv_set()
1025        );
1026        pieces.push(atlas::Piece {
1027            index: candidate.index,
1028            node: candidate.node.clone(),
1029            input_hash: candidate.hash.clone(),
1030            region,
1031        });
1032    }
1033    let plain = atlas::assemble(pieces, recipe.page)?;
1034    let packed = plain.compress()?;
1035    let psnr = atlas::Kind::ALL.map(|kind| atlas::psnr(&plain, &packed, kind));
1036    let name = out
1037        .file_name()
1038        .ok_or("detail output needs a folder name")?
1039        .to_string_lossy()
1040        .into_owned();
1041    let parent = out.parent().unwrap_or(Path::new(""));
1042    let next = parent.join(format!(".{name}.next"));
1043    let old = parent.join(format!(".{name}.old"));
1044    for path in [&next, &old] {
1045        if path.exists() {
1046            return Err(format!("{} already exists", path.display()));
1047        }
1048    }
1049    let summary = atlas::write(&next, &packed, &hash, recipe.padding, psnr)?;
1050    atlas::read(&next)?;
1051    if out.exists() {
1052        fs::rename(out, &old).map_err(|e| e.to_string())?;
1053        if let Err(error) = fs::rename(&next, out) {
1054            let _ = fs::rename(&old, out);
1055            return Err(error.to_string());
1056        }
1057        fs::remove_dir_all(&old).map_err(|e| e.to_string())?;
1058    } else {
1059        fs::rename(&next, out).map_err(|e| e.to_string())?;
1060    }
1061    println!(
1062        "detail bake: {} parts on {} pages of {}×{}",
1063        packed.parts.len(),
1064        packed.pages(),
1065        packed.width,
1066        packed.height
1067    );
1068    Ok(report(&packed, &summary))
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073    use super::*;
1074    use pfx_materials::{NoiseKind, NoiseLayer};
1075
1076    fn mesh<'a>(material: &'a Material, uvs: &'a [[f32; 2]; 3]) -> Input<'a> {
1077        static POSITIONS: [[f32; 3]; 3] = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1078        static NORMALS: [[f32; 3]; 3] = [[0.0, 0.0, 1.0]; 3];
1079        static TANGENTS: [[f32; 4]; 3] = [[1.0, 0.0, 0.0, 1.0]; 3];
1080        static INDICES: [u32; 3] = [0, 1, 2];
1081        Input {
1082            positions: &POSITIONS,
1083            normals: &NORMALS,
1084            tangents: &TANGENTS,
1085            uvs,
1086            uvs1: None,
1087            indices: &INDICES,
1088            material,
1089        }
1090    }
1091
1092    #[test]
1093    fn baked_texel_matches_procedural_evaluation() {
1094        let mut material = Material::default();
1095        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 8.0, 0.7, 19);
1096        let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1097        let input = mesh(&material, &uvs);
1098        let maps = bake(&input, 8, 0).unwrap();
1099        let uv = [1.5 / 8.0, 1.5 / 8.0];
1100        let resolved = resolve(
1101            &material,
1102            0.0,
1103            &At {
1104                position: [uv[0], uv[1], 0.0],
1105                uv,
1106                normal: [0.0, 0.0, 1.0],
1107                edge: 0.0,
1108                time: 0.0,
1109            },
1110        );
1111        assert_eq!(texel(&maps.base, 9)[..3], resolved.material.base.map(byte));
1112        assert_eq!(
1113            texel(&maps.roughness, 9)[0],
1114            byte(resolved.material.roughness)
1115        );
1116    }
1117
1118    #[test]
1119    fn padding_extends_covered_texels_without_crossing_the_limit() {
1120        let material = Material::default();
1121        let uvs = [[0.125, 0.125], [0.375, 0.125], [0.125, 0.375]];
1122        let maps = bake(&mesh(&material, &uvs), 8, 1).unwrap();
1123        assert_eq!(texel(&maps.base, 8), texel(&maps.base, 9));
1124        assert_eq!(texel(&maps.normal, 8), texel(&maps.normal, 9));
1125        assert_eq!(texel(&maps.base, 63), [0; 4]);
1126    }
1127
1128    #[test]
1129    fn overlapping_uv_islands_remain_procedural() {
1130        let material = Material::default();
1131        let positions = [[0.0, 0.0, 0.0]; 6];
1132        let normals = [[0.0, 0.0, 1.0]; 6];
1133        let tangents = [[1.0, 0.0, 0.0, 1.0]; 6];
1134        let uvs = [
1135            [0.0, 0.0],
1136            [1.0, 0.0],
1137            [0.0, 1.0],
1138            [0.0, 0.0],
1139            [1.0, 0.0],
1140            [0.0, 1.0],
1141        ];
1142        let input = Input {
1143            positions: &positions,
1144            normals: &normals,
1145            tangents: &tangents,
1146            uvs: &uvs,
1147            uvs1: None,
1148            indices: &[0, 1, 2, 3, 4, 5],
1149            material: &material,
1150        };
1151        assert!(repeats(&input, 16));
1152    }
1153
1154    #[test]
1155    fn scene_bake_writes_checked_maps_and_skips_repeated_uvs() {
1156        use pfx_materials::{NoiseKind, NoiseLayer};
1157
1158        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1159        let root = Path::new("tmp").join(format!("detail-scene-test-{}", std::process::id()));
1160        if root.exists() {
1161            fs::remove_dir_all(&root).unwrap();
1162        }
1163        fs::create_dir_all(&root).unwrap();
1164        fs::copy(source.join("triangle.gltf"), root.join("triangle.gltf")).unwrap();
1165        fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1166        let mut material = Material::default();
1167        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1168        let recipe = crate::recipe::DetailRecipe {
1169            size: 64,
1170            padding: 2,
1171            dynamic_nodes: Vec::new(),
1172            parts: Vec::new(),
1173            sizes: Default::default(),
1174            view: None,
1175            page: atlas::PAGE,
1176        };
1177        let out = root.join("out");
1178        let report = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1179        assert_eq!(report.parts.len(), 1);
1180        assert_eq!(report.full_bytes, 3 * 4 * 64 * 64);
1181        assert_eq!(report.page, [64, 64]);
1182        assert_eq!(report.pages, 1);
1183        let levels: u64 = (0..atlas::LEVELS)
1184            .map(|level| (64u64 >> level).pow(2))
1185            .sum();
1186        assert_eq!(report.vram_bytes, levels * 5 / 2);
1187        let first = atlas::read(&out).unwrap();
1188        assert_eq!(
1189            first.1.parts[0].crop,
1190            Crop {
1191                x: 0,
1192                y: 0,
1193                width: 64,
1194                height: 64
1195            }
1196        );
1197        assert!(first.1.compressed());
1198        assert_eq!(first.1.parts[0].uv_set, 0);
1199        assert_eq!(report.parts[0].uv_set, 0);
1200        assert_eq!(first.1.schema(), atlas::SCHEMA);
1201        let again = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1202        assert_eq!(again, report);
1203        assert_eq!(first, atlas::read(&out).unwrap());
1204        let mut bin = fs::read(root.join("triangle.bin")).unwrap();
1205        bin[120..124].copy_from_slice(&2.0f32.to_le_bytes());
1206        fs::write(root.join("triangle.bin"), bin).unwrap();
1207        let none = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1208        assert!(none.parts.is_empty());
1209        assert_eq!(first, atlas::read(&out).unwrap());
1210        fs::remove_dir_all(root).unwrap();
1211    }
1212
1213    const SECOND: [[f32; 2]; 3] = [[0.25, 0.25], [0.75, 0.25], [0.25, 0.75]];
1214
1215    #[test]
1216    fn a_part_with_a_second_uv_set_bakes_through_it() {
1217        let mut material = Material::default();
1218        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 8.0, 0.7, 19);
1219        let tiled = [[-3.0, 0.0], [5.0, 0.0], [-3.0, 8.0]];
1220        let first = mesh(&material, &tiled);
1221        let second = Input {
1222            uvs1: Some(&SECOND),
1223            ..mesh(&material, &tiled)
1224        };
1225        assert!(repeats(&first, 64));
1226        assert!(!repeats(&second, 64));
1227        assert_eq!((first.uv_set(), second.uv_set()), (0, 1));
1228        assert_eq!(second.raster(), &SECOND[..]);
1229        let alone = mesh(&material, &SECOND);
1230        assert_eq!(
1231            crop(&second, 256, 2).unwrap(),
1232            crop(&alone, 256, 2).unwrap()
1233        );
1234        assert_eq!(
1235            crop(&second, 256, 2).unwrap(),
1236            Crop {
1237                x: 48,
1238                y: 48,
1239                width: 160,
1240                height: 160
1241            }
1242        );
1243        assert_ne!(
1244            input_hash(&second, 64, 2).unwrap(),
1245            input_hash(&first, 64, 2).unwrap()
1246        );
1247        let whole = Crop {
1248            x: 0,
1249            y: 0,
1250            width: 64,
1251            height: 64,
1252        };
1253        let region = bake_crop(&second, 64, 0, whole).unwrap();
1254        assert_eq!(region.uv_set, 1);
1255        assert_eq!(bake_crop(&alone, 64, 0, whole).unwrap().uv_set, 0);
1256        let (x, y) = (25u32, 20u32);
1257        let at = [(x as f32 + 0.5) / 64.0, (y as f32 + 0.5) / 64.0];
1258        let area = edge(SECOND[0], SECOND[1], SECOND[2]);
1259        let weight = [
1260            edge(SECOND[1], SECOND[2], at) / area,
1261            edge(SECOND[2], SECOND[0], at) / area,
1262            edge(SECOND[0], SECOND[1], at) / area,
1263        ];
1264        assert!(weight.iter().all(|&w| w > 0.0));
1265        let resolved = resolve(
1266            &material,
1267            0.0,
1268            &At {
1269                position: mix3([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], weight),
1270                uv: mix2(tiled, weight),
1271                normal: [0.0, 0.0, 1.0],
1272                edge: 0.0,
1273                time: 0.0,
1274            },
1275        );
1276        let index = (y * 64 + x) as usize;
1277        assert_eq!(
1278            texel(&region.base, index)[..3],
1279            resolved.material.base.map(byte)
1280        );
1281        assert_eq!(
1282            texel(&region.roughness, index)[0],
1283            byte(resolved.material.roughness)
1284        );
1285        assert_eq!(texel(&region.base, 63 * 64 + 63), [0; 4]);
1286    }
1287
1288    fn with_second_uvs(bin: &[u8], json: &str) -> (Vec<u8>, String) {
1289        let mut bin = bin.to_vec();
1290        while !bin.len().is_multiple_of(4) {
1291            bin.push(0);
1292        }
1293        let offset = bin.len();
1294        for value in SECOND.iter().flatten() {
1295            bin.extend_from_slice(&value.to_le_bytes());
1296        }
1297        let json = json
1298            .replace("\"byteLength\":150}]", &format!("\"byteLength\":{}}}]", bin.len()))
1299            .replace("\"TEXCOORD_0\":3}", "\"TEXCOORD_0\":3,\"TEXCOORD_1\":5}")
1300            .replace(
1301                "\"type\":\"SCALAR\"}]",
1302                "\"type\":\"SCALAR\"},{\"bufferView\":5,\"componentType\":5126,\"count\":3,\"type\":\"VEC2\"}]",
1303            )
1304            .replace(
1305                "\"byteLength\":6}]",
1306                &format!("\"byteLength\":6}},{{\"buffer\":0,\"byteOffset\":{offset},\"byteLength\":24}}]"),
1307            );
1308        (bin, json)
1309    }
1310
1311    #[test]
1312    fn a_scene_part_with_texcoord_1_bakes_through_it_and_the_manifest_says_so() {
1313        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1314        let root = Path::new("tmp").join(format!("detail-uv1-test-{}", std::process::id()));
1315        if root.exists() {
1316            fs::remove_dir_all(&root).unwrap();
1317        }
1318        fs::create_dir_all(&root).unwrap();
1319        let (bin, json) = with_second_uvs(
1320            &fs::read(source.join("triangle.bin")).unwrap(),
1321            &fs::read_to_string(source.join("triangle.gltf")).unwrap(),
1322        );
1323        assert!(json.contains("TEXCOORD_1") && json.contains(&format!("{}", bin.len())));
1324        fs::write(root.join("triangle.bin"), &bin).unwrap();
1325        fs::write(root.join("triangle.gltf"), &json).unwrap();
1326        let mut material = Material::default();
1327        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1328        let recipe = crate::recipe::DetailRecipe {
1329            size: 256,
1330            padding: 2,
1331            dynamic_nodes: Vec::new(),
1332            parts: Vec::new(),
1333            sizes: Default::default(),
1334            view: None,
1335            page: atlas::PAGE,
1336        };
1337        let out = root.join("out");
1338        let report = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1339        assert_eq!(report.parts.len(), 1);
1340        assert_eq!(report.parts[0].uv_set, 1);
1341        assert_eq!(
1342            report.parts[0].crop,
1343            Crop {
1344                x: 48,
1345                y: 48,
1346                width: 160,
1347                height: 160
1348            }
1349        );
1350        let (_, atlas) = atlas::read(&out).unwrap();
1351        assert_eq!(atlas.parts[0].uv_set, 1);
1352        assert_eq!(atlas.schema(), atlas::SCHEMA_UV1);
1353        let manifest: Value =
1354            serde_json::from_slice(&fs::read(out.join("manifest.json")).unwrap()).unwrap();
1355        assert_eq!(manifest["schema_version"], atlas::SCHEMA_UV1);
1356        assert_eq!(manifest["parts"][0]["uv_set"], 1);
1357        fs::remove_dir_all(root).unwrap();
1358    }
1359
1360    fn camera(distance: f32) -> crate::recipe::DetailView {
1361        facing([1.5, 2.5, 3.0], distance)
1362    }
1363
1364    fn facing(target: [f32; 3], distance: f32) -> crate::recipe::DetailView {
1365        crate::recipe::DetailView {
1366            position: [target[0], target[1], target[2] + distance],
1367            target,
1368            fov_y_deg: 60.0,
1369            shift: [0.0, 0.0],
1370            width: 1000,
1371            height: 1000,
1372        }
1373    }
1374
1375    #[test]
1376    fn the_view_projects_the_target_to_the_middle_and_the_shift_moves_it() {
1377        let mut look = facing([0.5, 0.5, 0.0], 2.0);
1378        let view = View::look(&look);
1379        let [x, y] = project(&view, look.target).unwrap();
1380        assert!(
1381            (x - 500.0).abs() < 1e-2 && (y - 500.0).abs() < 1e-2,
1382            "{x} {y}"
1383        );
1384        look.shift = [0.2, -0.5];
1385        let [x, y] = project(&View::look(&look), look.target).unwrap();
1386        assert!(
1387            (x - 400.0).abs() < 1e-2 && (y - 250.0).abs() < 1e-2,
1388            "{x} {y}"
1389        );
1390        assert!(project(&view, [0.5, 0.5, 4.0]).is_none());
1391    }
1392
1393    #[test]
1394    fn the_footprint_picks_a_power_of_two_between_the_floor_and_the_largest() {
1395        let material = Material::default();
1396        let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1397        let input = mesh(&material, &uvs);
1398        let at = [0.5, 0.5, 0.0];
1399        let near = View::look(&facing(at, 0.5));
1400        let closest = View::look(&facing(at, 0.2));
1401        let far = View::look(&facing(at, 200.0));
1402        let middle = View::look(&facing(at, 5.0));
1403        assert_eq!(resolution(&input, &near, 2048), 2048);
1404        assert_eq!(resolution(&input, &near, 256), 256);
1405        assert_eq!(resolution(&input, &far, 2048), FLOOR);
1406        let fit = resolution(&input, &middle, 4096);
1407        assert!(
1408            fit.is_power_of_two() && (FLOOR..4096).contains(&fit),
1409            "{fit}"
1410        );
1411        assert!(fit > resolution(&input, &far, 4096));
1412        assert_eq!(resolution(&input, &near, 4096), 2048);
1413        assert_eq!(resolution(&input, &closest, 4096), 4096);
1414        assert_eq!(resolution(&input, &closest, 99999), 4096);
1415    }
1416
1417    #[test]
1418    fn each_part_takes_its_footprint_unless_a_node_is_named() {
1419        use pfx_materials::{NoiseKind, NoiseLayer};
1420
1421        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1422        let root = Path::new("tmp").join(format!("detail-sizes-test-{}", std::process::id()));
1423        if root.exists() {
1424            fs::remove_dir_all(&root).unwrap();
1425        }
1426        fs::create_dir_all(&root).unwrap();
1427        fs::copy(source.join("triangle.gltf"), root.join("triangle.gltf")).unwrap();
1428        fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1429        let mut material = Material::default();
1430        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1431        let mut recipe = crate::recipe::DetailRecipe {
1432            size: 256,
1433            padding: 2,
1434            dynamic_nodes: Vec::new(),
1435            parts: Vec::new(),
1436            sizes: Default::default(),
1437            view: Some(camera(200.0)),
1438            page: atlas::PAGE,
1439        };
1440        let out = root.join("out");
1441        let far = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false).unwrap();
1442        assert_eq!(far.parts.len(), 1);
1443        assert_eq!(far.parts[0].size, FLOOR);
1444        assert_eq!(far.parts[0].node, "triangle");
1445        assert_eq!(far.full_bytes, map_bytes(FLOOR));
1446        let manifest = fs::read(out.join("manifest.json")).unwrap();
1447        assert_eq!(
1448            fs::read_to_string(out.join("manifest.sha256")).unwrap(),
1449            digest(&manifest)
1450        );
1451        let value: Value = serde_json::from_slice(&manifest).unwrap();
1452        assert_eq!(value["schema_version"], 2);
1453        assert_eq!(value["total_bytes"], far.total_bytes);
1454        assert_eq!(value["parts"][0]["size"], FLOOR);
1455        assert_eq!(value["parts"][0]["node"], "triangle");
1456        assert_eq!(value["maps"]["base"], "bc7-srgb");
1457
1458        recipe.sizes.insert("triangle".into(), 128);
1459        assert!(
1460            bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, false)
1461                .unwrap_err()
1462                .contains("already exists")
1463        );
1464        let forced = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).unwrap();
1465        assert_eq!(forced.parts[0].size, 128);
1466        assert_eq!(forced.full_bytes, map_bytes(128));
1467        assert_eq!(atlas::read(&out).unwrap().1.parts[0].density, 128);
1468
1469        recipe.view = None;
1470        recipe.sizes.clear();
1471        let flat = bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).unwrap();
1472        assert_eq!(flat.parts[0].size, 256);
1473
1474        recipe.sizes.insert("missing".into(), 128);
1475        assert!(
1476            bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true)
1477                .unwrap_err()
1478                .contains("not a mesh node")
1479        );
1480        recipe.sizes.clear();
1481        recipe.padding = 40;
1482        recipe.view = Some(camera(200.0));
1483        recipe.size = 256;
1484        assert!(
1485            bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true)
1486                .unwrap_err()
1487                .contains("padding")
1488        );
1489        recipe.padding = 2;
1490        recipe.view = None;
1491        recipe.sizes.insert("triangle".into(), 100);
1492        assert!(bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).is_err());
1493        recipe.sizes.insert("triangle".into(), 8192);
1494        assert!(bake_scene(&root, "triangle.gltf", &[material], &recipe, &out, true).is_err());
1495        fs::remove_dir_all(root).unwrap();
1496    }
1497
1498    #[test]
1499    fn the_recipe_reads_a_view_and_sizes_and_refuses_bad_ones() {
1500        let parsed: crate::recipe::DetailRecipe = toml::from_str(
1501            "size = 2048\n[sizes]\n\"mv_knob\" = 256\n[view]\nposition = [0.0, 0.1, 1.6]\ntarget = [0.0, 0.1, 0.0]\nfov_y_deg = 13.4\nshift = [0.0, -0.64]\n",
1502        )
1503        .unwrap();
1504        parsed.check().unwrap();
1505        assert_eq!(parsed.sizes["mv_knob"], 256);
1506        let view = parsed.view.unwrap();
1507        assert_eq!((view.width, view.height), (3840, 2160));
1508        let bad = |text: &str| {
1509            toml::from_str::<crate::recipe::DetailRecipe>(text)
1510                .unwrap()
1511                .check()
1512                .is_err()
1513        };
1514        assert!(bad("size = 32"));
1515        assert!(bad("size = 8192"));
1516        assert!(bad("size = 1000"));
1517        assert!(bad("size = 1024\n[sizes]\nknob = 48"));
1518        assert!(bad("size = 1024\n[sizes]\nknob = 8192"));
1519        assert!(bad(
1520            "size = 1024\n[view]\nposition = [0.0, 0.0, 1.0]\ntarget = [0.0, 0.0, 1.0]\nfov_y_deg = 30.0"
1521        ));
1522        assert!(bad(
1523            "size = 1024\n[view]\nposition = [0.0, 0.0, 1.0]\ntarget = [0.0, 0.0, 0.0]\nfov_y_deg = 0.0"
1524        ));
1525        assert!(toml::from_str::<crate::recipe::DetailRecipe>("size = 64\nsizess = 1").is_err());
1526        let listed: crate::recipe::DetailRecipe =
1527            toml::from_str("size = 64\nparts = [\"knob\", \"lip\"]\n").unwrap();
1528        assert_eq!(listed.parts, ["knob", "lip"]);
1529        listed.check().unwrap();
1530        assert!(parsed.parts.is_empty());
1531        assert!(
1532            toml::from_str::<crate::recipe::DetailRecipe>("size = 64\nparts = [\"\"]\n")
1533                .unwrap()
1534                .check()
1535                .unwrap_err()
1536                .contains("parts")
1537        );
1538    }
1539
1540    fn folder_bytes(path: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
1541        let mut files = std::collections::BTreeMap::new();
1542        for entry in fs::read_dir(path).unwrap() {
1543            let entry = entry.unwrap();
1544            if entry.file_type().unwrap().is_file() {
1545                let name = entry.file_name().into_string().unwrap();
1546                files.insert(name, fs::read(entry.path()).unwrap());
1547            }
1548        }
1549        files
1550    }
1551
1552    #[test]
1553    fn parts_bakes_exactly_the_named_nodes_and_matches_skipping_the_rest() {
1554        use pfx_materials::{NoiseKind, NoiseLayer};
1555
1556        let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../load/tests/data");
1557        let root = Path::new("tmp").join(format!("detail-parts-test-{}", std::process::id()));
1558        if root.exists() {
1559            fs::remove_dir_all(&root).unwrap();
1560        }
1561        fs::create_dir_all(&root).unwrap();
1562        fs::copy(source.join("triangle.bin"), root.join("triangle.bin")).unwrap();
1563        let json = fs::read_to_string(source.join("triangle.gltf"))
1564            .unwrap()
1565            .replace(r#""children":[1]"#, r#""children":[1,2]"#)
1566            .replace(
1567                r#"{"name":"triangle","mesh":0,"translation":[1,2,3]}]"#,
1568                r#"{"name":"triangle","mesh":0,"translation":[1,2,3]},{"name":"wedge","mesh":0,"translation":[4,0,0]}]"#,
1569            );
1570        assert!(json.contains("wedge") && json.contains("[1,2]"));
1571        fs::write(root.join("triangle.gltf"), json).unwrap();
1572        let mut material = Material::default();
1573        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 5.0, 0.1, 7);
1574        let mut recipe = crate::recipe::DetailRecipe {
1575            size: 64,
1576            padding: 2,
1577            dynamic_nodes: Vec::new(),
1578            parts: Vec::new(),
1579            sizes: Default::default(),
1580            view: None,
1581            page: 64,
1582        };
1583        let bake = |recipe: &crate::recipe::DetailRecipe, name: &str| {
1584            let out = root.join(name);
1585            let report =
1586                bake_scene(&root, "triangle.gltf", &[material], recipe, &out, false).unwrap();
1587            (report, folder_bytes(&out))
1588        };
1589        recipe.parts = vec!["triangle".into()];
1590        let (named, named_bytes) = bake(&recipe, "named");
1591        recipe.parts.clear();
1592        recipe.dynamic_nodes = vec!["wedge".into()];
1593        let (_, skipped_bytes) = bake(&recipe, "skipped");
1594        assert_eq!(named.parts.len(), 1);
1595        assert_eq!(named.parts[0].node, "triangle");
1596        assert_eq!(named_bytes, skipped_bytes);
1597        assert_eq!(
1598            atlas::read(&root.join("named")).unwrap().1.parts[0].index,
1599            0
1600        );
1601
1602        recipe.dynamic_nodes.clear();
1603        recipe.parts = vec!["wedge".into()];
1604        let (wedge, wedge_bytes) = bake(&recipe, "wedge");
1605        recipe.parts.clear();
1606        recipe.dynamic_nodes = vec!["triangle".into()];
1607        let (_, triangle_skipped) = bake(&recipe, "triangle-skipped");
1608        assert_eq!(wedge.parts.len(), 1);
1609        assert_eq!(wedge.parts[0].node, "wedge");
1610        assert_eq!(wedge_bytes, triangle_skipped);
1611        assert_eq!(
1612            atlas::read(&root.join("wedge")).unwrap().1.parts[0].index,
1613            1
1614        );
1615
1616        recipe.dynamic_nodes.clear();
1617        recipe.parts = vec!["wedge".into(), "triangle".into()];
1618        let (both, both_bytes) = bake(&recipe, "both");
1619        recipe.parts = vec!["triangle".into(), "triangle".into(), "wedge".into()];
1620        let (_, repeated) = bake(&recipe, "repeated");
1621        recipe.parts.clear();
1622        let (all, all_bytes) = bake(&recipe, "all");
1623        assert_eq!(both.parts.len(), 2);
1624        assert_eq!(all.parts.len(), 2);
1625        assert_eq!(both_bytes, all_bytes);
1626        assert_eq!(repeated, both_bytes);
1627        let nodes: Vec<&str> = all.parts.iter().map(|part| part.node.as_str()).collect();
1628        assert_eq!(nodes, ["triangle", "wedge"]);
1629
1630        recipe.parts = vec!["missing".into()];
1631        assert!(
1632            bake_scene(
1633                &root,
1634                "triangle.gltf",
1635                &[material],
1636                &recipe,
1637                &root.join("missing"),
1638                false
1639            )
1640            .unwrap_err()
1641            .contains("not a mesh node")
1642        );
1643        recipe.parts = vec!["root".into()];
1644        assert!(
1645            bake_scene(
1646                &root,
1647                "triangle.gltf",
1648                &[material],
1649                &recipe,
1650                &root.join("root-part"),
1651                false
1652            )
1653            .unwrap_err()
1654            .contains("not a mesh node")
1655        );
1656        fs::remove_dir_all(root).unwrap();
1657    }
1658
1659    #[test]
1660    fn a_crop_is_the_uv_box_with_padding_on_the_alignment_and_bakes_like_the_tile() {
1661        let mut material = Material::default();
1662        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 9.0, 0.6, 3);
1663        let uvs = [[0.30, 0.55], [0.45, 0.55], [0.30, 0.70]];
1664        let input = mesh(&material, &uvs);
1665        let boxed = crop(&input, 256, 8).unwrap();
1666        assert_eq!(
1667            boxed,
1668            Crop {
1669                x: 64,
1670                y: 128,
1671                width: 64,
1672                height: 64
1673            }
1674        );
1675        for value in [boxed.x, boxed.y, boxed.width, boxed.height] {
1676            assert_eq!(value % atlas::ALIGN, 0);
1677        }
1678        let full = bake(&input, 256, 8).unwrap();
1679        let region = bake_crop(&input, 256, 8, boxed).unwrap();
1680        for y in 0..256u32 {
1681            for x in 0..256u32 {
1682                let tile = texel(&full.base, (y * 256 + x) as usize);
1683                let inside = (boxed.x..boxed.x + boxed.width).contains(&x)
1684                    && (boxed.y..boxed.y + boxed.height).contains(&y);
1685                if !inside {
1686                    assert_eq!(tile[3], 0, "texel {x},{y} outside the crop is covered");
1687                    continue;
1688                }
1689                let at = ((y - boxed.y) * boxed.width + x - boxed.x) as usize;
1690                assert_eq!(texel(&region.base, at), tile);
1691                assert_eq!(
1692                    texel(&region.normal, at),
1693                    texel(&full.normal, (y * 256 + x) as usize)
1694                );
1695                assert_eq!(
1696                    texel(&region.roughness, at),
1697                    texel(&full.roughness, (y * 256 + x) as usize)
1698                );
1699            }
1700        }
1701        let covered = atlas::covered_crop(256, &full.base).unwrap();
1702        assert!(covered.x >= boxed.x && covered.x + covered.width <= boxed.x + boxed.width);
1703        let edge_uvs = [[0.9, 0.9], [1.0, 0.9], [0.9, 1.0]];
1704        let edge = crop(&mesh(&material, &edge_uvs), 64, 8).unwrap();
1705        assert_eq!(
1706            edge,
1707            Crop {
1708                x: 48,
1709                y: 48,
1710                width: 16,
1711                height: 16
1712            }
1713        );
1714        let whole = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1715        assert_eq!(
1716            crop(&mesh(&material, &whole), 64, 2).unwrap(),
1717            Crop {
1718                x: 0,
1719                y: 0,
1720                width: 64,
1721                height: 64
1722            }
1723        );
1724        let flat = [[0.2, 0.2], [0.2, 0.2], [0.2, 0.2]];
1725        assert!(crop(&mesh(&material, &flat), 64, 2).is_err());
1726        assert!(
1727            bake_crop(
1728                &input,
1729                256,
1730                8,
1731                Crop {
1732                    x: 240,
1733                    y: 0,
1734                    width: 32,
1735                    height: 16
1736                }
1737            )
1738            .is_err()
1739        );
1740    }
1741
1742    #[test]
1743    fn a_detail_folder_of_the_first_format_still_loads_as_an_atlas() {
1744        let mut material = Material::default();
1745        material.layers[0] = NoiseLayer::new(NoiseKind::Value, 6.0, 0.5, 11);
1746        let uvs = [[0.1, 0.1], [0.4, 0.1], [0.1, 0.3]];
1747        let input = mesh(&material, &uvs);
1748        let maps = bake(&input, 128, 4).unwrap();
1749        let hash = input_hash(&input, 128, 4).unwrap();
1750        let root = Path::new("tmp").join(format!("detail-v1-test-{}", std::process::id()));
1751        if root.exists() {
1752            fs::remove_dir_all(&root).unwrap();
1753        }
1754        write(&root.join("part-0003"), &hash, &maps).unwrap();
1755        let summary = serde_json::to_vec_pretty(&json!({
1756            "schema_version": 1,
1757            "total_bytes": map_bytes(128),
1758            "parts": [{
1759                "path": "part-0003",
1760                "node": "sheet",
1761                "size": 128,
1762                "bytes": map_bytes(128),
1763                "input_hash": hash,
1764            }],
1765        }))
1766        .unwrap();
1767        fs::write(root.join("manifest.json"), &summary).unwrap();
1768        fs::write(root.join("manifest.sha256"), digest(&summary)).unwrap();
1769        let loaded = atlas::load(&root).unwrap();
1770        assert_eq!(loaded.parts.len(), 1);
1771        let part = &loaded.parts[0];
1772        assert_eq!(
1773            (part.index, part.node.as_str(), part.density),
1774            (3, "sheet", 128)
1775        );
1776        let crop = part.crop;
1777        assert_eq!(Some(crop), atlas::covered_crop(128, &maps.base));
1778        let region = atlas::cut(&maps, crop);
1779        let expected = atlas::chain(atlas::Kind::Base, crop.width, crop.height, &region.base, 1);
1780        let page = &loaded.base[0].levels[0];
1781        for y in 0..crop.height {
1782            let from = (y * crop.width * 4) as usize;
1783            let to = (((part.at[1] + y) * loaded.width + part.at[0]) * 4) as usize;
1784            let row = (crop.width * 4) as usize;
1785            assert_eq!(page[to..to + row], expected[0][from..from + row]);
1786        }
1787        assert!(!loaded.compressed());
1788        let replaced = root.join("manifest.json");
1789        let mut bytes = fs::read(&replaced).unwrap();
1790        bytes[0] = b' ';
1791        fs::write(&replaced, bytes).unwrap();
1792        assert!(atlas::load(&root).is_err());
1793        fs::remove_dir_all(root).unwrap();
1794    }
1795
1796    #[test]
1797    fn artifact_checks_each_payload() {
1798        let material = Material::default();
1799        let uvs = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]];
1800        let input = mesh(&material, &uvs);
1801        let maps = bake(&input, 8, 2).unwrap();
1802        let hash = input_hash(&input, 8, 2).unwrap();
1803        let root = Path::new("tmp").join(format!("detail-artifact-test-{}", std::process::id()));
1804        if root.exists() {
1805            fs::remove_dir_all(&root).unwrap();
1806        }
1807        write(&root, &hash, &maps).unwrap();
1808        assert_eq!(read(&root).unwrap(), (hash, maps));
1809        let file = root.join("base.rgba");
1810        let mut bytes = fs::read(&file).unwrap();
1811        bytes[0] ^= 1;
1812        fs::write(file, bytes).unwrap();
1813        assert!(read(&root).is_err());
1814        fs::remove_dir_all(root).unwrap();
1815    }
1816}