Complete local voxel MVP with server, client, MCP, packages and world interchange
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
//! Independent factual content catalog and authored render templates.
|
||||
//!
|
||||
//! Minecraft IDs are provenance, never Shacraft runtime BlockIds. Collision
|
||||
//! measurements use an explicitly documented world/entity context; they do not
|
||||
//! claim to reproduce entity-dependent or block-entity-dependent behavior.
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use flate2::read::GzDecoder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read;
|
||||
|
||||
mod packages;
|
||||
mod shapes;
|
||||
pub use packages::*;
|
||||
|
||||
pub const MINECRAFT_VERSION: &str = "26.2";
|
||||
pub const DATA_VERSION: i32 = 4903;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Aabb {
|
||||
pub min: [f32; 3],
|
||||
pub max: [f32; 3],
|
||||
}
|
||||
impl Aabb {
|
||||
pub const fn new(min: [f32; 3], max: [f32; 3]) -> Self {
|
||||
Self { min, max }
|
||||
}
|
||||
pub fn volume(&self) -> f32 {
|
||||
(0..3).map(|i| self.max[i] - self.min[i]).product()
|
||||
}
|
||||
pub fn valid(&self) -> bool {
|
||||
(0..3).all(|i| {
|
||||
self.min[i].is_finite() && self.max[i].is_finite() && self.max[i] > self.min[i]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Coverage {
|
||||
pub identity: String,
|
||||
pub render: String,
|
||||
pub collision: String,
|
||||
pub behavior: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDefinition {
|
||||
pub state: String,
|
||||
/// Target Java numerical state ID, unrelated to the persistent runtime registry.
|
||||
pub minecraft_id: u32,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f32,
|
||||
pub collision: Vec<Aabb>,
|
||||
pub render: Vec<Aabb>,
|
||||
pub coverage: Coverage,
|
||||
pub family: String,
|
||||
pub light: u8,
|
||||
pub hardness: f32,
|
||||
}
|
||||
impl StateDefinition {
|
||||
pub fn solid(&self) -> bool {
|
||||
!self.collision.is_empty()
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockDefinition {
|
||||
pub name: String,
|
||||
pub default_state: String,
|
||||
pub properties: BTreeMap<String, Vec<String>>,
|
||||
pub family: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlockSample {
|
||||
pub state: String,
|
||||
pub position: [i32; 3],
|
||||
pub family: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntityDefinition {
|
||||
pub name: String,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub color: [u8; 3],
|
||||
/// Authored boxes in entity-local coordinates, relative to the feet.
|
||||
pub render: Vec<Aabb>,
|
||||
/// Default physical dimensions, registry flags, attributes, and model family.
|
||||
/// Instance properties and unknown NBT belong to the entity persistence layer.
|
||||
pub properties: BTreeMap<String, Value>,
|
||||
pub coverage: Coverage,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Catalog {
|
||||
pub version: String,
|
||||
pub data_version: i32,
|
||||
pub blocks: Vec<BlockDefinition>,
|
||||
pub states: Vec<StateDefinition>,
|
||||
pub entities: Vec<EntityDefinition>,
|
||||
#[serde(skip)]
|
||||
state_index: HashMap<String, usize>,
|
||||
#[serde(skip)]
|
||||
entity_index: HashMap<String, usize>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RawCatalog {
|
||||
version: String,
|
||||
data_version: i32,
|
||||
blocks: Vec<BlockDefinition>,
|
||||
states: Vec<RawState>,
|
||||
shapes: Vec<Vec<[f32; 6]>>,
|
||||
entities: BTreeMap<String, BTreeMap<String, Value>>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct RawState {
|
||||
state: String,
|
||||
minecraft_id: u32,
|
||||
block: usize,
|
||||
shape: usize,
|
||||
context: bool,
|
||||
light: u8,
|
||||
hardness: f32,
|
||||
}
|
||||
|
||||
fn decompress(bytes: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
GzDecoder::new(bytes)
|
||||
.take(32 * 1024 * 1024)
|
||||
.read_to_end(&mut out)?;
|
||||
ensure!(
|
||||
out.len() < 32 * 1024 * 1024,
|
||||
"catalog exceeds maximum decompressed size"
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
impl Catalog {
|
||||
pub fn load_builtin() -> Result<Self> {
|
||||
let raw: RawCatalog =
|
||||
serde_json::from_slice(&decompress(include_bytes!("../data/catalog.json.gz"))?)?;
|
||||
ensure!(
|
||||
raw.version == MINECRAFT_VERSION && raw.data_version == DATA_VERSION,
|
||||
"unexpected built-in catalog version"
|
||||
);
|
||||
let mut states = Vec::with_capacity(raw.states.len() + 1);
|
||||
for s in raw.states {
|
||||
let block = raw.blocks.get(s.block).context("invalid block index")?;
|
||||
let collision = raw
|
||||
.shapes
|
||||
.get(s.shape)
|
||||
.context("invalid shape index")?
|
||||
.iter()
|
||||
.map(|b| Aabb::new([b[0], b[1], b[2]], [b[3], b[4], b[5]]))
|
||||
.collect::<Vec<_>>();
|
||||
let (render, render_coverage) =
|
||||
shapes::block_render(&s.state, &block.family, &collision);
|
||||
let contextual = s.context
|
||||
|| matches!(
|
||||
block.family.as_str(),
|
||||
"powder_snow" | "shulker_box" | "moving_piston" | "big_dripleaf"
|
||||
);
|
||||
states.push(StateDefinition {
|
||||
color: shapes::color(&block.name),
|
||||
opacity: shapes::opacity(&block.name),
|
||||
state: s.state,
|
||||
minecraft_id: s.minecraft_id,
|
||||
collision,
|
||||
render,
|
||||
coverage: Coverage {
|
||||
identity: "official-report-exact".into(),
|
||||
render: render_coverage.into(),
|
||||
collision: if contextual {
|
||||
"approximate-context"
|
||||
} else {
|
||||
"measured-empty-context"
|
||||
}
|
||||
.into(),
|
||||
behavior: "storage-and-physics".into(),
|
||||
},
|
||||
family: block.family.clone(),
|
||||
light: s.light,
|
||||
hardness: s.hardness,
|
||||
});
|
||||
}
|
||||
let mut entities = Vec::new();
|
||||
for (name, mut properties) in raw.entities {
|
||||
let width = properties
|
||||
.get("width")
|
||||
.and_then(Value::as_f64)
|
||||
.context("entity width missing")? as f32;
|
||||
let height = properties
|
||||
.get("height")
|
||||
.and_then(Value::as_f64)
|
||||
.context("entity height missing")? as f32;
|
||||
let (render, family) = shapes::entity_render(&name, width, height);
|
||||
properties.insert("model_family".into(), json!(family));
|
||||
entities.push(EntityDefinition {
|
||||
name: name.clone(),
|
||||
width,
|
||||
height,
|
||||
color: shapes::entity_color(&name),
|
||||
render,
|
||||
properties,
|
||||
coverage: Coverage {
|
||||
identity: "official-report-exact".into(),
|
||||
render: if family == "invisible" {
|
||||
"intentionally-invisible"
|
||||
} else {
|
||||
"authored-procedural"
|
||||
}
|
||||
.into(),
|
||||
collision: "measured-default-dimensions".into(),
|
||||
behavior: "persistent-instance-no-vanilla-ai".into(),
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut result = Self {
|
||||
version: raw.version,
|
||||
data_version: raw.data_version,
|
||||
blocks: raw.blocks,
|
||||
states,
|
||||
entities,
|
||||
state_index: HashMap::new(),
|
||||
entity_index: HashMap::new(),
|
||||
};
|
||||
result.add_trampoline();
|
||||
result.rebuild_indexes()?;
|
||||
Ok(result)
|
||||
}
|
||||
pub fn rebuild_indexes(&mut self) -> Result<()> {
|
||||
self.state_index.clear();
|
||||
self.entity_index.clear();
|
||||
for (i, s) in self.states.iter().enumerate() {
|
||||
ensure!(
|
||||
self.state_index.insert(s.state.clone(), i).is_none(),
|
||||
"duplicate canonical state {}",
|
||||
s.state
|
||||
);
|
||||
ensure!(
|
||||
s.collision.iter().chain(s.render.iter()).all(Aabb::valid),
|
||||
"invalid geometry for {}",
|
||||
s.state
|
||||
);
|
||||
}
|
||||
for (i, e) in self.entities.iter().enumerate() {
|
||||
ensure!(
|
||||
self.entity_index.insert(e.name.clone(), i).is_none(),
|
||||
"duplicate entity {}",
|
||||
e.name
|
||||
);
|
||||
ensure!(
|
||||
e.render.iter().all(Aabb::valid),
|
||||
"invalid entity model {}",
|
||||
e.name
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn state(&self, canonical: &str) -> Option<&StateDefinition> {
|
||||
self.state_index
|
||||
.get(canonical)
|
||||
.map(|i| &self.states[*i])
|
||||
.or_else(|| {
|
||||
self.blocks
|
||||
.iter()
|
||||
.find(|b| b.name == canonical)
|
||||
.and_then(|b| self.state_index.get(&b.default_state))
|
||||
.map(|i| &self.states[*i])
|
||||
})
|
||||
}
|
||||
pub fn entity(&self, name: &str) -> Option<&EntityDefinition> {
|
||||
self.entity_index.get(name).map(|i| &self.entities[*i])
|
||||
}
|
||||
pub fn default_state(&self, name: &str) -> Option<&str> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.map(|b| b.default_state.as_str())
|
||||
}
|
||||
pub fn palette(&self) -> BTreeMap<String, String> {
|
||||
[
|
||||
("grass", "minecraft:grass_block"),
|
||||
("stone", "minecraft:stone"),
|
||||
("snow", "minecraft:snow_block"),
|
||||
("glass", "minecraft:glass"),
|
||||
("wood", "minecraft:oak_planks"),
|
||||
("stairs", "minecraft:oak_stairs"),
|
||||
("slab", "minecraft:oak_slab"),
|
||||
("fence", "minecraft:oak_fence"),
|
||||
("water", "minecraft:water"),
|
||||
("sand", "minecraft:sand"),
|
||||
("light", "minecraft:sea_lantern"),
|
||||
("trampoline", "shacraft:trampoline"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(key, name)| {
|
||||
self.default_state(name)
|
||||
.map(|s| (key.to_owned(), s.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// One default state per block, in an original 32-column inspection garden.
|
||||
/// The floor is caller-owned; every sample is one cell above it.
|
||||
pub fn sampler(&self) -> Vec<BlockSample> {
|
||||
self.blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| BlockSample {
|
||||
state: b.default_state.clone(),
|
||||
family: b.family.clone(),
|
||||
position: [-48 + (i % 32) as i32 * 3, 1, -57 + (i / 32) as i32 * 3],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn summary(&self) -> Value {
|
||||
let mut render = BTreeMap::<&str, usize>::new();
|
||||
let mut collision = BTreeMap::<&str, usize>::new();
|
||||
for s in &self.states {
|
||||
*render.entry(&s.coverage.render).or_default() += 1;
|
||||
*collision.entry(&s.coverage.collision).or_default() += 1;
|
||||
}
|
||||
json!({"minecraft_version":self.version,"data_version":self.data_version,
|
||||
"minecraft_blocks":self.blocks.iter().filter(|b|b.name.starts_with("minecraft:")).count(),
|
||||
"minecraft_states":self.states.iter().filter(|s|s.state.starts_with("minecraft:")).count(),
|
||||
"entities":self.entities.len(),"total_states":self.states.len(),"render":render,"collision":collision,
|
||||
"palette":self.palette(),"collision_context":"BlockPos.ZERO, air neighbors, empty entity context; contextual classes flagged separately",
|
||||
"simulation":"server movement, block editing, authored modules; vanilla AI/redstone/fluid simulation are not implemented"})
|
||||
}
|
||||
fn add_trampoline(&mut self) {
|
||||
self.blocks.push(BlockDefinition {
|
||||
name: "shacraft:trampoline".into(),
|
||||
default_state: "shacraft:trampoline".into(),
|
||||
properties: BTreeMap::new(),
|
||||
family: "trampoline".into(),
|
||||
});
|
||||
self.states.push(StateDefinition {
|
||||
state: "shacraft:trampoline".into(),
|
||||
minecraft_id: u32::MAX,
|
||||
color: [97, 205, 151],
|
||||
opacity: 1.,
|
||||
collision: vec![Aabb::new([0., 0., 0.], [1., 1., 1.])],
|
||||
render: vec![
|
||||
Aabb::new([0., 0., 0.], [1., 0.18, 1.]),
|
||||
Aabb::new([0.05, 0.18, 0.05], [0.95, 0.86, 0.95]),
|
||||
Aabb::new([0., 0.86, 0.], [1., 1., 1.]),
|
||||
],
|
||||
coverage: Coverage {
|
||||
identity: "authored-package".into(),
|
||||
render: "authored-procedural".into(),
|
||||
collision: "authored-exact".into(),
|
||||
behavior: "wasm-on-jump".into(),
|
||||
},
|
||||
family: "trampoline".into(),
|
||||
light: 0,
|
||||
hardness: 1.,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize registry state keys without discarding unknown properties.
|
||||
pub fn canonical_state(name: &str, properties: &BTreeMap<String, String>) -> Result<String> {
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.is_ascii()
|
||||
&& name.contains(':')
|
||||
&& !name
|
||||
.bytes()
|
||||
.any(|b| b.is_ascii_whitespace() || b"[]=,".contains(&b)),
|
||||
"invalid resource name"
|
||||
);
|
||||
for (k, v) in properties {
|
||||
if k.is_empty()
|
||||
|| v.is_empty()
|
||||
|| !k.is_ascii()
|
||||
|| !v.is_ascii()
|
||||
|| k.bytes()
|
||||
.chain(v.bytes())
|
||||
.any(|b| b.is_ascii_whitespace() || b"[]=,:".contains(&b))
|
||||
{
|
||||
bail!("invalid property");
|
||||
}
|
||||
}
|
||||
let result = if properties.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{}[{}]",
|
||||
name,
|
||||
properties
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
};
|
||||
ensure!(result.len() <= 512, "canonical state too long");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
fn catalog() -> &'static Catalog {
|
||||
static C: OnceLock<Catalog> = OnceLock::new();
|
||||
C.get_or_init(|| Catalog::load_builtin().unwrap())
|
||||
}
|
||||
#[test]
|
||||
fn complete_against_independent_official_report_projection() {
|
||||
let c = catalog();
|
||||
let report: Value = serde_json::from_slice(
|
||||
&decompress(include_bytes!("../data/report-identities.json.gz")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let blocks = report["blocks"].as_object().unwrap();
|
||||
assert_eq!(blocks.len(), 1196);
|
||||
assert_eq!(c.data_version, 4903);
|
||||
let mut actual = HashMap::new();
|
||||
for s in &c.states {
|
||||
if s.state.starts_with("minecraft:") {
|
||||
actual.insert(s.state.clone(), s.minecraft_id);
|
||||
}
|
||||
}
|
||||
let mut expected = HashMap::new();
|
||||
for (name, b) in blocks {
|
||||
let def = c.blocks.iter().find(|b| &b.name == name).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&def.properties).unwrap(),
|
||||
b["properties"]
|
||||
);
|
||||
for state in b["states"].as_array().unwrap() {
|
||||
let props =
|
||||
serde_json::from_value(state.get("properties").cloned().unwrap_or(json!({})))
|
||||
.unwrap();
|
||||
let name = canonical_state(name, &props).unwrap();
|
||||
expected.insert(name.clone(), state["id"].as_u64().unwrap() as u32);
|
||||
if state.get("default") == Some(&json!(true)) {
|
||||
assert_eq!(def.default_state, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(actual.len(), 32366);
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
c.entities
|
||||
.iter()
|
||||
.map(|e| e.name.clone())
|
||||
.collect::<HashSet<_>>(),
|
||||
report["entities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap().to_owned())
|
||||
.collect()
|
||||
);
|
||||
assert!(c.state("minecraft:potent_sulfur").is_some());
|
||||
assert!(c.entity("minecraft:sulfur_cube").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn every_state_has_valid_shapes_and_explicit_coverage() {
|
||||
for s in &catalog().states {
|
||||
assert!(!s.coverage.render.is_empty());
|
||||
assert!(!s.coverage.collision.is_empty());
|
||||
for b in s.collision.iter().chain(&s.render) {
|
||||
assert!(b.valid(), "{} {b:?}", s.state);
|
||||
assert!(
|
||||
b.min.iter().chain(&b.max).all(|v| (-2.0..=3.0).contains(v)),
|
||||
"{} {b:?}",
|
||||
s.state
|
||||
);
|
||||
}
|
||||
if s.family != "air"
|
||||
&& !matches!(
|
||||
s.family.as_str(),
|
||||
"light" | "structure_void" | "barrier" | "moving_piston"
|
||||
)
|
||||
{
|
||||
assert!(!s.render.is_empty(), "missing {}", s.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn official_entity_dimensions_and_authored_models_present() {
|
||||
assert_eq!(catalog().entities.len(), 158);
|
||||
for e in &catalog().entities {
|
||||
assert!(e.width >= 0. && e.height >= 0.);
|
||||
assert!(e.properties.contains_key("attributes"));
|
||||
if e.properties["model_family"] != "invisible" {
|
||||
assert!(!e.render.is_empty());
|
||||
}
|
||||
}
|
||||
assert_eq!(catalog().entity("minecraft:player").unwrap().width, 0.6);
|
||||
assert_eq!(
|
||||
catalog().entity("minecraft:cow").unwrap().properties["model_family"],
|
||||
"quadruped"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sampler_defaults_and_palette_are_registered() {
|
||||
let c = catalog();
|
||||
let samples = c.sampler();
|
||||
assert_eq!(samples.len(), 1197);
|
||||
let positions = samples.iter().map(|s| s.position).collect::<HashSet<_>>();
|
||||
assert_eq!(positions.len(), samples.len());
|
||||
for sample in samples {
|
||||
assert!(c.state(&sample.state).is_some());
|
||||
assert!(sample.position[0].abs() <= 64 && sample.position[2].abs() <= 64);
|
||||
}
|
||||
for state in c.palette().values() {
|
||||
assert!(c.state(state).is_some());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn key_collision_states_and_orientation() {
|
||||
let c = catalog();
|
||||
let bottom = c
|
||||
.state("minecraft:oak_slab[type=bottom,waterlogged=false]")
|
||||
.unwrap();
|
||||
let top = c
|
||||
.state("minecraft:oak_slab[type=top,waterlogged=false]")
|
||||
.unwrap();
|
||||
assert_eq!(bottom.collision[0], Aabb::new([0., 0., 0.], [1., 0.5, 1.]));
|
||||
assert_eq!(top.collision[0].min[1], 0.5);
|
||||
for half in ["bottom", "top"] {
|
||||
for facing in ["north", "south", "east", "west"] {
|
||||
let s=c.state(&format!("minecraft:oak_stairs[facing={facing},half={half},shape=straight,waterlogged=false]")).unwrap();
|
||||
assert!((s.collision.iter().map(Aabb::volume).sum::<f32>() - 0.75).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
let door = c
|
||||
.state(
|
||||
"minecraft:oak_door[facing=north,half=lower,hinge=left,open=false,powered=false]",
|
||||
)
|
||||
.unwrap();
|
||||
let open = c
|
||||
.state("minecraft:oak_door[facing=north,half=lower,hinge=left,open=true,powered=false]")
|
||||
.unwrap();
|
||||
assert_ne!(door.collision, open.collision);
|
||||
assert!(
|
||||
c.state("minecraft:water[level=0]")
|
||||
.unwrap()
|
||||
.collision
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:oak_fence")
|
||||
.unwrap()
|
||||
.collision
|
||||
.iter()
|
||||
.any(|b| b.max[1] == 1.5)
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:scaffolding")
|
||||
.unwrap()
|
||||
.coverage
|
||||
.collision
|
||||
.contains("approximate")
|
||||
);
|
||||
assert!(
|
||||
c.state("minecraft:powder_snow")
|
||||
.unwrap()
|
||||
.coverage
|
||||
.collision
|
||||
.contains("approximate")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Unified packages: verified resources with an explicit public/server boundary.
|
||||
use anyhow::{Context, Result, bail, ensure};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
pub const MAX_PACKAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageDependency {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageResource {
|
||||
pub path: String,
|
||||
pub scope: String,
|
||||
pub role: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PackageManifest {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub license: String,
|
||||
pub dependencies: Vec<PackageDependency>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub resources: Vec<PackageResource>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Package {
|
||||
pub directory: PathBuf,
|
||||
pub manifest: PackageManifest,
|
||||
}
|
||||
fn token(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 128
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
}
|
||||
fn version(s: &str) -> bool {
|
||||
let p = s.split('.').collect::<Vec<_>>();
|
||||
p.len() == 3
|
||||
&& p.iter().all(|s| {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 5
|
||||
&& (s.len() == 1 || !s.starts_with('0'))
|
||||
&& s.bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
fn safe_path(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 256
|
||||
&& s.is_ascii()
|
||||
&& !s.contains('\\')
|
||||
&& !s.contains(':')
|
||||
&& !s.contains('%')
|
||||
&& !s.bytes().any(|b| b.is_ascii_control())
|
||||
&& !Path::new(s).is_absolute()
|
||||
&& Path::new(s)
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
&& s.split('/').all(|s| !s.is_empty() && s != "." && s != "..")
|
||||
}
|
||||
impl PackageManifest {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
ensure!(self.schema == 1, "unsupported package schema");
|
||||
ensure!(token(&self.id), "invalid package id");
|
||||
ensure!(version(&self.version), "invalid exact semantic version");
|
||||
ensure!(
|
||||
!self.license.is_empty()
|
||||
&& self.license.len() <= 256
|
||||
&& !self.license.chars().any(char::is_control),
|
||||
"license required"
|
||||
);
|
||||
ensure!(
|
||||
self.resources.len() <= 128 && !self.resources.is_empty(),
|
||||
"invalid package resource count"
|
||||
);
|
||||
ensure!(
|
||||
self.dependencies.len() <= 32 && self.capabilities.len() <= 32,
|
||||
"too many dependencies or capabilities"
|
||||
);
|
||||
let mut paths = HashSet::new();
|
||||
let mut total = 0u64;
|
||||
for r in &self.resources {
|
||||
ensure!(safe_path(&r.path), "unsafe resource path {}", r.path);
|
||||
ensure!(paths.insert(&r.path), "duplicate resource path");
|
||||
ensure!(
|
||||
matches!(r.scope.as_str(), "client" | "common" | "server"),
|
||||
"invalid resource scope"
|
||||
);
|
||||
ensure!(token(&r.role), "invalid resource role");
|
||||
ensure!(
|
||||
r.sha256.len() == 64
|
||||
&& r.sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
|
||||
"invalid SHA-256"
|
||||
);
|
||||
ensure!(r.size <= MAX_RESOURCE_BYTES, "resource too large");
|
||||
total = total.checked_add(r.size).context("package size overflow")?;
|
||||
if r.role == "wasm-on-jump" {
|
||||
ensure!(
|
||||
r.scope == "server" && r.path.ends_with(".wasm"),
|
||||
"executable jump hook must be server-only WASM"
|
||||
);
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
total <= MAX_PACKAGE_BYTES,
|
||||
"package exceeds total size limit"
|
||||
);
|
||||
let mut deps = HashSet::new();
|
||||
for d in &self.dependencies {
|
||||
ensure!(
|
||||
token(&d.id) && version(&d.version) && d.id != self.id && deps.insert(&d.id),
|
||||
"invalid or duplicate dependency"
|
||||
);
|
||||
}
|
||||
for c in &self.capabilities {
|
||||
ensure!(
|
||||
matches!(
|
||||
c.as_str(),
|
||||
"blocks.define"
|
||||
| "entities.define"
|
||||
| "client.texture"
|
||||
| "client.shader"
|
||||
| "client.sound"
|
||||
| "client.style"
|
||||
| "server.on_jump"
|
||||
),
|
||||
"unsupported capability {c}"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.resources.iter().all(|r| r.role != "wasm-on-jump"
|
||||
|| self.capabilities.iter().any(|c| c == "server.on_jump")),
|
||||
"WASM hook capability missing"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// Clients never receive server resource names, contents, sizes, or hashes.
|
||||
pub fn client_manifest(&self) -> Value {
|
||||
json!({"schema":self.schema,"id":self.id,"version":self.version,"license":self.license,
|
||||
"dependencies":self.dependencies,"capabilities":self.capabilities.iter().filter(|s|!s.starts_with("server.")).collect::<Vec<_>>(),
|
||||
"resources":self.resources.iter().filter(|r|r.scope!="server").collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
impl Package {
|
||||
pub fn load(directory: impl AsRef<Path>) -> Result<Self> {
|
||||
let dir = directory.as_ref();
|
||||
ensure!(
|
||||
!dir.symlink_metadata()?.file_type().is_symlink(),
|
||||
"package directory is a symlink"
|
||||
);
|
||||
let directory = dir.canonicalize()?;
|
||||
let path = directory.join("manifest.json");
|
||||
ensure!(
|
||||
!path.symlink_metadata()?.file_type().is_symlink(),
|
||||
"package manifest is a symlink"
|
||||
);
|
||||
ensure!(path.metadata()?.len() <= 256 * 1024, "manifest too large");
|
||||
let manifest: PackageManifest = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||
manifest.validate()?;
|
||||
let package = Self {
|
||||
directory,
|
||||
manifest,
|
||||
};
|
||||
for r in &package.manifest.resources {
|
||||
package.read_resource(&r.path)?;
|
||||
}
|
||||
Ok(package)
|
||||
}
|
||||
/// Re-verifies the resource at read time to reject replacement after loading.
|
||||
pub fn read_resource(&self, path: &str) -> Result<Vec<u8>> {
|
||||
let resource = self
|
||||
.manifest
|
||||
.resources
|
||||
.iter()
|
||||
.find(|r| r.path == path)
|
||||
.context("resource is not declared")?;
|
||||
ensure!(safe_path(path), "unsafe resource path");
|
||||
let mut real = self.directory.clone();
|
||||
for part in Path::new(path).components() {
|
||||
real.push(part);
|
||||
ensure!(
|
||||
!real.symlink_metadata()?.file_type().is_symlink(),
|
||||
"resource paths may not contain symlinks"
|
||||
);
|
||||
}
|
||||
let file = std::fs::File::open(&real)?;
|
||||
ensure!(
|
||||
file.metadata()?.is_file()
|
||||
&& file.metadata()?.len() == resource.size
|
||||
&& resource.size <= MAX_RESOURCE_BYTES,
|
||||
"resource size mismatch"
|
||||
);
|
||||
use std::io::Read;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_RESOURCE_BYTES + 1).read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() as u64 == resource.size,
|
||||
"resource changed while reading"
|
||||
);
|
||||
ensure!(
|
||||
format!("{:x}", Sha256::digest(&bytes)) == resource.sha256,
|
||||
"resource hash mismatch: {path}"
|
||||
);
|
||||
Ok(bytes)
|
||||
}
|
||||
pub fn public_resource(&self, path: &str) -> Result<Vec<u8>> {
|
||||
ensure!(
|
||||
self.manifest
|
||||
.resources
|
||||
.iter()
|
||||
.any(|r| r.path == path && r.scope != "server"),
|
||||
"resource is not public"
|
||||
);
|
||||
self.read_resource(path)
|
||||
}
|
||||
}
|
||||
/// Load and verify the entire dependency graph. Missing or incompatible required
|
||||
/// packages and cycles fail before the server exposes any package to clients.
|
||||
pub fn load_packages(root: impl AsRef<Path>) -> Result<Vec<Package>> {
|
||||
let mut dirs = std::fs::read_dir(root)?
|
||||
.map(|e| e.map(|e| e.path()))
|
||||
.collect::<std::io::Result<Vec<_>>>()?;
|
||||
dirs.sort();
|
||||
ensure!(dirs.len() <= 128, "too many package entries");
|
||||
let mut out = Vec::new();
|
||||
let mut ids = BTreeMap::new();
|
||||
for path in dirs {
|
||||
if path.is_dir() && path.join("manifest.json").exists() {
|
||||
let p = Package::load(path)?;
|
||||
ensure!(
|
||||
ids.insert(p.manifest.id.clone(), out.len()).is_none(),
|
||||
"duplicate package id"
|
||||
);
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
for p in &out {
|
||||
for d in &p.manifest.dependencies {
|
||||
let i = *ids
|
||||
.get(&d.id)
|
||||
.with_context(|| format!("missing required package {}", d.id))?;
|
||||
ensure!(
|
||||
out[i].manifest.version == d.version,
|
||||
"dependency version mismatch for {}",
|
||||
d.id
|
||||
);
|
||||
}
|
||||
}
|
||||
fn visit(
|
||||
i: usize,
|
||||
p: &[Package],
|
||||
ids: &BTreeMap<String, usize>,
|
||||
visiting: &mut HashSet<usize>,
|
||||
done: &mut HashSet<usize>,
|
||||
) -> Result<()> {
|
||||
if done.contains(&i) {
|
||||
return Ok(());
|
||||
}
|
||||
if !visiting.insert(i) {
|
||||
bail!("package dependency cycle")
|
||||
}
|
||||
for d in &p[i].manifest.dependencies {
|
||||
visit(ids[&d.id], p, ids, visiting, done)?;
|
||||
}
|
||||
visiting.remove(&i);
|
||||
done.insert(i);
|
||||
Ok(())
|
||||
}
|
||||
let mut done = HashSet::new();
|
||||
for i in 0..out.len() {
|
||||
visit(i, &out, &ids, &mut HashSet::new(), &mut done)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn package_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages")
|
||||
}
|
||||
#[test]
|
||||
fn bundled_packages_hashes_dependencies_and_privacy() {
|
||||
let p = load_packages(package_root()).unwrap();
|
||||
assert_eq!(p.len(), 2);
|
||||
let t = p
|
||||
.iter()
|
||||
.find(|p| p.manifest.id == "shacraft.trampoline")
|
||||
.unwrap();
|
||||
assert!(
|
||||
t.read_resource("server/trampoline.wasm")
|
||||
.unwrap()
|
||||
.starts_with(b"\0asm")
|
||||
);
|
||||
assert!(t.public_resource("server/trampoline.wasm").is_err());
|
||||
let public = t.manifest.client_manifest().to_string();
|
||||
assert!(!public.contains("server/"));
|
||||
assert!(!public.contains("server.on_jump"));
|
||||
}
|
||||
#[test]
|
||||
fn paths_sizes_and_versions_are_validated() {
|
||||
let p = load_packages(package_root()).unwrap();
|
||||
for path in [
|
||||
"../secret",
|
||||
"/etc/passwd",
|
||||
"client/../../secret",
|
||||
"client\\secret",
|
||||
"client//file",
|
||||
"client/./file",
|
||||
"client/%2e%2e/key",
|
||||
] {
|
||||
let mut m = p[0].manifest.clone();
|
||||
m.resources[0].path = path.into();
|
||||
assert!(m.validate().is_err(), "{path}");
|
||||
}
|
||||
let mut m = p[0].manifest.clone();
|
||||
m.resources[0].size = MAX_RESOURCE_BYTES + 1;
|
||||
assert!(m.validate().is_err());
|
||||
m = p[0].manifest.clone();
|
||||
m.version = "latest".into();
|
||||
assert!(m.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn changed_resource_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bytes = b"own content";
|
||||
std::fs::write(dir.path().join("asset"), bytes).unwrap();
|
||||
let m = PackageManifest {
|
||||
schema: 1,
|
||||
id: "test.sample".into(),
|
||||
version: "1.0.0".into(),
|
||||
license: "MIT".into(),
|
||||
dependencies: vec![],
|
||||
capabilities: vec![],
|
||||
resources: vec![PackageResource {
|
||||
path: "asset".into(),
|
||||
scope: "client".into(),
|
||||
role: "texture".into(),
|
||||
sha256: format!("{:x}", Sha256::digest(bytes)),
|
||||
size: bytes.len() as u64,
|
||||
}],
|
||||
};
|
||||
std::fs::write(
|
||||
dir.path().join("manifest.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = Package::load(dir.path()).unwrap();
|
||||
std::fs::write(dir.path().join("asset"), b"bad content").unwrap();
|
||||
assert!(p.read_resource("asset").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
//! Shacraft's original low-poly templates; no Minecraft visual models/assets.
|
||||
use crate::Aabb;
|
||||
use std::collections::BTreeMap;
|
||||
fn bx(a: [f32; 3], b: [f32; 3]) -> Aabb {
|
||||
Aabb::new(a, b)
|
||||
}
|
||||
fn props(state: &str) -> BTreeMap<&str, &str> {
|
||||
state
|
||||
.split_once('[')
|
||||
.map(|(_, tail)| {
|
||||
tail.trim_end_matches(']')
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('='))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn get<'a>(p: &BTreeMap<&'a str, &'a str>, key: &str, default: &'a str) -> &'a str {
|
||||
p.get(key).copied().unwrap_or(default)
|
||||
}
|
||||
fn num(p: &BTreeMap<&str, &str>, key: &str, default: f32) -> f32 {
|
||||
p.get(key).and_then(|s| s.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
fn rotate(boxes: Vec<Aabb>, facing: &str) -> Vec<Aabb> {
|
||||
let steps = match facing {
|
||||
"east" => 1,
|
||||
"south" => 2,
|
||||
"west" => 3,
|
||||
_ => 0,
|
||||
};
|
||||
boxes
|
||||
.into_iter()
|
||||
.map(|mut b| {
|
||||
for _ in 0..steps {
|
||||
b = bx(
|
||||
[1. - b.max[2], b.min[1], b.min[0]],
|
||||
[1. - b.min[2], b.max[1], b.max[0]],
|
||||
)
|
||||
}
|
||||
b
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
const FULL: Aabb = Aabb::new([0., 0., 0.], [1., 1., 1.]);
|
||||
pub(crate) fn block_render(
|
||||
state: &str,
|
||||
family: &str,
|
||||
collision: &[Aabb],
|
||||
) -> (Vec<Aabb>, &'static str) {
|
||||
let p = props(state);
|
||||
let name = state.split('[').next().unwrap_or(state);
|
||||
let facing = get(&p, "facing", "north");
|
||||
let b = match family {
|
||||
"air" | "barrier" | "light" | "structure_void" | "moving_piston" => {
|
||||
return (vec![], "intentionally-invisible");
|
||||
}
|
||||
"liquid" | "bubble_column" => vec![bx(
|
||||
[0., 0., 0.],
|
||||
[
|
||||
1.,
|
||||
if num(&p, "level", 0.) >= 8. {
|
||||
1.
|
||||
} else {
|
||||
(8. - num(&p, "level", 0.)).max(1.) / 9.
|
||||
},
|
||||
1.,
|
||||
],
|
||||
)],
|
||||
"button" | "lever" => {
|
||||
let pressed = get(&p, "powered", "false") == "true";
|
||||
let depth = if pressed { 0.0625 } else { 0.125 };
|
||||
let face = get(&p, "face", "wall");
|
||||
let mut b = vec![match face {
|
||||
"floor" => bx([0.3125, 0., 0.375], [0.6875, depth, 0.625]),
|
||||
"ceiling" => bx([0.3125, 1. - depth, 0.375], [0.6875, 1., 0.625]),
|
||||
_ => bx([0.3125, 0.375, 1. - depth], [0.6875, 0.625, 1.]),
|
||||
}];
|
||||
if family == "lever" {
|
||||
b.push(match face {
|
||||
"floor" => bx(
|
||||
[0.46875, 0.0625, if pressed { 0.55 } else { 0.25 }],
|
||||
[0.53125, 0.375, if pressed { 0.75 } else { 0.45 }],
|
||||
),
|
||||
"ceiling" => bx(
|
||||
[0.46875, 0.625, if pressed { 0.55 } else { 0.25 }],
|
||||
[0.53125, 0.9375, if pressed { 0.75 } else { 0.45 }],
|
||||
),
|
||||
_ => bx(
|
||||
[0.46875, if pressed { 0.25 } else { 0.55 }, 0.625],
|
||||
[0.53125, if pressed { 0.45 } else { 0.75 }, 0.9375],
|
||||
),
|
||||
});
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"pressure_plate" | "weighted_pressure_plate" => vec![bx(
|
||||
[0.0625, 0., 0.0625],
|
||||
[
|
||||
0.9375,
|
||||
if get(&p, "powered", "false") == "true" || num(&p, "power", 0.) > 0. {
|
||||
0.03125
|
||||
} else {
|
||||
0.0625
|
||||
},
|
||||
0.9375,
|
||||
],
|
||||
)],
|
||||
"trip_wire_hook" => rotate(
|
||||
vec![
|
||||
bx([0.375, 0.25, 0.875], [0.625, 0.75, 1.]),
|
||||
bx([0.46875, 0.4375, 0.5], [0.53125, 0.5, 0.9375]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"snow_layer" => vec![bx([0., 0., 0.], [1., num(&p, "layers", 1.) / 8., 1.])],
|
||||
"mossy_carpet" => {
|
||||
let mut b = vec![bx([0., 0., 0.], [1., 0.0625, 1.])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0., 0., 0.], [1., 0., 0.0625]),
|
||||
("south", [0., 0., 0.9375], [1., 0., 1.]),
|
||||
("west", [0., 0., 0.], [0.0625, 0., 1.]),
|
||||
("east", [0.9375, 0., 0.], [1., 0., 1.]),
|
||||
] {
|
||||
let v = get(&p, dir, "none");
|
||||
if v != "none" {
|
||||
b.push(bx(a, [z[0], if v == "tall" { 1. } else { 0.5 }, z[2]]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"big_dripleaf" | "big_dripleaf_stem" => {
|
||||
let mut b = vec![bx([0.4375, 0., 0.4375], [0.5625, 0.875, 0.5625])];
|
||||
if family == "big_dripleaf" {
|
||||
let tilt = get(&p, "tilt", "none");
|
||||
b.push(if tilt == "full" {
|
||||
bx([0.0625, 0.375, 0.75], [0.9375, 0.875, 0.8125])
|
||||
} else {
|
||||
bx(
|
||||
[
|
||||
0.0625,
|
||||
if tilt == "partial" { 0.625 } else { 0.8125 },
|
||||
0.0625,
|
||||
],
|
||||
[0.9375, 0.875, 0.9375],
|
||||
)
|
||||
});
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"fence" => {
|
||||
let mut b = vec![bx([0.375, 0., 0.375], [0.625, 1., 0.625])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0.4375, 0., 0.], [0.5625, 0., 0.5]),
|
||||
("south", [0.4375, 0., 0.5], [0.5625, 0., 1.]),
|
||||
("west", [0., 0., 0.4375], [0.5, 0., 0.5625]),
|
||||
("east", [0.5, 0., 0.4375], [1., 0., 0.5625]),
|
||||
] {
|
||||
if get(&p, dir, "false") == "true" {
|
||||
for y in [0.375, 0.75] {
|
||||
b.push(bx([a[0], y, a[2]], [z[0], y + 0.1875, z[2]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"wall" => {
|
||||
let mut b = vec![];
|
||||
if get(&p, "up", "true") == "true" {
|
||||
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
|
||||
}
|
||||
for (dir, a, z) in [
|
||||
("north", [0.3125, 0., 0.], [0.6875, 0., 0.5]),
|
||||
("south", [0.3125, 0., 0.5], [0.6875, 0., 1.]),
|
||||
("west", [0., 0., 0.3125], [0.5, 0., 0.6875]),
|
||||
("east", [0.5, 0., 0.3125], [1., 0., 0.6875]),
|
||||
] {
|
||||
let value = get(&p, dir, "none");
|
||||
if value != "none" {
|
||||
b.push(bx(
|
||||
a,
|
||||
[z[0], if value == "tall" { 1. } else { 0.8125 }, z[2]],
|
||||
));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
b.push(bx([0.25, 0., 0.25], [0.75, 1., 0.75]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"fence_gate" => {
|
||||
let mut b = vec![
|
||||
bx([0., 0.3125, 0.375], [0.125, 1., 0.625]),
|
||||
bx([0.875, 0.3125, 0.375], [1., 1., 0.625]),
|
||||
];
|
||||
if get(&p, "open", "false") == "true" {
|
||||
b.push(bx([0., 0.375, 0.5], [0.1875, 0.875, 1.]));
|
||||
b.push(bx([0.8125, 0.375, 0.5], [1., 0.875, 1.]));
|
||||
} else {
|
||||
for y in [0.375, 0.75] {
|
||||
b.push(bx([0.125, y, 0.4375], [0.875, y + 0.125, 0.5625]));
|
||||
}
|
||||
}
|
||||
rotate(b, facing)
|
||||
}
|
||||
"rail" | "powered_rail" | "detector_rail" => {
|
||||
let s = get(&p, "shape", "north_south");
|
||||
let mut b = vec![];
|
||||
if s.starts_with("ascending_") {
|
||||
for i in 0..8 {
|
||||
let z = i as f32 / 8.;
|
||||
for x in [0.1875, 0.75] {
|
||||
b.push(bx([x, 0.875 - z, z], [x + 0.0625, 0.9375 - z, z + 0.125]));
|
||||
}
|
||||
}
|
||||
rotate(b, s.trim_start_matches("ascending_"))
|
||||
} else {
|
||||
for x in [0.1875, 0.75] {
|
||||
b.push(bx([x, 0.0625, 0.], [x + 0.0625, 0.125, 1.]));
|
||||
}
|
||||
for z in [0.125, 0.5, 0.875] {
|
||||
b.push(bx([0.125, 0., z], [0.875, 0.0625, (z + 0.0625).min(1.)]));
|
||||
}
|
||||
// Curved tracks use a visible orthogonal corner in this box renderer.
|
||||
if s == "east_west" {
|
||||
rotate(b, "east")
|
||||
} else if s.contains('_') && s != "north_south" {
|
||||
let mut c = vec![
|
||||
bx([0.1875, 0.06, 0.5], [1., 0.125, 0.5625]),
|
||||
bx([0.1875, 0.06, 0.], [0.25, 0.125, 0.56]),
|
||||
bx([0.75, 0.06, 0.], [0.8125, 0.125, 0.94]),
|
||||
bx([0.75, 0.06, 0.875], [1., 0.125, 0.9375]),
|
||||
];
|
||||
c = rotate(
|
||||
c,
|
||||
match s {
|
||||
"south_east" => "east",
|
||||
"south_west" => "south",
|
||||
"north_west" => "west",
|
||||
_ => "north",
|
||||
},
|
||||
);
|
||||
c
|
||||
} else {
|
||||
b
|
||||
}
|
||||
}
|
||||
}
|
||||
"ladder" => rotate(
|
||||
vec![
|
||||
bx([0.125, 0., 0.9375], [0.25, 1., 1.]),
|
||||
bx([0.75, 0., 0.9375], [0.875, 1., 1.]),
|
||||
bx([0.125, 0.1875, 0.875], [0.875, 0.25, 1.]),
|
||||
bx([0.125, 0.4375, 0.875], [0.875, 0.5, 1.]),
|
||||
bx([0.125, 0.6875, 0.875], [0.875, 0.75, 1.]),
|
||||
bx([0.125, 0.9375, 0.875], [0.875, 1., 1.]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"standing_sign" | "banner" => rotate(
|
||||
vec![
|
||||
bx([0.46875, 0., 0.46875], [0.53125, 0.5, 0.53125]),
|
||||
bx([0.0625, 0.5, 0.46875], [0.9375, 0.9375, 0.53125]),
|
||||
],
|
||||
rotation_facing(num(&p, "rotation", 0.)),
|
||||
),
|
||||
"wall_sign" | "wall_banner" => rotate(
|
||||
vec![bx([0.0625, 0.25, 0.9375], [0.9375, 0.8125, 1.])],
|
||||
facing,
|
||||
),
|
||||
"ceiling_hanging_sign" | "wall_hanging_sign" => rotate(
|
||||
vec![
|
||||
bx([0.0625, 0.125, 0.4375], [0.9375, 0.6875, 0.5625]),
|
||||
bx([0.1875, 0.6875, 0.46875], [0.25, 1., 0.53125]),
|
||||
bx([0.75, 0.6875, 0.46875], [0.8125, 1., 0.53125]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"torch" | "redstone_torch" => vec![
|
||||
bx([0.4375, 0., 0.4375], [0.5625, 0.625, 0.5625]),
|
||||
bx([0.375, 0.5, 0.375], [0.625, 0.75, 0.625]),
|
||||
],
|
||||
"wall_torch" | "redstone_wall_torch" => rotate(
|
||||
vec![
|
||||
bx([0.4375, 0.125, 0.6875], [0.5625, 0.5, 0.875]),
|
||||
bx([0.375, 0.4375, 0.625], [0.625, 0.6875, 0.875]),
|
||||
],
|
||||
facing,
|
||||
),
|
||||
"redstone_wire" | "tripwire" => {
|
||||
let mut b = vec![bx([0.3125, 0.01, 0.3125], [0.6875, 0.04, 0.6875])];
|
||||
for (dir, a, z) in [
|
||||
("north", [0.4375, 0.01, 0.], [0.5625, 0.04, 0.5]),
|
||||
("south", [0.4375, 0.01, 0.5], [0.5625, 0.04, 1.]),
|
||||
("west", [0., 0.01, 0.4375], [0.5, 0.04, 0.5625]),
|
||||
("east", [0.5, 0.01, 0.4375], [1., 0.04, 0.5625]),
|
||||
] {
|
||||
let v = get(&p, dir, "none");
|
||||
if v != "none" && v != "false" {
|
||||
b.push(bx(a, z));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"nether_portal" => {
|
||||
if get(&p, "axis", "x") == "x" {
|
||||
vec![bx([0., 0., 0.4375], [1., 1., 0.5625])]
|
||||
} else {
|
||||
vec![bx([0.4375, 0., 0.], [0.5625, 1., 1.])]
|
||||
}
|
||||
}
|
||||
"end_portal" | "end_gateway" => vec![bx([0., 0.125, 0.], [1., 0.1875, 1.])],
|
||||
"fire" | "soul_fire" => vec![
|
||||
bx([0.125, 0., 0.46875], [0.875, 0.875, 0.53125]),
|
||||
bx([0.46875, 0., 0.125], [0.53125, 0.75, 0.875]),
|
||||
],
|
||||
"vine" | "glow_lichen" | "sculk_vein" | "multiface" => {
|
||||
let mut b = vec![];
|
||||
for (dir, a, z) in [
|
||||
("north", [0., 0., 0.], [1., 1., 0.03125]),
|
||||
("south", [0., 0., 0.96875], [1., 1., 1.]),
|
||||
("west", [0., 0., 0.], [0.03125, 1., 1.]),
|
||||
("east", [0.96875, 0., 0.], [1., 1., 1.]),
|
||||
("up", [0., 0.96875, 0.], [1., 1., 1.]),
|
||||
("down", [0., 0., 0.], [1., 0.03125, 1.]),
|
||||
] {
|
||||
if get(&p, dir, "false") == "true" {
|
||||
b.push(bx(a, z));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
b.push(bx([0., 0., 0.], [1., 1., 0.03125]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"flower_pot" => {
|
||||
let mut b = vec![bx([0.3125, 0., 0.3125], [0.6875, 0.375, 0.6875])];
|
||||
if name != "minecraft:flower_pot" {
|
||||
b.extend(plant(0.8));
|
||||
}
|
||||
b
|
||||
}
|
||||
"crop" | "beetroot" | "potato" | "carrot" | "torchflower_crop" | "pitcher_crop"
|
||||
| "nether_wart" => plant(0.2 + num(&p, "age", 0.).min(7.) / 10.),
|
||||
"flower"
|
||||
| "wither_rose"
|
||||
| "eyeblossom"
|
||||
| "tall_flower"
|
||||
| "double_plant"
|
||||
| "sapling"
|
||||
| "bush"
|
||||
| "tall_grass"
|
||||
| "short_dry_grass"
|
||||
| "tall_dry_grass"
|
||||
| "dry_vegetation"
|
||||
| "sugar_cane"
|
||||
| "mushroom"
|
||||
| "nether_fungus"
|
||||
| "nether_roots"
|
||||
| "nether_sprouts"
|
||||
| "hanging_roots"
|
||||
| "hanging_moss"
|
||||
| "firefly_bush"
|
||||
| "sweet_berry_bush"
|
||||
| "mangrove_propagule"
|
||||
| "bamboo_sapling"
|
||||
| "stem"
|
||||
| "attached_stem"
|
||||
| "seagrass"
|
||||
| "tall_seagrass"
|
||||
| "kelp"
|
||||
| "kelp_plant"
|
||||
| "twisting_vines"
|
||||
| "twisting_vines_plant"
|
||||
| "weeping_vines"
|
||||
| "weeping_vines_plant"
|
||||
| "cave_vines"
|
||||
| "cave_vines_plant"
|
||||
| "small_dripleaf"
|
||||
| "spore_blossom"
|
||||
| "cactus_flower" => plant(if family == "mushroom" { 0.45 } else { 0.85 }),
|
||||
"leaf_litter" | "flower_bed" | "frogspawn" => {
|
||||
vec![bx([0.0625, 0.015, 0.0625], [0.9375, 0.0625, 0.9375])]
|
||||
}
|
||||
"coral"
|
||||
| "coral_plant"
|
||||
| "base_coral_plant"
|
||||
| "coral_fan"
|
||||
| "base_coral_fan"
|
||||
| "coral_wall_fan"
|
||||
| "base_coral_wall_fan" => plant(0.625),
|
||||
"web" => vec![
|
||||
bx([0., 0., 0.46875], [1., 1., 0.53125]),
|
||||
bx([0.46875, 0., 0.], [0.53125, 1., 1.]),
|
||||
],
|
||||
"powder_snow" => vec![FULL],
|
||||
_ => {
|
||||
if !collision.is_empty() {
|
||||
collision.to_vec()
|
||||
} else {
|
||||
// Noncollidable interaction geometry authored from the family, no
|
||||
// silent air substitution. Rare unsupported visual details remain
|
||||
// explicitly approximate in the coverage field.
|
||||
return (
|
||||
vec![bx([0.125, 0., 0.125], [0.875, 0.5, 0.875])],
|
||||
"authored-approximate",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
(b, "authored-procedural")
|
||||
}
|
||||
fn rotation_facing(v: f32) -> &'static str {
|
||||
match ((v + 2.) as i32 / 4).rem_euclid(4) {
|
||||
0 => "south",
|
||||
1 => "west",
|
||||
2 => "north",
|
||||
_ => "east",
|
||||
}
|
||||
}
|
||||
fn plant(height: f32) -> Vec<Aabb> {
|
||||
vec![
|
||||
bx([0.46875, 0., 0.46875], [0.53125, height, 0.53125]),
|
||||
bx(
|
||||
[0.1875, height * 0.35, 0.46875],
|
||||
[0.8125, height * 0.75, 0.53125],
|
||||
),
|
||||
bx(
|
||||
[0.46875, height * 0.25, 0.1875],
|
||||
[0.53125, height * 0.65, 0.8125],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn color(name: &str) -> [u8; 3] {
|
||||
let name = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let colors = [
|
||||
("light_blue", [108, 169, 193]),
|
||||
("light_gray", [170, 173, 163]),
|
||||
("magenta", [175, 97, 153]),
|
||||
("orange", [207, 136, 66]),
|
||||
("yellow", [220, 191, 80]),
|
||||
("purple", [130, 96, 164]),
|
||||
("cyan", [61, 155, 156]),
|
||||
("blue", [74, 111, 181]),
|
||||
("brown", [119, 88, 65]),
|
||||
("green", [91, 132, 75]),
|
||||
("lime", [148, 185, 87]),
|
||||
("pink", [216, 153, 165]),
|
||||
("red", [179, 78, 64]),
|
||||
("black", [54, 59, 62]),
|
||||
("gray", [104, 113, 112]),
|
||||
("white", [215, 219, 207]),
|
||||
];
|
||||
for (prefix, c) in colors {
|
||||
if name.starts_with(&format!("{prefix}_")) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
if name.contains("sulfur") {
|
||||
return [220, 210, 78];
|
||||
}
|
||||
if name.contains("cinnabar") {
|
||||
return [185, 78, 63];
|
||||
}
|
||||
if name.contains("snow") || name.contains("quartz") || name.contains("calcite") {
|
||||
return [217, 229, 228];
|
||||
}
|
||||
if name.contains("water") {
|
||||
return [69, 133, 182];
|
||||
}
|
||||
if name.contains("lava") {
|
||||
return [226, 123, 49];
|
||||
}
|
||||
if name.contains("glass") || name.contains("ice") {
|
||||
return [139, 190, 203];
|
||||
}
|
||||
if name.contains("grass") || name.contains("leaves") || name.contains("moss") {
|
||||
return [109, 148, 82];
|
||||
}
|
||||
if name.contains("cherry") {
|
||||
return [191, 141, 142];
|
||||
}
|
||||
if name.contains("pale_oak") {
|
||||
return [194, 192, 169];
|
||||
}
|
||||
if name.contains("crimson") {
|
||||
return [132, 63, 82];
|
||||
}
|
||||
if name.contains("warped") {
|
||||
return [58, 139, 135];
|
||||
}
|
||||
if name.contains("dark_oak") || name.contains("spruce") {
|
||||
return [106, 85, 60];
|
||||
}
|
||||
if name.contains("birch") || name.contains("bamboo") {
|
||||
return [190, 179, 121];
|
||||
}
|
||||
if name.contains("acacia") || name.contains("mangrove") {
|
||||
return [159, 102, 71];
|
||||
}
|
||||
if name.contains("oak")
|
||||
|| name.contains("jungle")
|
||||
|| name.contains("wood")
|
||||
|| name.contains("chest")
|
||||
{
|
||||
return [163, 134, 91];
|
||||
}
|
||||
if name.contains("copper") {
|
||||
return if name.contains("oxidized") {
|
||||
[79, 155, 131]
|
||||
} else {
|
||||
[179, 115, 83]
|
||||
};
|
||||
}
|
||||
if name.contains("gold") {
|
||||
return [210, 181, 81];
|
||||
}
|
||||
if name.contains("diamond") {
|
||||
return [91, 189, 191];
|
||||
}
|
||||
if name.contains("emerald") {
|
||||
return [67, 161, 112];
|
||||
}
|
||||
if name.contains("redstone") {
|
||||
return [162, 70, 57];
|
||||
}
|
||||
if name.contains("sand") || name.contains("end_stone") {
|
||||
return [195, 184, 144];
|
||||
}
|
||||
if name.contains("dirt") || name.contains("mud") {
|
||||
return [131, 108, 83];
|
||||
}
|
||||
if name.contains("deepslate") || name.contains("blackstone") || name.contains("obsidian") {
|
||||
return [69, 72, 80];
|
||||
}
|
||||
if name.contains("stone") || name.contains("andesite") || name.contains("cobble") {
|
||||
return [141, 150, 146];
|
||||
}
|
||||
let hash = name
|
||||
.bytes()
|
||||
.fold(2166136261u32, |h, b| (h ^ b as u32).wrapping_mul(16777619));
|
||||
[
|
||||
100 + (hash % 82) as u8,
|
||||
100 + ((hash >> 8) % 82) as u8,
|
||||
100 + ((hash >> 16) % 82) as u8,
|
||||
]
|
||||
}
|
||||
pub(crate) fn opacity(name: &str) -> f32 {
|
||||
if name.contains("glass") {
|
||||
0.42
|
||||
} else if name.contains("water") || name.contains("ice") {
|
||||
0.68
|
||||
} else {
|
||||
1.
|
||||
}
|
||||
}
|
||||
pub(crate) fn entity_color(name: &str) -> [u8; 3] {
|
||||
match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"player" | "mannequin" => [78, 154, 169],
|
||||
"cow" | "mooshroom" => [132, 108, 90],
|
||||
"pig" => [209, 150, 155],
|
||||
"sheep" => [219, 216, 196],
|
||||
"creeper" | "zombie" | "zombie_villager" => [110, 152, 98],
|
||||
"skeleton" | "stray" | "parched" => [201, 202, 179],
|
||||
"enderman" | "ender_dragon" => [66, 60, 83],
|
||||
"sulfur_cube" => [213, 209, 75],
|
||||
"slime" => [129, 188, 103],
|
||||
"magma_cube" => [149, 76, 44],
|
||||
"allay" => [96, 199, 212],
|
||||
"bee" => [213, 182, 72],
|
||||
"fox" => [191, 123, 71],
|
||||
"wolf" => [169, 177, 166],
|
||||
_ => color(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn entity_render(name: &str, width: f32, height: f32) -> (Vec<Aabb>, &'static str) {
|
||||
let n = name.strip_prefix("minecraft:").unwrap_or(name);
|
||||
let family = if n.ends_with("_boat") || n.ends_with("_raft") {
|
||||
"boat"
|
||||
} else if n.ends_with("minecart") {
|
||||
"cart"
|
||||
} else {
|
||||
match n {
|
||||
"marker" | "interaction" | "area_effect_cloud" | "lightning_bolt" => "invisible",
|
||||
"cow" | "mooshroom" | "pig" | "sheep" | "wolf" | "cat" | "ocelot" | "fox" | "panda"
|
||||
| "polar_bear" | "goat" | "horse" | "skeleton_horse" | "zombie_horse" | "donkey"
|
||||
| "mule" | "llama" | "trader_llama" | "camel" | "camel_husk" | "hoglin" | "zoglin"
|
||||
| "ravager" | "sniffer" | "armadillo" => "quadruped",
|
||||
"creeper" => "creeper",
|
||||
"spider" | "cave_spider" | "silverfish" | "endermite" => "arthropod",
|
||||
"cod" | "salmon" | "tropical_fish" | "pufferfish" | "dolphin" | "tadpole"
|
||||
| "axolotl" | "guardian" | "elder_guardian" | "nautilus" | "zombie_nautilus" => {
|
||||
"aquatic"
|
||||
}
|
||||
"squid" | "glow_squid" => "squid",
|
||||
"ghast" | "happy_ghast" => "ghast",
|
||||
"slime" | "magma_cube" | "sulfur_cube" | "shulker" => "cube-creature",
|
||||
"bat" | "bee" | "allay" | "vex" | "parrot" | "phantom" => "winged",
|
||||
"chicken" => "bird",
|
||||
"rabbit" => "rabbit",
|
||||
"frog" | "turtle" => "low-quadruped",
|
||||
"ender_dragon" => "dragon",
|
||||
"wither" => "wither",
|
||||
"snow_golem" => "snow-golem",
|
||||
"blaze" | "breeze" => "elemental",
|
||||
"player" | "mannequin" | "zombie" | "zombie_villager" | "drowned" | "husk"
|
||||
| "skeleton" | "stray" | "parched" | "bogged" | "wither_skeleton"
|
||||
| "zombified_piglin" | "piglin" | "piglin_brute" | "villager" | "wandering_trader"
|
||||
| "pillager" | "vindicator" | "evoker" | "illusioner" | "witch" | "enderman"
|
||||
| "iron_golem" | "copper_golem" | "creaking" | "warden" | "giant" | "armor_stand"
|
||||
| "strider" => "biped",
|
||||
"painting" | "item_frame" | "glow_item_frame" => "frame",
|
||||
"block_display" | "item_display" | "text_display" => "property-dependent-display",
|
||||
"arrow" | "spectral_arrow" | "trident" | "firework_rocket" => "bolt",
|
||||
"falling_block" | "tnt" => "block",
|
||||
"end_crystal" | "ominous_item_spawner" => "crystal",
|
||||
"evoker_fangs" => "fangs",
|
||||
"item" | "leash_knot" | "fishing_bobber" | "egg" | "ender_pearl"
|
||||
| "experience_bottle" | "experience_orb" | "eye_of_ender" | "fireball"
|
||||
| "small_fireball" | "dragon_fireball" | "snowball" | "splash_potion"
|
||||
| "lingering_potion" | "llama_spit" | "shulker_bullet" | "wind_charge"
|
||||
| "breeze_wind_charge" | "wither_skull" => "small-object",
|
||||
_ => "small-object",
|
||||
}
|
||||
};
|
||||
if family == "invisible" {
|
||||
return (vec![], family);
|
||||
}
|
||||
let mut b = match family {
|
||||
"biped" => vec![
|
||||
bx([-0.3, 0.38, -0.18], [0.3, 0.77, 0.18]),
|
||||
bx([-0.23, 0.77, -0.23], [0.23, 1., 0.23]),
|
||||
bx([-0.27, 0., -0.16], [-0.04, 0.4, 0.16]),
|
||||
bx([0.04, 0., -0.16], [0.27, 0.4, 0.16]),
|
||||
bx([-0.5, 0.4, -0.14], [-0.31, 0.75, 0.14]),
|
||||
bx([0.31, 0.4, -0.14], [0.5, 0.75, 0.14]),
|
||||
],
|
||||
"quadruped" | "low-quadruped" => {
|
||||
let mut b = vec![
|
||||
bx([-0.4, 0.3, -0.35], [0.4, 0.8, 0.35]),
|
||||
bx([-0.3, 0.58, -0.5], [0.3, 0.98, -0.2]),
|
||||
];
|
||||
for x in [-0.35, 0.15] {
|
||||
for z in [-0.3, 0.18] {
|
||||
b.push(bx([x, 0., z], [x + 0.2, 0.4, z + 0.15]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"creeper" => vec![
|
||||
bx([-0.27, 0.22, -0.2], [0.27, 0.73, 0.2]),
|
||||
bx([-0.38, 0.7, -0.38], [0.38, 1., 0.38]),
|
||||
bx([-0.38, 0., -0.38], [-0.02, 0.25, -0.06]),
|
||||
bx([0.02, 0., -0.38], [0.38, 0.25, -0.06]),
|
||||
bx([-0.38, 0., 0.06], [-0.02, 0.25, 0.38]),
|
||||
bx([0.02, 0., 0.06], [0.38, 0.25, 0.38]),
|
||||
],
|
||||
"arthropod" => {
|
||||
let mut b = vec![
|
||||
bx([-0.27, 0.3, -0.35], [0.27, 0.7, 0.35]),
|
||||
bx([-0.2, 0.32, -0.5], [0.2, 0.62, -0.25]),
|
||||
];
|
||||
for z in [-0.32, -0.12, 0.12, 0.32] {
|
||||
b.push(bx([-0.5, 0.1, z], [-0.2, 0.35, z + 0.07]));
|
||||
b.push(bx([0.2, 0.1, z], [0.5, 0.35, z + 0.07]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"aquatic" => vec![
|
||||
bx([-0.24, 0.25, -0.45], [0.24, 0.7, 0.25]),
|
||||
bx([-0.4, 0.3, 0.2], [0.4, 0.62, 0.5]),
|
||||
bx([-0.03, 0.7, -0.15], [0.03, 1., 0.1]),
|
||||
bx([-0.45, 0.25, -0.2], [0.45, 0.35, 0.1]),
|
||||
],
|
||||
"squid" | "ghast" => {
|
||||
let mut b = vec![bx([-0.4, 0.4, -0.4], [0.4, 1., 0.4])];
|
||||
for x in [-0.3, 0., 0.3] {
|
||||
for z in [-0.3, 0., 0.3] {
|
||||
b.push(bx([x - 0.04, 0., z - 0.04], [x + 0.04, 0.45, z + 0.04]));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
"cube-creature" => vec![
|
||||
bx([-0.5, 0., -0.5], [0.5, 1., 0.5]),
|
||||
bx([-0.3, 0.55, -0.52], [-0.12, 0.72, -0.49]),
|
||||
bx([0.12, 0.55, -0.52], [0.3, 0.72, -0.49]),
|
||||
],
|
||||
"winged" => vec![
|
||||
bx([-0.19, 0.2, -0.3], [0.19, 0.8, 0.3]),
|
||||
bx([-0.23, 0.65, -0.4], [0.23, 1., -0.05]),
|
||||
bx([-0.5, 0.45, -0.1], [-0.15, 0.6, 0.3]),
|
||||
bx([0.15, 0.45, -0.1], [0.5, 0.6, 0.3]),
|
||||
],
|
||||
"bird" => vec![
|
||||
bx([-0.34, 0.25, -0.25], [0.34, 0.7, 0.25]),
|
||||
bx([-0.23, 0.65, -0.35], [0.23, 1., -0.02]),
|
||||
bx([-0.12, 0.73, -0.5], [0.12, 0.82, -0.3]),
|
||||
bx([-0.22, 0., -0.03], [-0.08, 0.3, 0.06]),
|
||||
bx([0.08, 0., -0.03], [0.22, 0.3, 0.06]),
|
||||
],
|
||||
"rabbit" => vec![
|
||||
bx([-0.3, 0.12, -0.25], [0.3, 0.6, 0.3]),
|
||||
bx([-0.22, 0.5, -0.4], [0.22, 0.78, -0.05]),
|
||||
bx([-0.2, 0.7, -0.25], [-0.08, 1., -0.1]),
|
||||
bx([0.08, 0.7, -0.25], [0.2, 1., -0.1]),
|
||||
bx([-0.4, 0., 0.05], [-0.1, 0.2, 0.4]),
|
||||
bx([0.1, 0., 0.05], [0.4, 0.2, 0.4]),
|
||||
],
|
||||
"dragon" => vec![
|
||||
bx([-0.15, 0.3, -0.3], [0.15, 0.7, 0.3]),
|
||||
bx([-0.1, 0.4, -0.5], [0.1, 0.65, -0.2]),
|
||||
bx([-0.5, 0.48, -0.1], [-0.12, 0.56, 0.3]),
|
||||
bx([0.12, 0.48, -0.1], [0.5, 0.56, 0.3]),
|
||||
bx([-0.055, 0.35, 0.25], [0.055, 0.45, 0.5]),
|
||||
],
|
||||
"wither" => vec![
|
||||
bx([-0.1, 0.05, -0.15], [0.1, 0.75, 0.15]),
|
||||
bx([-0.4, 0.65, -0.12], [0.4, 0.75, 0.12]),
|
||||
bx([-0.17, 0.75, -0.2], [0.17, 1., 0.2]),
|
||||
bx([-0.5, 0.65, -0.18], [-0.25, 0.9, 0.18]),
|
||||
bx([0.25, 0.65, -0.18], [0.5, 0.9, 0.18]),
|
||||
],
|
||||
"snow-golem" => vec![
|
||||
bx([-0.45, 0., -0.45], [0.45, 0.4, 0.45]),
|
||||
bx([-0.35, 0.4, -0.35], [0.35, 0.75, 0.35]),
|
||||
bx([-0.25, 0.75, -0.25], [0.25, 1., 0.25]),
|
||||
bx([-0.5, 0.5, -0.04], [0.5, 0.6, 0.04]),
|
||||
],
|
||||
"elemental" => {
|
||||
let mut b = vec![bx([-0.3, 0.7, -0.3], [0.3, 1., 0.3])];
|
||||
for (x, z) in [(-0.4, -0.3), (0.3, -0.3), (-0.4, 0.3), (0.3, 0.3)] {
|
||||
b.push(bx([x, 0.15, z], [x + 0.1, 0.65, z + 0.1]));
|
||||
}
|
||||
b
|
||||
}
|
||||
"boat" => vec![
|
||||
bx([-0.5, 0., -0.5], [0.5, 0.2, 0.5]),
|
||||
bx([-0.5, 0.2, -0.5], [-0.36, 0.7, 0.5]),
|
||||
bx([0.36, 0.2, -0.5], [0.5, 0.7, 0.5]),
|
||||
bx([-0.36, 0.2, -0.5], [0.36, 0.7, -0.38]),
|
||||
bx([-0.36, 0.2, 0.38], [0.36, 0.7, 0.5]),
|
||||
bx([-0.36, 0.32, -0.08], [0.36, 0.42, 0.08]),
|
||||
],
|
||||
"cart" => vec![
|
||||
bx([-0.5, 0.1, -0.5], [0.5, 0.3, 0.5]),
|
||||
bx([-0.5, 0.3, -0.5], [-0.38, 0.85, 0.5]),
|
||||
bx([0.38, 0.3, -0.5], [0.5, 0.85, 0.5]),
|
||||
bx([-0.38, 0.3, -0.5], [0.38, 0.85, -0.38]),
|
||||
bx([-0.38, 0.3, 0.38], [0.38, 0.85, 0.5]),
|
||||
],
|
||||
"frame" | "property-dependent-display" => vec![bx([-0.5, 0., -0.05], [0.5, 1., 0.05])],
|
||||
"bolt" => vec![
|
||||
bx([-0.04, 0.45, -0.5], [0.04, 0.55, 0.5]),
|
||||
bx([-0.18, 0.43, -0.5], [0.18, 0.57, -0.3]),
|
||||
],
|
||||
"block" => vec![bx([-0.5, 0., -0.5], [0.5, 1., 0.5])],
|
||||
"crystal" => vec![
|
||||
bx([-0.3, 0.25, -0.3], [0.3, 0.75, 0.3]),
|
||||
bx([-0.15, 0.1, -0.15], [0.15, 0.9, 0.15]),
|
||||
bx([-0.45, 0., -0.45], [0.45, 0.08, 0.45]),
|
||||
],
|
||||
"fangs" => vec![
|
||||
bx([-0.35, 0., -0.3], [-0.1, 1., 0.3]),
|
||||
bx([0.1, 0., -0.3], [0.35, 1., 0.3]),
|
||||
],
|
||||
_ => vec![
|
||||
bx([-0.3, 0.2, -0.4], [0.3, 0.8, 0.4]),
|
||||
bx([-0.4, 0.3, -0.25], [0.4, 0.7, 0.25]),
|
||||
],
|
||||
};
|
||||
if family == "boat" && n.contains("chest") {
|
||||
b.push(bx([-0.25, 0.4, 0.08], [0.25, 0.95, 0.42]));
|
||||
}
|
||||
if family == "cart" && n != "minecart" {
|
||||
b.push(bx([-0.3, 0.3, -0.3], [0.3, 1., 0.3]));
|
||||
}
|
||||
let w = width.max(0.25);
|
||||
let h = height.max(0.25);
|
||||
for v in &mut b {
|
||||
for i in 0..3 {
|
||||
v.min[i] *= if i == 1 { h } else { w };
|
||||
v.max[i] *= if i == 1 { h } else { w };
|
||||
}
|
||||
}
|
||||
(b, family)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn four_quarter_rotations_restore_box() {
|
||||
let initial = vec![bx([0.125, 0.25, 0.375], [0.25, 0.5, 0.75])];
|
||||
let mut b = initial.clone();
|
||||
for _ in 0..4 {
|
||||
b = rotate(b, "east");
|
||||
}
|
||||
assert_eq!(b, initial);
|
||||
}
|
||||
#[test]
|
||||
fn connected_fence_has_two_added_rails() {
|
||||
let (a, _) = block_render(
|
||||
"minecraft:oak_fence[east=false,north=false,south=false,waterlogged=false,west=false]",
|
||||
"fence",
|
||||
&[],
|
||||
);
|
||||
let (b, _) = block_render(
|
||||
"minecraft:oak_fence[east=true,north=false,south=false,waterlogged=false,west=false]",
|
||||
"fence",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(b.len(), a.len() + 2);
|
||||
assert!(b.iter().all(|b| b.max[1] <= 1.));
|
||||
}
|
||||
#[test]
|
||||
fn rail_ascent_reaches_next_block() {
|
||||
let (b, _) = block_render(
|
||||
"minecraft:rail[shape=ascending_north,waterlogged=false]",
|
||||
"rail",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(b.len(), 16);
|
||||
assert!(b.iter().any(|b| b.max[1] > 0.9));
|
||||
assert!(b.iter().all(Aabb::valid));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user