Phase 13: real non-cube block models via blockstate/model JSON resolution
Adds worker/src/models.rs (parent-chain blockstate/model resolution, texture-variable substitution reusing Phase 12's atlas keys), routes non-cube blocks through new per-element mesh emission in mesh.rs while leaving full-cube blocks on the existing cube mesher, and threads a per-server ModelContext (vanilla worker-wide + modded per-job) through main.rs. Modded model JSON is stored in a new block_models Postgres table and read alongside the existing (previously write-only) block_registry table. Fixes a pre-existing face-culling bug as a side effect of excluding non-cube voxels from the cube mesher's input. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
@@ -53,6 +53,26 @@ pub fn texture_name(block_id: u16, meta: u8) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a handful of well-known **non-cube** vanilla blocks to their real blockstate registry
|
||||
/// name (see `models.rs`), so `mesh.rs` can render their actual shape instead of a flat cube.
|
||||
/// Deliberately small and hand-picked, not a full 1-256 vanilla id table: this is a demo of the
|
||||
/// Phase 13 model-resolution pipeline working end to end on real game data, not an attempt at
|
||||
/// exhaustive vanilla coverage — expanding it is just adding more match arms, no design changes
|
||||
/// needed. Cube-shaped blocks (including ones covered by `texture_name` above) are deliberately
|
||||
/// absent: `models::ResolvedModel::is_full_cube` would just send them right back to the existing
|
||||
/// greedy cube mesher, so there's no point resolving them through this path at all.
|
||||
pub fn vanilla_model_name(block_id: u16, meta: u8) -> Option<&'static str> {
|
||||
match block_id {
|
||||
50 => Some("minecraft:torch"),
|
||||
53 => Some("minecraft:oak_stairs"),
|
||||
65 => Some("minecraft:ladder"),
|
||||
85 => Some("minecraft:oak_fence"),
|
||||
37 => Some("minecraft:dandelion"),
|
||||
38 if meta == 0 => Some("minecraft:poppy"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn wool_texture(meta: u8) -> &'static str {
|
||||
match meta {
|
||||
0 => "white_wool",
|
||||
@@ -102,4 +122,17 @@ mod tests {
|
||||
fn unmapped_block_returns_none() {
|
||||
assert_eq!(texture_name(9999, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_model_name_covers_the_hand_picked_non_cube_demo_blocks() {
|
||||
assert_eq!(vanilla_model_name(50, 0), Some("minecraft:torch"));
|
||||
assert_eq!(vanilla_model_name(85, 0), Some("minecraft:oak_fence"));
|
||||
assert_eq!(vanilla_model_name(9999, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_shaped_blocks_are_absent_from_the_non_cube_table() {
|
||||
assert_eq!(vanilla_model_name(1, 0), None); // stone
|
||||
assert_eq!(vanilla_model_name(4, 0), None); // cobblestone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,42 @@ pub async fn fetch_chunk_sections(
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Phase 13: the mod's numeric-blockId -> registry-name dump for one server (see api's
|
||||
/// `textures.ts`/`ws-gateway.ts`) — read here so `models::resolve_for_block` can look up a
|
||||
/// modded voxel's real registry name (`meta` carries no equivalent per-server data; see
|
||||
/// `models::ModelRegistry::resolve`'s doc comment for why that's an accepted gap, not an
|
||||
/// oversight).
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct BlockRegistryRow {
|
||||
pub block_id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn fetch_block_registry(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockRegistryRow>> {
|
||||
let rows = sqlx::query_as::<_, BlockRegistryRow>(r#"SELECT block_id, name FROM block_registry WHERE server_id = $1"#)
|
||||
.bind(server_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Phase 13: the mod's raw blockstate/model JSON dump for one server (see api's `models.ts`) —
|
||||
/// `kind` is `"blockstate"` or `"model"`, matching `models::ModelRegistry::build`'s two input maps.
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct BlockModelRow {
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub json: String,
|
||||
}
|
||||
|
||||
pub async fn fetch_block_models(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockModelRow>> {
|
||||
let rows = sqlx::query_as::<_, BlockModelRow>(r#"SELECT kind, name, json FROM block_models WHERE server_id = $1"#)
|
||||
.bind(server_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn upsert_mesh_pointer(
|
||||
pool: &PgPool,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod block_names;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod mesh;
|
||||
pub mod models;
|
||||
pub mod palette;
|
||||
pub mod render;
|
||||
pub mod storage;
|
||||
|
||||
+55
-3
@@ -1,8 +1,9 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use mcmapper_worker::{atlas, config, db, mesh, storage, textures};
|
||||
use mcmapper_worker::{atlas, config, db, mesh, models, storage, textures};
|
||||
use rayon::prelude::*;
|
||||
use redis::streams::{StreamReadOptions, StreamReadReply};
|
||||
use redis::AsyncCommands;
|
||||
@@ -124,6 +125,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
"[worker] failed to build texture atlas, 3D meshes will use flat vertex colors only: {err:#}"
|
||||
),
|
||||
}
|
||||
|
||||
// Phase 13: the vanilla half of the model registry — extracted from the same client jar,
|
||||
// worker-wide like the palette/atlas above (see models.rs's doc comment). The modded half
|
||||
// is per-server and fetched fresh per chunk-job below (see fetch_chunk_data), since
|
||||
// block_registry/block_models are genuinely server-scoped data, unlike the vanilla jar.
|
||||
match models::load_or_build(std::path::Path::new(&cache_dir), &mc_version).await {
|
||||
Ok(registry) => {
|
||||
println!(
|
||||
"[worker] vanilla model registry ready ({} blockstates, {} models)",
|
||||
registry.blockstate_count(),
|
||||
registry.model_count()
|
||||
);
|
||||
mcmapper_worker::render::set_vanilla_model_registry(registry);
|
||||
}
|
||||
Err(err) => eprintln!(
|
||||
"[worker] failed to build vanilla model registry, non-cube vanilla blocks will render as flat cubes: {err:#}"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)");
|
||||
}
|
||||
@@ -267,6 +286,13 @@ struct ChunkData {
|
||||
job: ChunkJob,
|
||||
columns: Vec<db::StoredColumn>,
|
||||
sections: Vec<db::StoredSection>,
|
||||
/// Phase 13: this job's server-scoped modded model data (empty registry/map if the server has
|
||||
/// never had a mod ship any, which is every server before its mod adopts `block_models` — see
|
||||
/// the Mod repo's `BlockAssetExtractor`). Fetched fresh per job rather than cached across
|
||||
/// batches: simplest correct thing, and mirrors this stage's existing "one DB round trip per
|
||||
/// job" shape (see `columns`/`sections` above) rather than a new batch-level cache.
|
||||
modded_models: models::ModelRegistry,
|
||||
modded_id_to_name: HashMap<u16, String>,
|
||||
}
|
||||
|
||||
async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result<ChunkData> {
|
||||
@@ -274,7 +300,30 @@ async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result
|
||||
db::fetch_chunk_columns(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
||||
let sections =
|
||||
db::fetch_chunk_sections(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
|
||||
Ok(ChunkData { job: job.clone(), columns, sections })
|
||||
|
||||
let registry_rows = db::fetch_block_registry(pool, job.server_id).await.unwrap_or_default();
|
||||
let modded_id_to_name: HashMap<u16, String> = registry_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| u16::try_from(r.block_id).ok().map(|id| (id, r.name)))
|
||||
.collect();
|
||||
|
||||
let model_rows = db::fetch_block_models(pool, job.server_id).await.unwrap_or_default();
|
||||
let mut blockstate_files = HashMap::new();
|
||||
let mut model_files = HashMap::new();
|
||||
for row in model_rows {
|
||||
match row.kind.as_str() {
|
||||
"blockstate" => {
|
||||
blockstate_files.insert(row.name, row.json);
|
||||
}
|
||||
"model" => {
|
||||
model_files.insert(row.name, row.json);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let modded_models = models::ModelRegistry::build(&blockstate_files, &model_files);
|
||||
|
||||
Ok(ChunkData { job: job.clone(), columns, sections, modded_models, modded_id_to_name })
|
||||
}
|
||||
|
||||
struct RenderedMesh {
|
||||
@@ -325,6 +374,9 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
|
||||
let png_bytes = backend.rasterize_tile(&pixels)?;
|
||||
let tile_content_hash = content_hash(&png_bytes);
|
||||
|
||||
let model_ctx =
|
||||
mcmapper_worker::render::model_context(Some(&data.modded_models), data.modded_id_to_name.clone());
|
||||
|
||||
let mut meshes = Vec::new();
|
||||
for section in &data.sections {
|
||||
let blocks = match decode_blocks(§ion.blocks) {
|
||||
@@ -337,7 +389,7 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mesh_buf = mesh::mesh_section(&blocks, backend);
|
||||
let mesh_buf = mesh::mesh_section(&blocks, backend, Some(&model_ctx));
|
||||
if mesh_buf.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
+196
-9
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::palette::color_for_textured;
|
||||
use crate::render::RenderBackend;
|
||||
use crate::render::{ModelContext, RenderBackend};
|
||||
|
||||
const SIZE: i32 = 16;
|
||||
|
||||
@@ -109,9 +111,43 @@ fn axis_pos(axis: usize, layer: i32, u: i32, v: i32) -> (i32, i32, i32) {
|
||||
/// quads. The per-voxel visibility extraction may have run on GPU; this merge/compaction step is
|
||||
/// always CPU — it's sequential and branchy (each cell's fate depends on what its neighbors in
|
||||
/// the same pass already claimed), not a good GPU-parallel fit.
|
||||
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend) -> MeshBuffers {
|
||||
let face_masks = backend.compute_face_masks(blocks);
|
||||
///
|
||||
/// Phase 13: `models`, if given, is consulted per non-air voxel to resolve its real shape (see
|
||||
/// `models::ModelRegistry::resolve`). Voxels that resolve to a genuinely non-cube model are
|
||||
/// excluded from `blocks` before it's handed to the cube mesher (so they neither get a spurious
|
||||
/// flat-cube quad of their own, nor incorrectly count as a "solid neighbor" that culls an
|
||||
/// adjacent block's face — both fixed as a side effect of the same exclusion) and instead get
|
||||
/// their own per-element box geometry emitted directly, unmerged, via `emit_non_cube_elements`.
|
||||
/// Voxels that resolve to a full cube (`ResolvedModel::is_full_cube`) are deliberately left alone
|
||||
/// — the existing cube path already renders them correctly via `block_names::texture_name`.
|
||||
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend, models: Option<&ModelContext>) -> MeshBuffers {
|
||||
let mut buf = MeshBuffers::default();
|
||||
let mut cube_blocks = *blocks;
|
||||
|
||||
if let Some(models) = models {
|
||||
// Memoized per distinct packed block value (block_id<<4|meta) — a section can easily
|
||||
// repeat the same non-cube block (a run of torches, a fence line) hundreds of times, and
|
||||
// re-walking the same blockstate/model parent chain for each instance is wasted work.
|
||||
let mut cache: HashMap<u16, Option<crate::models::ResolvedModel>> = HashMap::new();
|
||||
for (i, &block) in blocks.iter().enumerate() {
|
||||
if block == 0 {
|
||||
continue;
|
||||
}
|
||||
let resolved = cache
|
||||
.entry(block)
|
||||
.or_insert_with(|| models.resolve(block >> 4, (block & 0xF) as u8))
|
||||
.clone();
|
||||
let Some(resolved) = resolved else { continue };
|
||||
if resolved.is_full_cube() {
|
||||
continue;
|
||||
}
|
||||
cube_blocks[i] = 0;
|
||||
let (vx, vy, vz) = ((i % 16) as f32, (i / 256) as f32, ((i / 16) % 16) as f32);
|
||||
emit_non_cube_elements(&resolved, block >> 4, (block & 0xF) as u8, vx, vy, vz, &mut buf);
|
||||
}
|
||||
}
|
||||
|
||||
let face_masks = backend.compute_face_masks(&cube_blocks);
|
||||
let mut face_index = 0;
|
||||
for axis in 0..3 {
|
||||
for &dir in &[-1i32, 1i32] {
|
||||
@@ -244,6 +280,99 @@ fn emit_quad(
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits one resolved model's element boxes at voxel `(vx, vy, vz)` (section-local, block units)
|
||||
/// directly into `buf` — each element is its own standalone quad set, never greedy-merged with
|
||||
/// neighbors (unlike the cube path), since non-cube geometry rarely tiles cleanly and merging it
|
||||
/// would need real per-shape adjacency logic this phase doesn't attempt. `color` is the block's
|
||||
/// existing flat vertex-color fallback (same `color_for_textured` lookup the cube path uses,
|
||||
/// keyed by block id/meta — not a per-face-texture color, so every face of one block shares it).
|
||||
fn emit_non_cube_elements(
|
||||
model: &crate::models::ResolvedModel,
|
||||
block_id: u16,
|
||||
block_meta: u8,
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
vz: f32,
|
||||
buf: &mut MeshBuffers,
|
||||
) {
|
||||
let [r, g, b] = color_for_textured(block_id, block_meta, crate::render::texture_palette());
|
||||
let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
|
||||
for element in &model.elements {
|
||||
let from = [element.from[0] / 16.0, element.from[1] / 16.0, element.from[2] / 16.0];
|
||||
let to = [element.to[0] / 16.0, element.to[1] / 16.0, element.to[2] / 16.0];
|
||||
for (face_idx, texture) in element.faces.iter().enumerate() {
|
||||
let Some(texture) = texture else { continue };
|
||||
let axis = face_idx / 2;
|
||||
let dir: i32 = if face_idx % 2 == 1 { 1 } else { -1 };
|
||||
emit_element_face(axis, dir, from, to, texture, color, vx, vy, vz, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One face of one element — generalizes `emit_quad`'s axis/u/v scheme to arbitrary (possibly
|
||||
/// sub-block, possibly off-origin) box faces instead of always-integer whole-section quads.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn emit_element_face(
|
||||
axis: usize,
|
||||
dir: i32,
|
||||
from: [f32; 3],
|
||||
to: [f32; 3],
|
||||
texture: &str,
|
||||
color: [f32; 3],
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
vz: f32,
|
||||
buf: &mut MeshBuffers,
|
||||
) {
|
||||
let (u_axis, v_axis) = match axis {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let layer = if dir == 1 { to[axis] } else { from[axis] };
|
||||
let (u0, u1) = (from[u_axis], to[u_axis]);
|
||||
let (v0, v1) = (from[v_axis], to[v_axis]);
|
||||
let corners_uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
|
||||
|
||||
let normal = match (axis, dir) {
|
||||
(0, 1) => [1.0, 0.0, 0.0],
|
||||
(0, -1) => [-1.0, 0.0, 0.0],
|
||||
(1, 1) => [0.0, 1.0, 0.0],
|
||||
(1, -1) => [0.0, -1.0, 0.0],
|
||||
(2, 1) => [0.0, 0.0, 1.0],
|
||||
_ => [0.0, 0.0, -1.0],
|
||||
};
|
||||
|
||||
let atlas_rect = crate::render::texture_atlas().and_then(|atlas| atlas.rect(texture)).unwrap_or(NO_ATLAS_RECT);
|
||||
// Same tile-relative local-UV scheme as emit_quad's merged cube quads (see
|
||||
// models::ModelRegistry::resolve's doc comment on why this isn't the model's literal declared
|
||||
// UV rectangle) — an element's own width/height in block units, not a fixed 0..1, so a
|
||||
// sub-block-sized face (e.g. a torch's 2/16-block-wide side) samples a matching fraction of
|
||||
// the atlas tile rather than stretching the whole tile across a sliver of geometry.
|
||||
let width = u1 - u0;
|
||||
let height = v1 - v0;
|
||||
let local_uvs = [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]];
|
||||
|
||||
let base_index = buf.positions.len() as u32;
|
||||
for (i, (u, v)) in corners_uv.into_iter().enumerate() {
|
||||
let mut pos = [0.0f32; 3];
|
||||
pos[axis] = layer;
|
||||
pos[u_axis] = u;
|
||||
pos[v_axis] = v;
|
||||
buf.positions.push([pos[0] + vx, pos[1] + vy, pos[2] + vz]);
|
||||
buf.normals.push(normal);
|
||||
buf.colors.push(color);
|
||||
buf.uvs.push(local_uvs[i]);
|
||||
buf.atlas_rects.push(atlas_rect);
|
||||
}
|
||||
|
||||
if dir == 1 {
|
||||
buf.indices.extend_from_slice(&[base_index, base_index + 1, base_index + 2, base_index, base_index + 2, base_index + 3]);
|
||||
} else {
|
||||
buf.indices.extend_from_slice(&[base_index, base_index + 2, base_index + 1, base_index, base_index + 3, base_index + 2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -256,7 +385,7 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_section_produces_no_geometry() {
|
||||
let blocks = [0u16; 4096];
|
||||
let mesh = mesh_section(&blocks, &cpu());
|
||||
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||
assert!(mesh.is_empty());
|
||||
assert_eq!(mesh.positions.len(), 0);
|
||||
}
|
||||
@@ -265,7 +394,7 @@ mod tests {
|
||||
fn single_voxel_produces_six_unmerged_quads() {
|
||||
let mut blocks = [0u16; 4096];
|
||||
blocks[((0 * 16 + 0) * 16 + 0) as usize] = (2 << 4) | 0; // grass at local (0,0,0)
|
||||
let mesh = mesh_section(&blocks, &cpu());
|
||||
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||
assert_eq!(mesh.positions.len(), 6 * 4, "6 faces x 4 verts");
|
||||
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces x 2 tris x 3 indices");
|
||||
}
|
||||
@@ -279,7 +408,7 @@ mod tests {
|
||||
for b in blocks.iter_mut() {
|
||||
*b = (1 << 4) | 0; // stone everywhere
|
||||
}
|
||||
let mesh = mesh_section(&blocks, &cpu());
|
||||
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||
assert_eq!(mesh.positions.len(), 6 * 4, "6 merged outer faces x 4 verts");
|
||||
assert_eq!(mesh.indices.len(), 6 * 6);
|
||||
}
|
||||
@@ -300,7 +429,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let backend = cpu();
|
||||
let mesh = mesh_section(&blocks, &backend);
|
||||
let mesh = mesh_section(&blocks, &backend, None);
|
||||
// Just check the +y (top) face count via a targeted single-axis call.
|
||||
// face_index 3 = axis 1 (y), dir +1 — see compute_face_masks's doc comment for the
|
||||
// face_index = axis*2 + (dir==1) convention.
|
||||
@@ -315,7 +444,7 @@ mod tests {
|
||||
fn encode_round_trip_header() {
|
||||
let mut blocks = [0u16; 4096];
|
||||
blocks[0] = (2 << 4) | 0;
|
||||
let mesh = mesh_section(&blocks, &cpu());
|
||||
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||
let bytes = mesh.encode();
|
||||
let vertex_count = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||
let index_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
|
||||
@@ -332,7 +461,7 @@ mod tests {
|
||||
// local UVs should still be well-formed (start at the origin).
|
||||
let mut blocks = [0u16; 4096];
|
||||
blocks[0] = (1 << 4) | 0; // stone
|
||||
let mesh = mesh_section(&blocks, &cpu());
|
||||
let mesh = mesh_section(&blocks, &cpu(), None);
|
||||
assert!(!mesh.atlas_rects.is_empty());
|
||||
for rect in &mesh.atlas_rects {
|
||||
assert_eq!(*rect, NO_ATLAS_RECT);
|
||||
@@ -359,4 +488,62 @@ mod tests {
|
||||
assert_eq!(max_u, 16.0);
|
||||
assert_eq!(max_v, 16.0);
|
||||
}
|
||||
|
||||
fn torch_vanilla_registry() -> crate::models::ModelRegistry {
|
||||
let mut bs = HashMap::new();
|
||||
bs.insert(
|
||||
"minecraft:torch".to_string(),
|
||||
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
|
||||
);
|
||||
let mut models = HashMap::new();
|
||||
models.insert(
|
||||
"minecraft:block/torch".to_string(),
|
||||
r##"{"textures":{"torch":"blocks/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
|
||||
"up":{"texture":"#torch"}
|
||||
}}]}"##
|
||||
.to_string(),
|
||||
);
|
||||
crate::models::ModelRegistry::build(&bs, &models)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_cube_block_is_excluded_from_cube_meshing_and_emits_its_own_geometry() {
|
||||
let vanilla = torch_vanilla_registry();
|
||||
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
|
||||
|
||||
let mut blocks = [0u16; 4096];
|
||||
blocks[0] = (50 << 4) | 0; // torch at local (0,0,0), block_id 50 per vanilla_model_name
|
||||
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
|
||||
|
||||
// Only the torch model's single "up" face was declared — one quad, not a flat cube
|
||||
// (which single_voxel_produces_six_unmerged_quads shows would be 6 quads / 24 verts).
|
||||
assert_eq!(mesh.positions.len(), 4);
|
||||
assert_eq!(mesh.indices.len(), 6);
|
||||
// The element spans local (7,0,7)-(9,10,9) in 0..16 model space -> (0.4375, 0, 0.4375) to
|
||||
// (0.5625, 0.625, 0.5625) in block units, offset by the voxel's own (0,0,0) position.
|
||||
let ys: Vec<f32> = mesh.positions.iter().map(|p| p[1]).collect();
|
||||
assert!((ys.iter().cloned().fold(0.0f32, f32::max) - 0.625).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces() {
|
||||
// Before Phase 13, any non-zero block value (torch included) counted as a "solid
|
||||
// neighbor" for face-culling purposes, since compute_face_masks only ever checked
|
||||
// "non-zero" — this incorrectly culled a solid block's face against a torch sitting right
|
||||
// next to it. Excluding non-cube blocks from `cube_blocks` (see mesh_section's doc
|
||||
// comment) fixes this as a side effect: stone at (0,0,0) next to a torch at (1,0,0) must
|
||||
// still show its +x face.
|
||||
let vanilla = torch_vanilla_registry();
|
||||
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
|
||||
|
||||
let mut blocks = [0u16; 4096];
|
||||
blocks[0] = (1 << 4) | 0; // stone at (0,0,0)
|
||||
blocks[1] = (50 << 4) | 0; // torch at (1,0,0)
|
||||
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
|
||||
|
||||
// 6 unmerged stone cube quads (24 verts / 36 indices, same shape as
|
||||
// single_voxel_produces_six_unmerged_quads) + 1 torch quad (4 verts / 6 indices).
|
||||
assert_eq!(mesh.positions.len(), 28);
|
||||
assert_eq!(mesh.indices.len(), 42);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// A single resolved 3D model as a flat list of axis-aligned element boxes, ready for `mesh.rs`
|
||||
/// to render as standalone (non-greedy-merged) geometry — the Phase 13 counterpart to the cube
|
||||
/// greedy-mesher for blocks whose real shape isn't a full 0..16/0..16/0..16 cube (torches, stairs,
|
||||
/// fences, and — the named stress test — Thaumcraft's extensive custom block models).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedModel {
|
||||
pub elements: Vec<ResolvedElement>,
|
||||
}
|
||||
|
||||
impl ResolvedModel {
|
||||
/// A single element spanning the full 0..16 cube on every axis with all 6 faces present is
|
||||
/// indistinguishable from what the existing greedy cube mesher already draws (just via a
|
||||
/// different texture-lookup convention) — `mesh.rs` leaves these on the old path rather than
|
||||
/// double-rendering them, see its doc comment.
|
||||
pub fn is_full_cube(&self) -> bool {
|
||||
matches!(self.elements.as_slice(), [only] if only.from == [0.0, 0.0, 0.0]
|
||||
&& only.to == [16.0, 16.0, 16.0]
|
||||
&& only.faces.iter().all(|f| f.is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces[i]` indexed by the same `axis*2 + (dir==1)` convention `mesh.rs`/`render::FaceMasks`
|
||||
/// already use: 0=-x(west) 1=+x(east) 2=-y(down) 3=+y(up) 4=-z(north) 5=+z(south).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedElement {
|
||||
pub from: [f32; 3],
|
||||
pub to: [f32; 3],
|
||||
pub faces: [Option<String>; 6],
|
||||
}
|
||||
|
||||
fn face_index(name: &str) -> Option<usize> {
|
||||
Some(match name {
|
||||
"west" => 0,
|
||||
"east" => 1,
|
||||
"down" => 2,
|
||||
"up" => 3,
|
||||
"north" => 4,
|
||||
"south" => 5,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct RawBlockState {
|
||||
#[serde(default)]
|
||||
variants: HashMap<String, VariantValue>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum VariantValue {
|
||||
Single(VariantEntry),
|
||||
List(Vec<VariantEntry>),
|
||||
}
|
||||
|
||||
impl VariantValue {
|
||||
fn first(&self) -> Option<&VariantEntry> {
|
||||
match self {
|
||||
VariantValue::Single(v) => Some(v),
|
||||
VariantValue::List(v) => v.first(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct VariantEntry {
|
||||
model: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawModel {
|
||||
parent: Option<String>,
|
||||
#[serde(default)]
|
||||
textures: HashMap<String, String>,
|
||||
elements: Option<Vec<RawElement>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct RawElement {
|
||||
from: [f32; 3],
|
||||
to: [f32; 3],
|
||||
#[serde(default)]
|
||||
faces: HashMap<String, RawFace>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct RawFace {
|
||||
texture: String,
|
||||
}
|
||||
|
||||
/// Parsed blockstate + model JSON for one "source" — either the vanilla set (extracted from the
|
||||
/// downloaded Mojang client jar, see `load_or_build`) or one server's modded set (shipped by the
|
||||
/// mod's `BlockAssetExtractor`, see the Mod repo, and stored per-server in Postgres). Deliberately
|
||||
/// dumb about anything beyond single-representative-variant resolution — see `resolve`'s doc
|
||||
/// comment for exactly what's out of scope and why.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ModelRegistry {
|
||||
blockstates: HashMap<String, RawBlockStateDebug>,
|
||||
models: HashMap<String, RawModelDebug>,
|
||||
}
|
||||
|
||||
// serde types above can't cheaply derive Debug (HashMap<String, RawFace> etc. don't need to be
|
||||
// inspectable), so the registry wraps them in a thin newtype that just says "opaque" — only used
|
||||
// so `ModelRegistry` itself can derive `Debug` for tests/assertions without extra ceremony at
|
||||
// every call site.
|
||||
struct RawBlockStateDebug(RawBlockState);
|
||||
impl std::fmt::Debug for RawBlockStateDebug {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "RawBlockState({} variants)", self.0.variants.len())
|
||||
}
|
||||
}
|
||||
struct RawModelDebug(RawModel);
|
||||
impl std::fmt::Debug for RawModelDebug {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "RawModel(parent={:?})", self.0.parent)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelRegistry {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.blockstates.is_empty()
|
||||
}
|
||||
|
||||
pub fn blockstate_count(&self) -> usize {
|
||||
self.blockstates.len()
|
||||
}
|
||||
|
||||
pub fn model_count(&self) -> usize {
|
||||
self.models.len()
|
||||
}
|
||||
|
||||
/// Parses every `(name, json)` pair, silently skipping entries that fail to parse — matches
|
||||
/// this crate's existing "skip malformed input, never hard-fail a whole batch over one bad
|
||||
/// entry" pattern (e.g. `main.rs`'s `decode_blocks` error handling for a malformed section).
|
||||
pub fn build(blockstate_files: &HashMap<String, String>, model_files: &HashMap<String, String>) -> ModelRegistry {
|
||||
let mut blockstates = HashMap::new();
|
||||
for (name, json) in blockstate_files {
|
||||
if let Ok(parsed) = serde_json::from_str::<RawBlockState>(json) {
|
||||
blockstates.insert(name.clone(), RawBlockStateDebug(parsed));
|
||||
}
|
||||
}
|
||||
let mut models = HashMap::new();
|
||||
for (name, json) in model_files {
|
||||
if let Ok(parsed) = serde_json::from_str::<RawModel>(json) {
|
||||
models.insert(name.clone(), RawModelDebug(parsed));
|
||||
}
|
||||
}
|
||||
ModelRegistry { blockstates, models }
|
||||
}
|
||||
|
||||
/// Resolves `registry_name` (e.g. `"minecraft:torch"`, `"thaumcraft:blockcustomplant"`) to
|
||||
/// real element geometry: blockstate variant selection, model parent-chain walking, and
|
||||
/// texture-variable substitution. `fallback` (typically the vanilla registry) is consulted
|
||||
/// for any model reference not found in `self` — a modded block's model can `"parent"` a
|
||||
/// vanilla one (e.g. `"minecraft:block/cross"`), and vanilla models are never shipped by the
|
||||
/// mod (they're not on a dedicated server's classpath at all — see the Mod repo's
|
||||
/// `BlockAssetExtractor` doc comment), only extracted backend-side from the client jar.
|
||||
///
|
||||
/// Several real blockstate/model features are deliberately **not** implemented — this is a
|
||||
/// best-effort "show the real shape", not a full model resolver:
|
||||
/// - `multipart` blockstates (fences/walls/redstone wire/glass panes) aren't resolved at all
|
||||
/// — there's no way to know neighbor-dependent connection state from this project's raw
|
||||
/// `block_id`/`meta` chunk data anyway. Returns `None`; the caller falls back to the
|
||||
/// existing flat-cube rendering, same as any other unresolved block.
|
||||
/// - When a `variants` map has multiple property-keyed entries (true for most blocks that
|
||||
/// have *any* blockstate property), there's no way to know which one applies — this
|
||||
/// project's chunk data model only ever carries a numeric `meta` (legacy blocks) or a
|
||||
/// truncated packed state id (modern, see the Phase 10 `char[]` truncation note), never
|
||||
/// named property values. This always picks one deterministic representative variant
|
||||
/// (`""` if present, else the alphabetically-first key) — the same "no property data"
|
||||
/// simplification already accepted for texture-name resolution elsewhere in this crate.
|
||||
/// - Per-face `uv`/`rotation` and per-variant `x`/`y` block rotation are ignored. Element
|
||||
/// geometry (`from`/`to`) is real, but texture mapping reuses the same tile-relative
|
||||
/// "coordinate span in block units" scheme the existing cube mesher already uses (see
|
||||
/// `mesh.rs::emit_quad`'s local UV), not the model's literal declared UV rectangle.
|
||||
/// - A weighted multi-model variant list always picks its first entry, ignoring `weight`.
|
||||
pub fn resolve(&self, registry_name: &str, fallback: Option<&ModelRegistry>) -> Option<ResolvedModel> {
|
||||
let blockstate = self
|
||||
.blockstates
|
||||
.get(registry_name)
|
||||
.or_else(|| fallback.and_then(|f| f.blockstates.get(registry_name)))?;
|
||||
let variants = &blockstate.0.variants;
|
||||
if variants.is_empty() {
|
||||
return None; // multipart-only (or genuinely empty) — not resolved, see doc comment
|
||||
}
|
||||
let variant = match variants.get("") {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
let mut keys: Vec<&String> = variants.keys().collect();
|
||||
keys.sort();
|
||||
variants.get(keys.first()?.as_str())?
|
||||
}
|
||||
}
|
||||
.first()?;
|
||||
|
||||
let mut current = normalize_ref(&variant.model);
|
||||
let mut textures_merged: HashMap<String, String> = HashMap::new();
|
||||
let mut elements: Option<Vec<RawElement>> = None;
|
||||
for _ in 0..16 {
|
||||
let model = self
|
||||
.models
|
||||
.get(current.as_str())
|
||||
.or_else(|| fallback.and_then(|f| f.models.get(current.as_str())))?;
|
||||
for (k, v) in &model.0.textures {
|
||||
textures_merged.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
if elements.is_none() {
|
||||
if let Some(e) = &model.0.elements {
|
||||
elements = Some(e.clone());
|
||||
}
|
||||
}
|
||||
match &model.0.parent {
|
||||
Some(parent) => current = normalize_ref(parent),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let elements = elements?;
|
||||
|
||||
let resolved_elements = elements
|
||||
.into_iter()
|
||||
.map(|el| {
|
||||
let mut faces: [Option<String>; 6] = Default::default();
|
||||
for (face_name, raw_face) in &el.faces {
|
||||
let Some(idx) = face_index(face_name) else { continue };
|
||||
faces[idx] = resolve_texture(&raw_face.texture, &textures_merged);
|
||||
}
|
||||
ResolvedElement { from: el.from, to: el.to, faces }
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(ResolvedModel { elements: resolved_elements })
|
||||
}
|
||||
}
|
||||
|
||||
/// Vanilla blockstate/model JSON omits the `minecraft:` namespace on internal references (a bare
|
||||
/// `"block/torch"` model ref, or `"#all"` texture var) — this project's registries always key
|
||||
/// vanilla entries with an explicit `minecraft:` prefix (see `extract_vanilla_source`) to keep
|
||||
/// lookups uniform with modded entries (which are always already fully-qualified `"modid:path"`,
|
||||
/// matching `block_registry.name`'s convention — see the api's `textures.ts` doc comment).
|
||||
fn normalize_ref(s: &str) -> String {
|
||||
if s.contains(':') {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("minecraft:{s}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows `#variable` chains (a face's `texture` can point at another texture-map key, e.g.
|
||||
/// `"#all": "#base"`, `"#base": "block/stone"`) to a literal texture path, then extracts the atlas
|
||||
/// lookup key (the same file-stem convention `atlas.rs`/`textures.rs` key their maps by, e.g.
|
||||
/// `"minecraft:block/stone"` -> `"stone"`) from it. Returns `None` on an unresolvable variable
|
||||
/// (references a texture key nothing defines) or a pathological cycle, rather than guessing.
|
||||
fn resolve_texture(texture: &str, textures: &HashMap<String, String>) -> Option<String> {
|
||||
let mut current: &str = texture;
|
||||
for _ in 0..16 {
|
||||
match current.strip_prefix('#') {
|
||||
Some(var) => current = textures.get(var)?.as_str(),
|
||||
None => {
|
||||
let stem = current.rsplit('/').next().unwrap_or(current);
|
||||
return Some(stem.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct CachedSource {
|
||||
blockstates: HashMap<String, String>,
|
||||
models: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Loads a cached vanilla model registry from `<cache_dir>/vanilla-<version>-models.json` if
|
||||
/// present, otherwise downloads the Mojang client jar (reuses `textures::download_client_jar_bytes`
|
||||
/// — a small one-time duplicate download on a cold cache, the same accepted tradeoff `atlas.rs`
|
||||
/// already makes for keeping each build path independent) and extracts every
|
||||
/// `assets/minecraft/blockstates/*.json` and `assets/minecraft/models/block/**/*.json` entry.
|
||||
pub async fn load_or_build(cache_dir: &Path, mc_version: &str) -> anyhow::Result<ModelRegistry> {
|
||||
let cache_path = cache_dir.join(format!("vanilla-{mc_version}-models.json"));
|
||||
if let Ok(bytes) = std::fs::read(&cache_path) {
|
||||
if let Ok(source) = serde_json::from_slice::<CachedSource>(&bytes) {
|
||||
let registry = ModelRegistry::build(&source.blockstates, &source.models);
|
||||
println!(
|
||||
"[worker] loaded cached vanilla model registry ({} blockstates, {} models) from {}",
|
||||
registry.blockstate_count(),
|
||||
registry.model_count(),
|
||||
cache_path.display()
|
||||
);
|
||||
return Ok(registry);
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla model registry..."
|
||||
);
|
||||
let jar_bytes = crate::textures::download_client_jar_bytes(mc_version).await?;
|
||||
let source = extract_vanilla_source(&jar_bytes)?;
|
||||
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
std::fs::write(&cache_path, serde_json::to_vec(&source)?)?;
|
||||
|
||||
let registry = ModelRegistry::build(&source.blockstates, &source.models);
|
||||
println!(
|
||||
"[worker] built vanilla model registry ({} blockstates, {} models), cached to {}",
|
||||
registry.blockstate_count(),
|
||||
registry.model_count(),
|
||||
cache_path.display()
|
||||
);
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn extract_vanilla_source(jar_bytes: &[u8]) -> anyhow::Result<CachedSource> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?;
|
||||
let mut blockstates = HashMap::new();
|
||||
let mut models = HashMap::new();
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let name = file.name().to_string();
|
||||
if let Some(path) =
|
||||
name.strip_prefix("assets/minecraft/blockstates/").and_then(|p| p.strip_suffix(".json"))
|
||||
{
|
||||
let mut text = String::new();
|
||||
if file.read_to_string(&mut text).is_ok() {
|
||||
blockstates.insert(format!("minecraft:{path}"), text);
|
||||
}
|
||||
} else if let Some(path) =
|
||||
name.strip_prefix("assets/minecraft/models/block/").and_then(|p| p.strip_suffix(".json"))
|
||||
{
|
||||
let mut text = String::new();
|
||||
if file.read_to_string(&mut text).is_ok() {
|
||||
models.insert(format!("minecraft:block/{path}"), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CachedSource { blockstates, models })
|
||||
}
|
||||
|
||||
/// Resolves one voxel's `(block_id, meta)` to real geometry, if any is known — the single entry
|
||||
/// point `mesh.rs` calls per non-air voxel. Modded blocks take priority over vanilla when both a
|
||||
/// per-server registry name (`modded_id_to_name`, sourced from the `block_registry` table — see
|
||||
/// the api's `textures.ts`) and a matching modded model are available; `meta` is never consulted
|
||||
/// for modded blocks since `block_registry` only carries `block_id -> name` (no per-state
|
||||
/// granularity — the mod can't cheaply enumerate every possible state, and it wouldn't help
|
||||
/// without the property-string data `ModelRegistry::resolve`'s doc comment already flags as
|
||||
/// missing). Falls back to a small built-in table of vanilla non-cube blocks (`vanilla_model_name`,
|
||||
/// `block_names.rs`) when there's no per-server match.
|
||||
pub fn resolve_for_block(
|
||||
vanilla: Option<&ModelRegistry>,
|
||||
modded: Option<&ModelRegistry>,
|
||||
modded_id_to_name: &HashMap<u16, String>,
|
||||
block_id: u16,
|
||||
meta: u8,
|
||||
) -> Option<ResolvedModel> {
|
||||
if let (Some(name), Some(modded_registry)) = (modded_id_to_name.get(&block_id), modded) {
|
||||
if let Some(resolved) = modded_registry.resolve(name, vanilla) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
let name = crate::block_names::vanilla_model_name(block_id, meta)?;
|
||||
vanilla?.resolve(name, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn full_cube_source() -> (HashMap<String, String>, HashMap<String, String>) {
|
||||
let blockstates = HashMap::from([(
|
||||
"minecraft:stone".to_string(),
|
||||
r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#.to_string(),
|
||||
)]);
|
||||
let models = HashMap::from([(
|
||||
"minecraft:block/stone".to_string(),
|
||||
r#"{"parent":"block/cube_all","textures":{"all":"block/stone"}}"#.to_string(),
|
||||
), (
|
||||
"minecraft:block/cube_all".to_string(),
|
||||
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{
|
||||
"down":{"texture":"#all"},"up":{"texture":"#all"},
|
||||
"north":{"texture":"#all"},"south":{"texture":"#all"},
|
||||
"west":{"texture":"#all"},"east":{"texture":"#all"}
|
||||
}}]}"##.to_string(),
|
||||
)]);
|
||||
(blockstates, models)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_a_full_cube_via_a_parent_chain_and_texture_variable() {
|
||||
let (bs, models) = full_cube_source();
|
||||
let registry = ModelRegistry::build(&bs, &models);
|
||||
let resolved = registry.resolve("minecraft:stone", None).unwrap();
|
||||
assert!(resolved.is_full_cube());
|
||||
assert_eq!(resolved.elements[0].faces[3], Some("stone".to_string())); // +y = up
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_a_non_cube_torch_shaped_model() {
|
||||
let mut bs = HashMap::new();
|
||||
bs.insert(
|
||||
"minecraft:torch".to_string(),
|
||||
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
|
||||
);
|
||||
let mut models = HashMap::new();
|
||||
models.insert(
|
||||
"minecraft:block/torch".to_string(),
|
||||
r##"{"textures":{"torch":"block/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
|
||||
"up":{"texture":"#torch"},"down":{"texture":"#torch"}
|
||||
}}]}"##.to_string(),
|
||||
);
|
||||
let registry = ModelRegistry::build(&bs, &models);
|
||||
let resolved = registry.resolve("minecraft:torch", None).unwrap();
|
||||
assert!(!resolved.is_full_cube());
|
||||
assert_eq!(resolved.elements.len(), 1);
|
||||
assert_eq!(resolved.elements[0].from, [7.0, 0.0, 7.0]);
|
||||
assert_eq!(resolved.elements[0].faces[3], Some("torch".to_string()));
|
||||
assert_eq!(resolved.elements[0].faces[0], None); // west face not modeled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_only_blockstate_is_not_resolved() {
|
||||
let mut bs = HashMap::new();
|
||||
bs.insert(
|
||||
"minecraft:oak_fence".to_string(),
|
||||
r#"{"multipart":[{"apply":{"model":"minecraft:block/oak_fence_post"}}]}"#.to_string(),
|
||||
);
|
||||
let registry = ModelRegistry::build(&bs, &HashMap::new());
|
||||
assert!(registry.resolve("minecraft:oak_fence", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modded_model_can_parent_a_vanilla_one_via_the_fallback_registry() {
|
||||
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||
|
||||
let mut modded_bs = HashMap::new();
|
||||
modded_bs.insert(
|
||||
"thaumcraft:blockcustomplant".to_string(),
|
||||
r#"{"variants":{"":{"model":"thaumcraft:block/customplant"}}}"#.to_string(),
|
||||
);
|
||||
let mut modded_models = HashMap::new();
|
||||
modded_models.insert(
|
||||
"thaumcraft:block/customplant".to_string(),
|
||||
r#"{"parent":"minecraft:block/cube_all","textures":{"all":"thaumcraft:block/customplant"}}"#
|
||||
.to_string(),
|
||||
);
|
||||
let modded = ModelRegistry::build(&modded_bs, &modded_models);
|
||||
|
||||
let resolved = modded.resolve("thaumcraft:blockcustomplant", Some(&vanilla)).unwrap();
|
||||
assert!(resolved.is_full_cube());
|
||||
assert_eq!(resolved.elements[0].faces[3], Some("customplant".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_registry_name_resolves_to_none() {
|
||||
let registry = ModelRegistry::build(&HashMap::new(), &HashMap::new());
|
||||
assert!(registry.resolve("minecraft:does_not_exist", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_for_block_prefers_modded_over_vanilla_for_a_known_id() {
|
||||
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||
|
||||
let mut modded_bs = HashMap::new();
|
||||
modded_bs.insert(
|
||||
"thaumcraft:manapool".to_string(),
|
||||
r#"{"variants":{"":{"model":"thaumcraft:block/manapool"}}}"#.to_string(),
|
||||
);
|
||||
let mut modded_models = HashMap::new();
|
||||
modded_models.insert(
|
||||
"thaumcraft:block/manapool".to_string(),
|
||||
r##"{"textures":{"top":"thaumcraft:block/manapool_top"},"elements":[{"from":[0,0,0],"to":[16,4,16],"faces":{"up":{"texture":"#top"}}}]}"##.to_string(),
|
||||
);
|
||||
let modded = ModelRegistry::build(&modded_bs, &modded_models);
|
||||
|
||||
let mut id_to_name = HashMap::new();
|
||||
id_to_name.insert(4000u16, "thaumcraft:manapool".to_string());
|
||||
|
||||
let resolved = resolve_for_block(Some(&vanilla), Some(&modded), &id_to_name, 4000, 0).unwrap();
|
||||
assert_eq!(resolved.elements[0].to, [16.0, 4.0, 16.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_for_block_falls_back_to_vanilla_for_an_unknown_id() {
|
||||
let (vanilla_bs, vanilla_models) = full_cube_source();
|
||||
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
|
||||
// block_id 1 = stone, matches block_names::vanilla_model_name's vanilla table indirectly
|
||||
// via a full-cube resolve — but stone isn't in the small non-cube demo table, so this
|
||||
// should resolve to None (block_names::vanilla_model_name only covers non-cube blocks —
|
||||
// see that function's doc comment) even though "minecraft:stone" itself is resolvable.
|
||||
assert!(resolve_for_block(Some(&vanilla), None, &HashMap::new(), 1, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_texture_follows_a_variable_chain_to_a_literal_stem() {
|
||||
let mut textures = HashMap::new();
|
||||
textures.insert("all".to_string(), "#base".to_string());
|
||||
textures.insert("base".to_string(), "minecraft:block/stone".to_string());
|
||||
assert_eq!(resolve_texture("#all", &textures), Some("stone".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_texture_on_an_unresolvable_variable_is_none() {
|
||||
let textures = HashMap::new();
|
||||
assert_eq!(resolve_texture("#missing", &textures), None);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,51 @@ pub(crate) fn texture_atlas() -> Option<&'static crate::atlas::TextureAtlas> {
|
||||
TEXTURE_ATLAS.get()
|
||||
}
|
||||
|
||||
/// Same `OnceLock`-once-at-startup pattern as `TEXTURE_PALETTE`/`TEXTURE_ATLAS`, for the Phase 13
|
||||
/// vanilla model registry (see `models.rs`). Worker-wide, not per-server — the same simplification
|
||||
/// tier already accepted for the texture palette/atlas. `None` until `main.rs` successfully builds
|
||||
/// one; `mesh.rs` simply skips non-cube resolution entirely when this is `None`.
|
||||
static VANILLA_MODEL_REGISTRY: OnceLock<crate::models::ModelRegistry> = OnceLock::new();
|
||||
|
||||
pub fn set_vanilla_model_registry(registry: crate::models::ModelRegistry) {
|
||||
let _ = VANILLA_MODEL_REGISTRY.set(registry);
|
||||
}
|
||||
|
||||
pub(crate) fn vanilla_model_registry() -> Option<&'static crate::models::ModelRegistry> {
|
||||
VANILLA_MODEL_REGISTRY.get()
|
||||
}
|
||||
|
||||
/// Bundles everything `mesh.rs` needs to resolve one voxel's real (possibly non-cube) shape:
|
||||
/// the worker-wide vanilla registry plus one job's per-server modded registry/id-map, both
|
||||
/// optional (a server with no modded model data yet, or a worker that never built the vanilla
|
||||
/// registry, simply resolves nothing — mesh_section falls back to its pre-Phase-13 behavior).
|
||||
/// Built fresh per chunk-job in `main.rs` (the modded half is genuinely per-server; the vanilla
|
||||
/// half is just a borrow of the static above) rather than stored globally, since it's cheap and
|
||||
/// keeps `mesh_section` ignorant of *where* the data came from.
|
||||
#[derive(Default)]
|
||||
pub struct ModelContext<'a> {
|
||||
pub vanilla: Option<&'a crate::models::ModelRegistry>,
|
||||
pub modded: Option<&'a crate::models::ModelRegistry>,
|
||||
pub modded_id_to_name: std::collections::HashMap<u16, String>,
|
||||
}
|
||||
|
||||
impl<'a> ModelContext<'a> {
|
||||
pub fn resolve(&self, block_id: u16, meta: u8) -> Option<crate::models::ResolvedModel> {
|
||||
crate::models::resolve_for_block(self.vanilla, self.modded, &self.modded_id_to_name, block_id, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ModelContext` for one chunk-job, combining the worker-wide vanilla registry (read
|
||||
/// from the `OnceLock` above — kept private to this module, `main.rs` never reads it directly) with
|
||||
/// the caller-supplied per-server modded registry/id-map (see `main.rs`'s `fetch_chunk_data`,
|
||||
/// which queries `block_registry`/`block_models` for the job's `server_id`).
|
||||
pub fn model_context(
|
||||
modded: Option<&crate::models::ModelRegistry>,
|
||||
modded_id_to_name: std::collections::HashMap<u16, String>,
|
||||
) -> ModelContext<'_> {
|
||||
ModelContext { vanilla: vanilla_model_registry(), modded, modded_id_to_name }
|
||||
}
|
||||
|
||||
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
|
||||
pub struct ColumnPixel {
|
||||
pub local_x: u8,
|
||||
|
||||
Reference in New Issue
Block a user