统一跳一跳三维地块与落点判定

修正跳一跳长按起跳预测为真实脚点指向下一块顶面中心

统一前端指示器飞行动画与后端顶面 footprint 判定

调整 Three.js 方块贴图与角色顶面投影表现

补充跳一跳 UV 图集切片与运行态规则文档
This commit is contained in:
2026-06-12 22:42:39 +08:00
parent 6bdf84dc0d
commit 6ee55707e1
15 changed files with 1915 additions and 646 deletions

View File

@@ -1090,15 +1090,8 @@ pub(crate) fn slice_jump_hop_tile_atlas(
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
let tile_width = x1.saturating_sub(x0).max(1);
let tile_height = y1.saturating_sub(y0).max(1);
let faces = slice_jump_hop_tile_uv_faces(
&source,
x0,
y0,
tile_width,
tile_height,
row,
col,
)?;
let faces =
slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?;
slices.push(JumpHopTileAtlasSlice {
tile_type: jump_hop_tile_type_by_index(index),
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
@@ -1129,22 +1122,70 @@ pub(crate) fn slice_jump_hop_tile_uv_faces(
Ok(JumpHopTileFaceSlices {
top: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Top,
1,
0,
)?,
front: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Front,
1,
1,
)?,
right: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Right,
2,
1,
)?,
back: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Back,
3,
1,
)?,
left: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Left,
0,
1,
)?,
bottom: slice_jump_hop_tile_uv_face(
source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2,
source,
uv_x,
uv_y,
face_side,
atlas_row,
atlas_col,
JumpHopTileFaceKey::Bottom,
1,
2,
)?,
})
}
@@ -1182,12 +1223,7 @@ pub(crate) fn slice_jump_hop_tile_uv_face(
Ok(JumpHopTileFaceSlice {
face,
source_atlas_cell: format!(
"row-{}-col-{}/{}",
atlas_row + 1,
atlas_col + 1,
face_label
),
source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label),
bytes: cursor.into_inner(),
})
}
@@ -1200,8 +1236,8 @@ pub(crate) fn crop_jump_hop_tile_texture_cell(
height: u32,
) -> image::DynamicImage {
let min_side = width.min(height).max(1);
// 洋红去背已在切片前完成,内缩只需微调去掉可能的极边缘残留,不再承担主要去洋红职责
let safe_inset = (min_side / 64).clamp(1, 4);
// 洋红去背已在切片前完成;这里仅避开 UV 安全缝和极边缘残留,不按颜色透明化主体
let safe_inset = (min_side / 18).clamp(4, 18);
let inset_x = safe_inset.min(width.saturating_sub(1) / 2);
let inset_y = safe_inset.min(height.saturating_sub(1) / 2);
let crop_width = width.saturating_sub(inset_x.saturating_mul(2)).max(1);
@@ -1918,7 +1954,9 @@ mod tests {
assert!(prompt.contains("第3行第2列bottom"));
assert!(prompt.contains("其余 6 个格子"));
// 贴图内容
assert!(prompt.contains("1x1x1 立方体物体的六面展开"));
assert!(prompt.contains("物体 -> 立方体 -> 展开"));
assert!(prompt.contains("1×1×1 的立方体"));
assert!(prompt.contains("经过方块化后的六面展开"));
assert!(prompt.contains("主题为\"森林冒险\""));
assert!(prompt.contains("6 个面必须属于同一个物体"));
assert!(prompt.contains("组合成一个完整的立方体造型"));
@@ -1935,6 +1973,7 @@ mod tests {
assert!(prompt.contains("不做透视渲染"));
assert!(prompt.contains("不画投影、高光、倒角、侧壁厚度"));
assert!(prompt.contains("大色块、高对比、粗线条和简单图形"));
assert!(prompt.contains("大小完全相同的正方形"));
// 背景填充(最后一段)
assert!(prompt.contains("【背景填充】"));
assert!(prompt.contains("大单元格之间的间距"));
@@ -2080,7 +2119,9 @@ mod tests {
"科幻芯片主题的俯视角清爽游戏化立体感平台素材",
);
assert!(prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图"));
assert!(
prompt.contains("具体内容为科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")
);
assert!(!prompt.contains("俯视角清爽游戏化立体感平台素材"));
assert!(!prompt.contains("俯视角"));
@@ -2176,12 +2217,10 @@ mod tests {
.max(1);
let tile_x = atlas_col.saturating_mul(cell_width);
let tile_y = atlas_row.saturating_mul(cell_height);
let uv_x = tile_x.saturating_add(
cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2,
);
let uv_y = tile_y.saturating_add(
cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2,
);
let uv_x = tile_x
.saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2);
let uv_y = tile_y
.saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2);
for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side {
for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side {
atlas.put_pixel(x, y, color);
@@ -2217,14 +2256,8 @@ mod tests {
),
"{message}"
);
assert!(
decoded.pixels().any(|pixel| pixel.0 == color),
"{message}"
);
assert!(
decoded.pixels().all(|pixel| pixel.0[3] == 255),
"{message}"
);
assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}");
assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}");
}
#[test]
@@ -2404,6 +2437,61 @@ mod tests {
);
}
#[test]
fn jump_hop_tile_atlas_slicing_avoids_uv_gutter_edges() {
let width = 384;
let height = 576;
let mut atlas =
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 255, 255]));
paint_test_uv_face(&mut atlas, 0, 0, 1, 0, image::Rgba([255, 0, 255, 255]));
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
let face_side = (cell_width / JUMP_HOP_TILE_UV_FACE_COLS)
.min(cell_height / JUMP_HOP_TILE_UV_FACE_ROWS)
.max(1);
let tile_x = 0;
let tile_y = 0;
let uv_x = tile_x + (cell_width - face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2;
let uv_y = tile_y + (cell_height - face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2;
let face_x = uv_x + face_side;
let face_y = uv_y;
let inset = 4;
for y in face_y + inset..face_y + face_side - inset {
for x in face_x + inset..face_x + face_side - inset {
atlas.put_pixel(x, y, image::Rgba([68, 186, 96, 255]));
}
}
let image = load_test_png(encode_test_atlas(atlas));
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
let top_tile = image::load_from_memory(slices[0].faces.top.bytes.as_slice())
.expect("top tile should decode")
.to_rgba8();
for (x, y) in [
(0, 0),
(JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1, 0),
(0, JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1),
(
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1,
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE - 1,
),
] {
assert_eq!(
top_tile.get_pixel(x, y).0,
[68, 186, 96, 255],
"UV 安全边不能被采样到输出面贴图角落"
);
}
assert!(
top_tile
.pixels()
.all(|pixel| !is_jump_hop_tile_texture_key_pixel(*pixel)),
"输出面贴图不应残留接近洋红安全色的像素"
);
}
#[test]
fn jump_hop_tile_asset_slots_are_unique_for_eighteen_slices() {
let slots = (0..JUMP_HOP_TILE_ITEM_COUNT)
@@ -2522,14 +2610,12 @@ mod tests {
// 在边缘区域故意填充一些洋红色像素,验证后续内缩裁切和关键色清除
let atlas_bytes = encode_test_atlas(atlas);
std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes)
.expect("保存原始图集");
std::fs::write(output_root.join("00-raw-atlas.png"), &atlas_bytes).expect("保存原始图集");
let atlas_image = load_test_png(atlas_bytes);
// === 阶段1: 图集切片18 个 tile cell ===
let slices =
slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功");
let slices = slice_jump_hop_tile_atlas(&atlas_image).expect("图集切片应该成功");
assert_eq!(
slices.len(),
JUMP_HOP_TILE_ITEM_COUNT,
@@ -2538,8 +2624,7 @@ mod tests {
let mut total_faces = 0usize;
let mut no_key_color_residue = true;
let expected_output_size =
JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32;
let expected_output_size = JUMP_HOP_TILE_TEXTURE_OUTPUT_SIZE as u32;
for (index, slice) in slices.iter().enumerate() {
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
@@ -2585,8 +2670,7 @@ mod tests {
face.source_atlas_cell.replace('/', "-")
);
let face_path = tile_dir.join(&face_filename);
std::fs::write(&face_path, &face.bytes)
.expect("保存面贴图");
std::fs::write(&face_path, &face.bytes).expect("保存面贴图");
// 解码并验证
let decoded = image::load_from_memory(&face.bytes)
@@ -2598,7 +2682,8 @@ mod tests {
decoded.dimensions(),
(expected_output_size, expected_output_size),
"tile {index} face {face_name} 应该输出 {}×{}",
expected_output_size, expected_output_size
expected_output_size,
expected_output_size
);
// 验证没有残余洋红关键色
@@ -2617,10 +2702,7 @@ mod tests {
}
assert_eq!(total_faces, JUMP_HOP_TILE_ITEM_COUNT * 6);
assert!(
no_key_color_residue,
"所有输出面贴图不应残留洋红关键色像素"
);
assert!(no_key_color_residue, "所有输出面贴图不应残留洋红关键色像素");
// === 阶段2: 关键色检测算法验证 ===
// 纯洋红应被检测为关键色
@@ -2683,31 +2765,34 @@ mod tests {
assert!(!negative_prompt.contains("规则圆盘"));
// === 阶段5: prompt 清洗验证 ===
let sanitized = sanitize_jump_hop_tile_prompt(
"宝可梦主题方块,皮卡丘风格可落脚平台素材",
);
let sanitized = sanitize_jump_hop_tile_prompt("宝可梦主题方块,皮卡丘风格可落脚平台素材");
assert!(!sanitized.contains("宝可梦"));
assert!(!sanitized.contains("皮卡丘"));
assert!(sanitized.contains("原创幻想萌宠冒险道具"));
assert!(sanitized.contains("黄色闪电萌宠符号"));
assert!(sanitized.contains("立方体主题身份方块包装"));
let sanitized2 = sanitize_jump_hop_tile_prompt(
"水果主题,跳台和地板",
);
let sanitized2 = sanitize_jump_hop_tile_prompt("水果主题,跳台和地板");
assert!(sanitized2.contains("立方体地板"));
assert!(!sanitized2.contains("跳台"));
// 打印摘要
println!(
"\n====== 跳一跳图集切片测试完成 ======"
);
println!("\n====== 跳一跳图集切片测试完成 ======");
println!("原始图集尺寸: {}×{}", width, height);
println!("大单元格数: {} ({}行×{}列)", JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS);
println!("每格 UV 面网格: {}×{}", JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS);
println!(
"大单元格数: {} ({}行×{}列)",
JUMP_HOP_TILE_ITEM_COUNT, JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS
);
println!(
"每格 UV 面网格: {}×{}",
JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS
);
println!("每格 face_side: {}px", face_side);
println!("输出面贴图数量: {}", total_faces);
println!("输出面贴图尺寸: {}×{}", expected_output_size, expected_output_size);
println!(
"输出面贴图尺寸: {}×{}",
expected_output_size, expected_output_size
);
println!("无关键色残留: {}", no_key_color_residue);
for (index, slice) in slices.iter().enumerate() {
println!(
@@ -2730,8 +2815,8 @@ mod tests {
#[ignore]
fn jump_hop_tile_atlas_ai_generation_pipeline() {
use crate::jump_hop_atlas_slicing::{
AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS,
compute_col_density, compute_row_density, refine_boundaries_seed,
AtlasSliceAlgorithm, DEFAULT_TILE_COLS, DEFAULT_TILE_ROWS, compute_col_density,
compute_row_density, refine_boundaries_seed,
};
let base_url = std::env::var("VECTOR_ENGINE_BASE_URL")
@@ -2757,8 +2842,7 @@ mod tests {
let theme_text = "水果";
let tile_prompt = "水果方块 UV 展开图集";
let atlas_prompt =
build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
let atlas_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
println!("\n====== 跳一跳 AI 图集生成与自适应切片对比测试 ======");
@@ -2781,22 +2865,19 @@ mod tests {
api_key: api_key.clone(),
request_timeout_ms: 180_000,
};
let http_client =
platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");
let http_client = platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");
let generation_result = rt.block_on(
platform_image::create_vector_engine_image_generation(
&http_client,
&settings,
&atlas_prompt,
Some(negative_prompt),
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
1,
&[],
"跳一跳图集测试",
),
);
let generation_result = rt.block_on(platform_image::create_vector_engine_image_generation(
&http_client,
&settings,
&atlas_prompt,
Some(negative_prompt),
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
1,
&[],
"跳一跳图集测试",
));
let generated = match generation_result {
Ok(images) => {
@@ -2806,9 +2887,16 @@ mod tests {
Err(error) => panic!("VectorEngine 生图失败:{error}"),
};
let tile_image = generated.images.into_iter().next().expect("应该有生成的图片");
std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes)
.expect("保存 AI 生成图集");
let tile_image = generated
.images
.into_iter()
.next()
.expect("应该有生成的图片");
std::fs::write(
output_root.join("01-ai-generated-atlas.png"),
&tile_image.bytes,
)
.expect("保存 AI 生成图集");
let download_image = crate::openai_image_generation::DownloadedOpenAiImage {
bytes: tile_image.bytes,
@@ -2820,11 +2908,13 @@ mod tests {
let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing(
download_image,
"跳一跳图集洋红去背测试失败",
).expect("洋红去背应该成功");
)
.expect("洋红去背应该成功");
std::fs::write(
output_root.join("01b-after-magenta-cleanup.png"),
&cleaned.bytes,
).expect("保存去背后图集");
)
.expect("保存去背后图集");
// 解码并计算 density打印供对比
let source = image::load_from_memory(&cleaned.bytes)
@@ -2858,7 +2948,8 @@ mod tests {
DEFAULT_TILE_ROWS,
DEFAULT_TILE_COLS,
algo,
).expect(&format!("{algo_name} 切片应该成功"));
)
.expect(&format!("{algo_name} 切片应该成功"));
assert_eq!(
slices.len(),
@@ -2869,23 +2960,33 @@ mod tests {
// 保存 density 数据供离线分析
std::fs::write(
algo_dir.join("row_density.csv"),
row_density.iter().enumerate()
row_density
.iter()
.enumerate()
.map(|(i, v)| format!("{i},{v:.6}"))
.collect::<Vec<_>>()
.join("\n"),
).expect("保存行 density");
)
.expect("保存行 density");
std::fs::write(
algo_dir.join("col_density.csv"),
col_density.iter().enumerate()
col_density
.iter()
.enumerate()
.map(|(i, v)| format!("{i},{v:.6}"))
.collect::<Vec<_>>()
.join("\n"),
).expect("保存列 density");
)
.expect("保存列 density");
// 保存行投影可视化ASCII 柱状图)
let mut row_viz = String::from("row_density:\n");
let max_d = row_density.iter().cloned().fold(0.0f32, f32::max).max(0.001);
let max_d = row_density
.iter()
.cloned()
.fold(0.0f32, f32::max)
.max(0.001);
for (y, &d) in row_density.iter().enumerate() {
let bar = (d / max_d * 60.0) as usize;
row_viz.push_str(&format!("{:4} |{}\n", y, "#".repeat(bar)));
@@ -2893,7 +2994,11 @@ mod tests {
std::fs::write(algo_dir.join("row_density_viz.txt"), &row_viz).ok();
let mut col_viz = String::from("col_density:\n");
let max_c = col_density.iter().cloned().fold(0.0f32, f32::max).max(0.001);
let max_c = col_density
.iter()
.cloned()
.fold(0.0f32, f32::max)
.max(0.001);
for (x, &d) in col_density.iter().enumerate() {
let bar = (d / max_c * 60.0) as usize;
col_viz.push_str(&format!("{:4} |{}\n", x, "#".repeat(bar)));
@@ -2904,20 +3009,34 @@ mod tests {
println!("\n固定网格种子位置:");
let cell_h = (height / DEFAULT_TILE_ROWS).max(1);
let cell_w = (width / DEFAULT_TILE_COLS).max(1);
let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS).map(|i| i * height / DEFAULT_TILE_ROWS).collect();
let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS).map(|i| i * width / DEFAULT_TILE_COLS).collect();
let row_seeds: Vec<_> = (1..DEFAULT_TILE_ROWS)
.map(|i| i * height / DEFAULT_TILE_ROWS)
.collect();
let col_seeds: Vec<_> = (1..DEFAULT_TILE_COLS)
.map(|i| i * width / DEFAULT_TILE_COLS)
.collect();
println!(" 行种子: {:?}", row_seeds);
println!(" 列种子: {:?}", col_seeds);
// 精修后的位置(用种子点精修展示偏移量)
let refined_rows = refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1));
let refined_cols = refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1));
let refined_rows =
refine_boundaries_seed(&row_density, &row_seeds, (cell_h / 3).max(1));
let refined_cols =
refine_boundaries_seed(&col_density, &col_seeds, (cell_w / 3).max(1));
println!("\n种子点精修偏移:");
for (i, (&seed, &refined)) in row_seeds.iter().zip(refined_rows.iter()).enumerate() {
println!(" 行边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32);
println!(
" 行边界 {}: seed={seed} → refined={refined} (偏移 {})",
i + 1,
refined as i32 - seed as i32
);
}
for (i, (&seed, &refined)) in col_seeds.iter().zip(refined_cols.iter()).enumerate() {
println!(" 列边界 {}: seed={seed} → refined={refined} (偏移 {})", i + 1, refined as i32 - seed as i32);
println!(
" 列边界 {}: seed={seed} → refined={refined} (偏移 {})",
i + 1,
refined as i32 - seed as i32
);
}
// 保存面贴图
@@ -2927,7 +3046,10 @@ mod tests {
let c = index as u32 % DEFAULT_TILE_COLS;
let tile_dir = algo_dir.join(format!(
"tile-{:02}-{:?}-row{}-col{}",
index + 1, slice.tile_type, r + 1, c + 1
index + 1,
slice.tile_type,
r + 1,
c + 1
));
std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录");
@@ -2946,8 +3068,7 @@ mod tests {
face_name,
face.source_atlas_cell.replace('/', "-")
);
std::fs::write(tile_dir.join(&filename), &face.bytes)
.expect("保存面贴图");
std::fs::write(tile_dir.join(&filename), &face.bytes).expect("保存面贴图");
}
}
println!("{algo_name}: 输出 {total_faces} 张面贴图");
@@ -2958,13 +3079,30 @@ mod tests {
let grid = match algo {
AtlasSliceAlgorithm::SeedRefinement => {
crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)
pixels,
width,
height,
DEFAULT_TILE_ROWS,
DEFAULT_TILE_COLS,
)
}
AtlasSliceAlgorithm::ValleyDetection => {
crate::jump_hop_atlas_slicing::detect_cell_grid_valley(
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS)
.unwrap_or_else(|_| crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
pixels, width, height, DEFAULT_TILE_ROWS, DEFAULT_TILE_COLS))
pixels,
width,
height,
DEFAULT_TILE_ROWS,
DEFAULT_TILE_COLS,
)
.unwrap_or_else(|_| {
crate::jump_hop_atlas_slicing::detect_cell_grid_seed(
pixels,
width,
height,
DEFAULT_TILE_ROWS,
DEFAULT_TILE_COLS,
)
})
}
};
for row in 0..DEFAULT_TILE_ROWS {
@@ -2973,11 +3111,11 @@ mod tests {
let x1 = grid.col_boundaries[col as usize + 1];
let y0 = grid.row_boundaries[row as usize];
let y1 = grid.row_boundaries[row as usize + 1];
let cell_img = image::imageops::crop_imm(
&source, x0, y0, x1 - x0, y1 - y0);
cell_img.to_image().save(
cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))
).expect("保存 cell 切图");
let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0);
cell_img
.to_image()
.save(cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)))
.expect("保存 cell 切图");
}
}
println!("{algo_name}: 保存 {DEFAULT_TILE_ROWS}×{DEFAULT_TILE_COLS} cell 网格切图");
@@ -2986,7 +3124,10 @@ mod tests {
println!("\n====== 对比测试完成 ======");
println!("输出目录: {}", output_root.display());
println!(" seed 算法结果: {}/02-slices-seed/", output_root.display());
println!(" valley 算法结果: {}/02-slices-valley/", output_root.display());
println!(
" valley 算法结果: {}/02-slices-valley/",
output_root.display()
);
println!(" density 数据: 各算法目录下的 row_density.csv / col_density.csv");
println!("==============================\n");
}
@@ -3021,8 +3162,7 @@ mod tests {
let theme_text = "水果";
let tile_prompt = "水果方块 UV 展开图集";
let atlas_prompt =
build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
let atlas_prompt = build_jump_hop_tile_atlas_prompt(theme_text, tile_prompt);
let negative_prompt = build_jump_hop_tile_atlas_negative_prompt();
println!("\n====== 跳一跳固定网格 AI 生图测试 ======");
@@ -3044,22 +3184,19 @@ mod tests {
api_key: api_key.clone(),
request_timeout_ms: 180_000,
};
let http_client =
platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");
let http_client = platform_image::build_vector_engine_image_http_client(&settings)
.expect("构建 HTTP 客户端");
let generation_result = rt.block_on(
platform_image::create_vector_engine_image_generation(
&http_client,
&settings,
&atlas_prompt,
Some(negative_prompt),
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
1,
&[],
"跳一跳图集测试(固定网格)",
),
);
let generation_result = rt.block_on(platform_image::create_vector_engine_image_generation(
&http_client,
&settings,
&atlas_prompt,
Some(negative_prompt),
JUMP_HOP_TILE_ATLAS_IMAGE_SIZE,
1,
&[],
"跳一跳图集测试(固定网格)",
));
let generated = match generation_result {
Ok(images) => {
@@ -3069,9 +3206,16 @@ mod tests {
Err(error) => panic!("VectorEngine 生图失败:{error}"),
};
let tile_image = generated.images.into_iter().next().expect("应该有生成的图片");
std::fs::write(output_root.join("01-ai-generated-atlas.png"), &tile_image.bytes)
.expect("保存 AI 生成图集");
let tile_image = generated
.images
.into_iter()
.next()
.expect("应该有生成的图片");
std::fs::write(
output_root.join("01-ai-generated-atlas.png"),
&tile_image.bytes,
)
.expect("保存 AI 生成图集");
let download_image = crate::openai_image_generation::DownloadedOpenAiImage {
bytes: tile_image.bytes,
@@ -3083,11 +3227,13 @@ mod tests {
let cleaned = prepare_jump_hop_magenta_screen_image_for_slicing(
download_image,
"跳一跳图集洋红去背测试失败",
).expect("洋红去背应该成功");
)
.expect("洋红去背应该成功");
std::fs::write(
output_root.join("02-after-magenta-cleanup.png"),
&cleaned.bytes,
).expect("保存去背后图集");
)
.expect("保存去背后图集");
let source = image::load_from_memory(&cleaned.bytes)
.expect("解码")
@@ -3100,8 +3246,7 @@ mod tests {
let cell_h = source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
println!("固定 cell: {}×{} px", cell_w, cell_h);
let slices = slice_jump_hop_tile_atlas(&cleaned)
.expect("固定网格切片应该成功");
let slices = slice_jump_hop_tile_atlas(&cleaned).expect("固定网格切片应该成功");
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_COUNT);
let mut total_faces = 0usize;
@@ -3110,7 +3255,10 @@ mod tests {
let c = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
let tile_dir = output_root.join(format!(
"tile-{:02}-{:?}-row{}-col{}",
index + 1, slice.tile_type, r + 1, c + 1
index + 1,
slice.tile_type,
r + 1,
c + 1
));
std::fs::create_dir_all(&tile_dir).expect("创建 tile 目录");
@@ -3129,8 +3277,7 @@ mod tests {
face_name,
face.source_atlas_cell.replace('/', "-")
);
std::fs::write(tile_dir.join(&filename), &face.bytes)
.expect("保存面贴图");
std::fs::write(tile_dir.join(&filename), &face.bytes).expect("保存面贴图");
}
}
@@ -3147,12 +3294,16 @@ mod tests {
let y0 = row * source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
let y1 = (row + 1) * source.height() / JUMP_HOP_TILE_ATLAS_ROWS;
let cell_img = image::imageops::crop_imm(&source, x0, y0, x1 - x0, y1 - y0);
cell_img.to_image().save(
cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1))
).expect("保存 cell 切图");
cell_img
.to_image()
.save(cells_dir.join(format!("cell-row{}-col{}.png", row + 1, col + 1)))
.expect("保存 cell 切图");
}
}
println!("保存 {}×{} cell 网格切图", JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS);
println!(
"保存 {}×{} cell 网格切图",
JUMP_HOP_TILE_ATLAS_ROWS, JUMP_HOP_TILE_ATLAS_COLS
);
println!("\n====== 固定网格测试完成 ======");
println!("输出目录: {}", output_root.display());

