1use std::path::Path;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct Part {
24 pub grid_index: [i32; 3],
25 pub offset: [i32; 3],
27 pub size: [i32; 3],
28}
29
30#[derive(Debug, Clone)]
32pub struct SplitPlan {
33 pub grid: [i32; 3],
34 pub parts: Vec<Part>,
35}
36
37impl SplitPlan {
38 pub fn is_single(&self) -> bool {
40 self.grid == [1, 1, 1]
41 }
42}
43
44fn ceil_div(a: i32, b: i32) -> i32 {
45 (a + b - 1) / b
46}
47
48pub fn plan_split(size: [i32; 3], part_max: i32) -> SplitPlan {
50 let max = part_max.max(1);
51 let grid = [
52 ceil_div(size[0], max).max(1),
53 ceil_div(size[1], max).max(1),
54 ceil_div(size[2], max).max(1),
55 ];
56 let mut parts = Vec::with_capacity((grid[0] * grid[1] * grid[2]) as usize);
57 for i in 0..grid[0] {
58 for j in 0..grid[1] {
59 for k in 0..grid[2] {
60 let offset = [i * max, j * max, k * max];
61 let part_size = [
62 (size[0] - offset[0]).min(max),
63 (size[1] - offset[1]).min(max),
64 (size[2] - offset[2]).min(max),
65 ];
66 parts.push(Part {
67 grid_index: [i, j, k],
68 offset,
69 size: part_size,
70 });
71 }
72 }
73 }
74 SplitPlan { grid, parts }
75}
76
77pub fn part_filename(base: &str, grid_index: [i32; 3]) -> String {
79 format!(
80 "{base}.x{}y{}z{}.nbt",
81 grid_index[0], grid_index[1], grid_index[2]
82 )
83}
84
85pub fn manifest_filename(base: &str) -> String {
87 format!("{base}.split.json")
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
103pub struct TileSet {
104 pub base: String,
106 pub size: [i32; 3],
108 pub part_max: i32,
110 pub grid: [i32; 3],
112 pub data_version: i32,
114 #[serde(default)]
116 pub generator: String,
117 pub parts: Vec<TilePart>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
123pub struct TilePart {
124 pub file: String,
126 pub id: String,
128 pub grid_index: [i32; 3],
130 pub offset: [i32; 3],
134 pub size: [i32; 3],
136}
137
138impl TileSet {
139 pub fn validate(&self) -> Result<(), String> {
147 if self.parts.is_empty() {
148 return Err("the manifest lists no tiles".to_string());
149 }
150 let mut covered: i64 = 0;
151 for part in &self.parts {
152 for axis in 0..3 {
153 if part.size[axis] <= 0 {
154 return Err(format!("tile {:?} has a zero or negative axis", part.file));
155 }
156 if part.size[axis] > self.part_max {
157 return Err(format!(
158 "tile {:?} is {} on axis {axis}, past the declared cap of {}",
159 part.file, part.size[axis], self.part_max
160 ));
161 }
162 if part.offset[axis] < 0 || part.offset[axis] + part.size[axis] > self.size[axis] {
163 return Err(format!(
164 "tile {:?} runs outside the {}x{}x{} volume on axis {axis}",
165 part.file, self.size[0], self.size[1], self.size[2]
166 ));
167 }
168 }
169 covered += part.size[0] as i64 * part.size[1] as i64 * part.size[2] as i64;
170 }
171 let whole = self.size[0] as i64 * self.size[1] as i64 * self.size[2] as i64;
172 if covered != whole {
173 return Err(format!(
174 "the {} tile(s) cover {covered} cell(s) of a {whole}-cell volume — a tile set that \
175 does not tile its volume exactly would reassemble with a hole or an overlap",
176 self.parts.len()
177 ));
178 }
179 Ok(())
180 }
181}
182
183pub fn read_tile_set(path: &Path) -> Result<Option<TileSet>, String> {
198 let text =
199 std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
200 let meta = crate::prefab::PrefabMeta::from_json(&text)
201 .map_err(|e| format!("{}: {e}", path.display()))?;
202 Ok(meta.structure_set)
203}
204
205#[derive(Serialize)]
206struct PartManifest {
207 file: String,
208 grid_index: [i32; 3],
209 offset: [i32; 3],
210 size: [i32; 3],
211}
212
213#[derive(Serialize)]
214struct SplitManifest {
215 base: String,
216 data_version: i32,
217 source_size: [i32; 3],
218 source_offset: [i32; 3],
219 part_max: i32,
220 grid: [i32; 3],
221 parts: Vec<PartManifest>,
222}
223
224pub fn tile_filename(name: &str) -> Option<(&str, [i32; 3])> {
231 let stem = name.strip_suffix(".nbt")?;
232 let (base, suffix) = stem.rsplit_once('.')?;
233 let rest = suffix.strip_prefix('x')?;
234 let (i, rest) = rest.split_once('y')?;
235 let (j, k) = rest.split_once('z')?;
236 Some((base, [i.parse().ok()?, j.parse().ok()?, k.parse().ok()?]))
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum TileEvidence {
243 Whole,
245 Tile {
248 base: String,
250 manifest: Option<std::path::PathBuf>,
252 },
253}
254
255pub fn tile_evidence(nbt_path: &Path) -> Result<TileEvidence, String> {
278 let Some(name) = nbt_path.file_name().and_then(|s| s.to_str()) else {
279 return Ok(TileEvidence::Whole);
280 };
281 let Some((base, _)) = tile_filename(name) else {
282 return Ok(TileEvidence::Whole);
283 };
284 let manifest = nbt_path.with_file_name(format!("{base}.json"));
285 let claimed = manifest.exists()
286 && read_tile_set(&manifest)?.is_some_and(|set| set.parts.iter().any(|p| p.file == name));
287 Ok(TileEvidence::Tile {
288 base: base.to_string(),
289 manifest: claimed.then_some(manifest),
290 })
291}
292
293pub fn fragment_refusal(
300 nbt_path: &Path,
301 evidence: &TileEvidence,
302 verb: &str,
303 consequence: &str,
304) -> Option<String> {
305 let TileEvidence::Tile { base, manifest } = evidence else {
306 return None;
307 };
308 Some(match manifest {
309 Some(m) => format!(
310 "{} is one tile of the zone described by {} — to {verb} it would {consequence}. \
311 Use the whole zone: pass {}",
312 nbt_path.display(),
313 m.display(),
314 m.display()
315 ),
316 None => format!(
317 "{} is one tile of a tiled zone (`{base}`) that has been separated from its set — to \
318 {verb} it would {consequence}, and its manifest is not beside it, so there is \
319 nothing here to reassemble the zone from. Put the tile back with its `{base}.json` \
320 manifest and the rest of its tiles, and pass the manifest",
321 nbt_path.display()
322 ),
323 })
324}
325
326pub fn manifest_claiming(nbt_path: &Path) -> Result<Option<std::path::PathBuf>, String> {
328 match tile_evidence(nbt_path)? {
329 TileEvidence::Tile { manifest, .. } => Ok(manifest),
330 TileEvidence::Whole => Ok(None),
331 }
332}
333
334pub fn manifest_json(
337 base: &str,
338 data_version: i32,
339 source_size: [i32; 3],
340 source_offset: [i32; 3],
341 part_max: i32,
342 plan: &SplitPlan,
343) -> String {
344 let manifest = SplitManifest {
345 base: base.to_string(),
346 data_version,
347 source_size,
348 source_offset,
349 part_max,
350 grid: plan.grid,
351 parts: plan
352 .parts
353 .iter()
354 .map(|p| PartManifest {
355 file: part_filename(base, p.grid_index),
356 grid_index: p.grid_index,
357 offset: p.offset,
358 size: p.size,
359 })
360 .collect(),
361 };
362 serde_json::to_string_pretty(&manifest).expect("manifest serializes")
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn tile_set(size: [i32; 3], parts: Vec<([i32; 3], [i32; 3])>) -> TileSet {
370 TileSet {
371 base: "zone".to_string(),
372 size,
373 part_max: 48,
374 grid: [1, 1, parts.len() as i32],
375 data_version: 4671,
376 generator: "crates/delvec/src/grammar".to_string(),
377 parts: parts
378 .into_iter()
379 .enumerate()
380 .map(|(i, (offset, size))| TilePart {
381 file: format!("zone.x0y0z{i}.nbt"),
382 id: format!("zone.x0y0z{i}"),
383 grid_index: [0, 0, i as i32],
384 offset,
385 size,
386 })
387 .collect(),
388 }
389 }
390
391 #[test]
395 fn every_plan_split_tiling_validates() {
396 for size in [
397 [1, 1, 1],
398 [48, 48, 48],
399 [49, 1, 1],
400 [20, 10, 84],
401 [90, 14, 130],
402 [200, 100, 200],
403 ] {
404 let plan = plan_split(size, 48);
405 let set = TileSet {
406 base: "zone".to_string(),
407 size,
408 part_max: 48,
409 grid: plan.grid,
410 data_version: 4671,
411 generator: String::new(),
412 parts: plan
413 .parts
414 .iter()
415 .map(|p| TilePart {
416 file: part_filename("zone", p.grid_index),
417 id: format!("zone{:?}", p.grid_index),
418 grid_index: p.grid_index,
419 offset: p.offset,
420 size: p.size,
421 })
422 .collect(),
423 };
424 assert_eq!(set.validate(), Ok(()), "{size:?}");
425 }
426 }
427
428 #[test]
432 fn a_tiling_with_a_gap_is_refused() {
433 let short = tile_set([4, 4, 100], vec![([0, 0, 0], [4, 4, 48])]);
434 let err = short.validate().unwrap_err();
435 assert!(err.contains("cover"), "{err}");
436
437 let overlap = tile_set(
439 [4, 4, 48],
440 vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 0], [4, 4, 24])],
441 );
442 assert_eq!(overlap.validate(), Ok(()), "volume alone cannot see this");
443 let outside = tile_set(
444 [4, 4, 48],
445 vec![([0, 0, 0], [4, 4, 24]), ([0, 0, 40], [4, 4, 24])],
446 );
447 assert!(
448 outside.validate().unwrap_err().contains("outside"),
449 "a part running past the volume is caught"
450 );
451 }
452
453 #[test]
456 fn a_part_past_the_declared_cap_is_refused() {
457 let big = tile_set([4, 4, 49], vec![([0, 0, 0], [4, 4, 49])]);
458 assert!(
459 big.validate()
460 .unwrap_err()
461 .contains("past the declared cap"),
462 "{:?}",
463 big.validate()
464 );
465 }
466
467 #[test]
470 fn a_manifest_with_no_tiles_is_refused() {
471 let empty = tile_set([4, 4, 4], vec![]);
472 assert!(empty.validate().unwrap_err().contains("no tiles"));
473 }
474
475 #[test]
482 fn a_tile_is_recognised_by_its_own_name_wherever_it_is_put() {
483 let dir = std::env::temp_dir().join(format!("dw-split-evid-{}", std::process::id()));
484 let _ = std::fs::remove_dir_all(&dir);
485 std::fs::create_dir_all(dir.join("elsewhere")).unwrap();
486
487 let set = tile_set(
488 [4, 4, 60],
489 vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
490 );
491 std::fs::write(
492 dir.join("zone.json"),
493 serde_json::to_string(
494 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
495 )
496 .unwrap(),
497 )
498 .unwrap();
499 for part in &set.parts {
500 std::fs::write(dir.join(&part.file), b"not really nbt").unwrap();
501 }
502
503 assert_eq!(
505 tile_evidence(&dir.join("zone.x0y0z1.nbt")).unwrap(),
506 TileEvidence::Tile {
507 base: "zone".to_string(),
508 manifest: Some(dir.join("zone.json")),
509 }
510 );
511
512 std::fs::copy(
514 dir.join("zone.x0y0z1.nbt"),
515 dir.join("elsewhere/zone.x0y0z1.nbt"),
516 )
517 .unwrap();
518 assert_eq!(
519 tile_evidence(&dir.join("elsewhere/zone.x0y0z1.nbt")).unwrap(),
520 TileEvidence::Tile {
521 base: "zone".to_string(),
522 manifest: None,
523 },
524 "a guard a `cp` defeats is not a property of the artifact"
525 );
526
527 assert_eq!(
529 tile_evidence(&dir.join("keep-gate-room.nbt")).unwrap(),
530 TileEvidence::Whole
531 );
532 assert_eq!(tile_filename("keep-gate-room.nbt"), None);
533 assert_eq!(
534 tile_filename("zone.x0y10z2.nbt"),
535 Some(("zone", [0, 10, 2]))
536 );
537 assert_eq!(
538 tile_filename("cave.mouth.nbt"),
539 None,
540 "a dotted name is not a grid suffix"
541 );
542
543 let beside = tile_evidence(&dir.join("zone.x0y0z0.nbt")).unwrap();
545 let msg = fragment_refusal(&dir.join("zone.x0y0z0.nbt"), &beside, "audit", "lie").unwrap();
546 assert!(msg.contains("zone.json"), "{msg}");
547 let orphan = tile_evidence(&dir.join("elsewhere/zone.x0y0z0.nbt")).unwrap();
548 let msg = fragment_refusal(
549 &dir.join("elsewhere/zone.x0y0z0.nbt"),
550 &orphan,
551 "audit",
552 "lie",
553 )
554 .unwrap();
555 assert!(msg.contains("separated from its set"), "{msg}");
556 assert_eq!(
557 fragment_refusal(&dir, &TileEvidence::Whole, "audit", "lie"),
558 None
559 );
560
561 std::fs::remove_dir_all(&dir).unwrap();
562 }
563
564 #[test]
568 fn the_two_metadata_shapes_are_told_apart() {
569 let dir = std::env::temp_dir().join(format!("dw-split-shape-{}", std::process::id()));
570 let _ = std::fs::remove_dir_all(&dir);
571 std::fs::create_dir_all(&dir).unwrap();
572
573 let single = dir.join("single.json");
574 std::fs::write(
575 &single,
576 r#"{"prefab_id":"prefab/x","structure":{"file":"x.nbt","id":"x","size":[2,2,2],"data_version":4671}}"#,
577 )
578 .unwrap();
579 assert_eq!(read_tile_set(&single).unwrap(), None);
580
581 let set = tile_set(
582 [4, 4, 60],
583 vec![([0, 0, 0], [4, 4, 48]), ([0, 0, 48], [4, 4, 12])],
584 );
585 let tiled = dir.join("tiled.json");
586 std::fs::write(
587 &tiled,
588 serde_json::to_string(
589 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": set }),
590 )
591 .unwrap(),
592 )
593 .unwrap();
594 assert_eq!(read_tile_set(&tiled).unwrap().unwrap(), set);
595
596 let broken = dir.join("broken.json");
599 let bad = tile_set([4, 4, 60], vec![([0, 0, 0], [4, 4, 48])]);
600 std::fs::write(
601 &broken,
602 serde_json::to_string(
603 &serde_json::json!({ "prefab_id": "prefab/zone", "structure_set": bad }),
604 )
605 .unwrap(),
606 )
607 .unwrap();
608 assert!(read_tile_set(&broken).unwrap_err().contains("cover"));
609
610 std::fs::remove_dir_all(&dir).unwrap();
611 }
612}