View File

@@ -819,7 +819,8 @@ fn max_opaque_rect(
let area = sh * (x - sx);
if area > best_area {
best_area = area;
best = (bx0 + sx, by0 + ly - sh + 1, x - sx, sh);
let top = by0 + ly.saturating_add(1).saturating_sub(sh);
best = (bx0 + sx, top, x - sx, sh);
}
start = sx;
}
@@ -832,13 +833,35 @@ fn max_opaque_rect(
let area = sh * (x - sx);
if area > best_area {
best_area = area;
best = (bx0 + sx, by0 + ly as u32 - sh + 1, x - sx, sh);
let top = by0 + ly.saturating_add(1).saturating_sub(sh);
best = (bx0 + sx, top, x - sx, sh);
}
}
}
if best_area == 0 { None } else { Some(best) }
}
#[cfg(test)]
mod tests {
use image::{Rgba, RgbaImage};
use super::max_opaque_rect;
#[test]
fn max_opaque_rect_handles_content_touching_top_edge() {
let mut image = RgbaImage::from_pixel(4, 3, Rgba([0, 0, 0, 0]));
for y in 0..2 {
for x in 0..3 {
image.put_pixel(x, y, Rgba([255, 255, 255, 255]));
}
}
let rect = max_opaque_rect(&image, 0, 0, 4, 3).expect("应该能识别贴顶矩形");
assert_eq!(rect, (0, 0, 3, 2));
}
}
// ---- 4. 主编排 ----
fn slice_jump_hop_tile_uv_faces_blob(