Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "bevy_forge_cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Command line tools for Bevy Forge scenes and projects"
|
||||
|
||||
[[bin]]
|
||||
name = "forge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bevy_forge_core = { path = "../bevy_forge_core" }
|
||||
clap.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Implementations of the `forge` subcommands.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use bevy_forge_core::{
|
||||
validate_scene, AssetIndex, CodegenOptions, ComponentData, ExportFormat, Issue, Project,
|
||||
SceneDoc, Severity,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::scaffold;
|
||||
use crate::{Commands, FormatArg, TemplateArg};
|
||||
|
||||
/// Execute one `forge` subcommand, returning its process exit code.
|
||||
pub(crate) fn run(command: Commands) -> Result<u8> {
|
||||
match command {
|
||||
Commands::New {
|
||||
dir,
|
||||
name,
|
||||
template,
|
||||
} => new_project(&dir, name.as_deref(), template),
|
||||
Commands::Validate {
|
||||
scene,
|
||||
project,
|
||||
json,
|
||||
} => validate(&scene, project.as_deref(), json),
|
||||
Commands::Export {
|
||||
scene,
|
||||
format,
|
||||
output,
|
||||
function,
|
||||
} => export(&scene, format, output.as_deref(), &function),
|
||||
Commands::Info { scene, json } => info(&scene, json),
|
||||
Commands::Scenes { project } => scenes(&project),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// new
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn new_project(dir: &Path, name: Option<&str>, template: TemplateArg) -> Result<u8> {
|
||||
let project_template = template.into();
|
||||
let created = scaffold::create(dir, name, project_template)?;
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!(
|
||||
"Created Bevy project `{}` at {}\n",
|
||||
created.name,
|
||||
created.root.display()
|
||||
));
|
||||
output.push_str(&format!(" template: {}\n", template_label(template)));
|
||||
output.push_str(&format!(" scene: {}\n", created.scene_path.display()));
|
||||
output.push_str("\nNext steps:\n");
|
||||
output.push_str(&format!(" cd {}\n", created.root.display()));
|
||||
output.push_str(" cargo run\n");
|
||||
output.push_str(" forge validate scenes/demo.forge.ron\n");
|
||||
if matches!(template, TemplateArg::Empty) {
|
||||
output
|
||||
.push_str(" # add entities in Bevy Forge before exporting the empty starter scene\n");
|
||||
} else {
|
||||
output.push_str(" forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n");
|
||||
}
|
||||
print_stdout(&output)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn template_label(template: TemplateArg) -> &'static str {
|
||||
match template {
|
||||
TemplateArg::Empty => "empty",
|
||||
TemplateArg::TwoD => "2D",
|
||||
TemplateArg::ThreeD => "3D",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn validate(scene: &Path, project_arg: Option<&Path>, json: bool) -> Result<u8> {
|
||||
let project = resolve_project(scene, project_arg)?;
|
||||
let document = load_scene(scene, project.as_ref())?;
|
||||
let assets = project
|
||||
.as_ref()
|
||||
.map(|project| AssetIndex::scan(&project.assets_dir));
|
||||
if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) {
|
||||
eprintln!("forge: warning: could not scan assets: {error}");
|
||||
}
|
||||
let report = validate_scene(&document, assets.as_ref());
|
||||
|
||||
if json {
|
||||
let issues: Vec<Value> = report.sorted().into_iter().map(issue_json).collect();
|
||||
let payload = json!({
|
||||
"valid": report.is_ok(),
|
||||
"errors": report.errors(),
|
||||
"warnings": report.warnings(),
|
||||
"issues": issues,
|
||||
});
|
||||
print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?;
|
||||
} else {
|
||||
let mut output = String::new();
|
||||
for issue in report.sorted() {
|
||||
output.push_str(&format_issue(issue));
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&format!(
|
||||
"Summary: {} errors, {} warnings, {} info — {}\n",
|
||||
report.errors(),
|
||||
report.warnings(),
|
||||
report.count(Severity::Info),
|
||||
if report.is_ok() { "valid" } else { "invalid" }
|
||||
));
|
||||
print_stdout(&output)?;
|
||||
}
|
||||
|
||||
Ok(if report.is_ok() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn format_issue(issue: &Issue) -> String {
|
||||
let mut line = issue.severity.label().to_string();
|
||||
if let Some(entity) = issue.entity {
|
||||
line.push_str(&format!(" [entity {entity}]"));
|
||||
}
|
||||
if let Some(field) = &issue.field {
|
||||
line.push_str(&format!(" [{field}]"));
|
||||
}
|
||||
line.push(' ');
|
||||
line.push_str(&issue.message);
|
||||
line
|
||||
}
|
||||
|
||||
fn issue_json(issue: &Issue) -> Value {
|
||||
json!({
|
||||
"severity": issue.severity.label(),
|
||||
"entity": issue.entity,
|
||||
"field": issue.field,
|
||||
"message": issue.message,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn export(scene: &Path, format: FormatArg, output: Option<&Path>, function: &str) -> Result<u8> {
|
||||
let document = read_scene_file(scene)?;
|
||||
let export_format: ExportFormat = format.into();
|
||||
let options = CodegenOptions {
|
||||
function_name: function.to_string(),
|
||||
// The CLI contract requires exported files to start with the Bevy
|
||||
// prelude import; the editor adds its own attribution header for
|
||||
// previews.
|
||||
include_header: false,
|
||||
};
|
||||
let mut code = match export_format.generate(&document, &options) {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("forge: export failed: {error}");
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
// The codegen contract promises a trailing newline; make sure we never
|
||||
// write a file without one even if a future generator forgets it.
|
||||
if !code.ends_with('\n') {
|
||||
code.push('\n');
|
||||
}
|
||||
|
||||
match output {
|
||||
Some(path) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("could not create output directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
fs::write(path, code.as_bytes())
|
||||
.with_context(|| format!("could not write {}", path.display()))?;
|
||||
print_stdout(&format!(
|
||||
"Wrote {} to {}\n",
|
||||
export_format.label(),
|
||||
path.display()
|
||||
))?;
|
||||
}
|
||||
None => print_stdout(&code)?,
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn info(scene: &Path, json: bool) -> Result<u8> {
|
||||
let project = discover_project(scene);
|
||||
let document = load_scene(scene, project.as_ref())?;
|
||||
let assets = project
|
||||
.as_ref()
|
||||
.map(|project| AssetIndex::scan(&project.assets_dir));
|
||||
if let Some(error) = assets.as_ref().and_then(|assets| assets.error.as_deref()) {
|
||||
eprintln!("forge: warning: could not scan assets: {error}");
|
||||
}
|
||||
let report = validate_scene(&document, assets.as_ref());
|
||||
let stats = document.stats();
|
||||
let path = display_path(scene);
|
||||
|
||||
let mut component_counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut asset_references: Vec<AssetReference> = Vec::new();
|
||||
for node in document.entities.values() {
|
||||
for component in &node.components {
|
||||
let key = component_key(component);
|
||||
*component_counts.entry(key).or_insert(0) += 1;
|
||||
for (field, path) in component.asset_references() {
|
||||
asset_references.push(AssetReference {
|
||||
entity: node.id,
|
||||
field,
|
||||
path: path.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json {
|
||||
let components: BTreeMap<&str, usize> = component_counts
|
||||
.iter()
|
||||
.map(|(key, count)| (key.as_str(), *count))
|
||||
.collect();
|
||||
let references: Vec<Value> = asset_references
|
||||
.iter()
|
||||
.map(|reference| {
|
||||
json!({
|
||||
"entity": reference.entity,
|
||||
"field": reference.field,
|
||||
"path": reference.path,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let project_json = project.as_ref().map(|project| {
|
||||
json!({
|
||||
"name": project.name,
|
||||
"root": path_string(&project.root),
|
||||
})
|
||||
});
|
||||
let payload = json!({
|
||||
"name": document.name,
|
||||
"path": path_string(&path),
|
||||
"format_version": document.format_version,
|
||||
"entities": stats.entities,
|
||||
"roots": stats.roots,
|
||||
"stats": {
|
||||
"sprites": stats.sprites,
|
||||
"meshes": stats.meshes,
|
||||
"lights": stats.lights,
|
||||
"cameras": stats.cameras,
|
||||
"custom_components": stats.custom_components,
|
||||
},
|
||||
"components": components,
|
||||
"asset_references": references,
|
||||
"project": project_json,
|
||||
"validation": {
|
||||
"valid": report.is_ok(),
|
||||
"errors": report.errors(),
|
||||
"warnings": report.warnings(),
|
||||
"info": report.count(Severity::Info),
|
||||
},
|
||||
});
|
||||
print_stdout(&format!("{}\n", serde_json::to_string(&payload)?))?;
|
||||
} else {
|
||||
let mut output = String::new();
|
||||
output.push_str(&format!("Scene: {}\n", document.name));
|
||||
output.push_str(&format!("Path: {}\n", path.display()));
|
||||
output.push_str(&format!("Format: version {}\n", document.format_version));
|
||||
match &project {
|
||||
Some(project) => output.push_str(&format!(
|
||||
"Project: {} ({})\n",
|
||||
project.name,
|
||||
project.root.display()
|
||||
)),
|
||||
None => output.push_str("Project: (not found)\n"),
|
||||
}
|
||||
output.push_str(&format!(
|
||||
"Entities: {} (roots: {})\n",
|
||||
stats.entities, stats.roots
|
||||
));
|
||||
output.push_str(&format!(
|
||||
"Stats: sprites {}, meshes {}, lights {}, cameras {}, custom components {}\n",
|
||||
stats.sprites, stats.meshes, stats.lights, stats.cameras, stats.custom_components
|
||||
));
|
||||
if component_counts.is_empty() {
|
||||
output.push_str("Components: (none)\n");
|
||||
} else {
|
||||
output.push_str("Components:\n");
|
||||
for (key, count) in &component_counts {
|
||||
output.push_str(&format!(" {key}: {count}\n"));
|
||||
}
|
||||
}
|
||||
if asset_references.is_empty() {
|
||||
output.push_str("Asset references: (none)\n");
|
||||
} else {
|
||||
output.push_str("Asset references:\n");
|
||||
for reference in &asset_references {
|
||||
output.push_str(&format!(
|
||||
" [entity {}] {} {}\n",
|
||||
reference.entity, reference.field, reference.path
|
||||
));
|
||||
}
|
||||
}
|
||||
output.push_str(&format!(
|
||||
"Validation: {} errors, {} warnings, {} info ({})\n",
|
||||
report.errors(),
|
||||
report.warnings(),
|
||||
report.count(Severity::Info),
|
||||
if report.is_ok() { "valid" } else { "invalid" }
|
||||
));
|
||||
print_stdout(&output)?;
|
||||
}
|
||||
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
struct AssetReference {
|
||||
entity: u64,
|
||||
field: &'static str,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn component_key(component: &ComponentData) -> String {
|
||||
match component {
|
||||
ComponentData::Custom(custom) => format!("custom:{}", custom.type_path),
|
||||
other => other.key(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn scenes(project_arg: &Path) -> Result<u8> {
|
||||
let project = Project::open(project_arg)?;
|
||||
let mut scenes = project.list_scenes();
|
||||
scenes.sort();
|
||||
let mut output = String::new();
|
||||
for scene in scenes {
|
||||
output.push_str(&path_string(&scene));
|
||||
output.push('\n');
|
||||
}
|
||||
print_stdout(&output)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolve the project for a scene: explicit flag first, then walk up.
|
||||
fn resolve_project(scene: &Path, explicit: Option<&Path>) -> Result<Option<Project>> {
|
||||
match explicit {
|
||||
Some(root) => Ok(Some(Project::open(root)?)),
|
||||
None => Ok(discover_project(scene)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk up from `scene`, returning the first directory that opens as a Bevy project.
|
||||
fn discover_project(scene: &Path) -> Option<Project> {
|
||||
let scene = std::path::absolute(scene).ok()?;
|
||||
let mut current = scene.parent();
|
||||
while let Some(directory) = current {
|
||||
if directory.join("Cargo.toml").is_file() {
|
||||
if let Ok(project) = Project::open(directory) {
|
||||
return Some(project);
|
||||
}
|
||||
}
|
||||
current = directory.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Load a scene, using the project loader when a project is available.
|
||||
fn load_scene(scene: &Path, project: Option<&Project>) -> Result<SceneDoc> {
|
||||
match project {
|
||||
Some(project) => Ok(project.load_scene(scene)?),
|
||||
None => read_scene_file(scene),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a scene file that may live outside a Bevy project.
|
||||
fn read_scene_file(scene: &Path) -> Result<SceneDoc> {
|
||||
// `Project::load_scene` is a pure RON file reader; a minimal project
|
||||
// description keeps parsing and version normalisation in one place for
|
||||
// standalone scenes as well.
|
||||
let root = scene
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
let loader = Project {
|
||||
root: root.clone(),
|
||||
name: String::new(),
|
||||
assets_dir: root,
|
||||
bevy_version: None,
|
||||
};
|
||||
Ok(loader.load_scene(scene)?)
|
||||
}
|
||||
|
||||
fn display_path(path: &Path) -> PathBuf {
|
||||
fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn path_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Write to stdout without panicking on a broken pipe.
|
||||
fn print_stdout(text: &str) -> Result<()> {
|
||||
let stdout = std::io::stdout();
|
||||
let mut handle = stdout.lock();
|
||||
handle
|
||||
.write_all(text.as_bytes())
|
||||
.context("could not write to stdout")?;
|
||||
handle.flush().context("could not flush stdout")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! `forge` — command line tools for Bevy Forge scenes.
|
||||
//!
|
||||
//! The binary validates and exports `.forge.ron` scenes and scaffolds new Bevy
|
||||
//! 0.20 projects. Every subcommand prints human readable output by default and
|
||||
//! supports machine-readable output where the contract asks for it (`--json`).
|
||||
//!
|
||||
//! Exit codes:
|
||||
//!
|
||||
//! * `0` — success (and `validate` found no errors)
|
||||
//! * `1` — validation errors or a code generation failure
|
||||
//! * `2` — usage, I/O, parse errors, or a directory that is not a Bevy project
|
||||
|
||||
mod commands;
|
||||
mod scaffold;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use bevy_forge_core::{ExportFormat, ProjectTemplate};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "forge",
|
||||
version,
|
||||
about = "Validate, export and scaffold Bevy Forge scenes for Bevy 0.20 BSN",
|
||||
propagate_version = true
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum Commands {
|
||||
/// Create a new Bevy 0.20 project with a starter scene.
|
||||
New {
|
||||
/// Target directory (must not exist or be empty).
|
||||
dir: PathBuf,
|
||||
/// Cargo package name; defaults to a sanitised directory name.
|
||||
#[arg(long, value_name = "NAME")]
|
||||
name: Option<String>,
|
||||
/// Starter scene template.
|
||||
#[arg(long, value_enum, value_name = "TEMPLATE", default_value_t = TemplateArg::TwoD)]
|
||||
template: TemplateArg,
|
||||
},
|
||||
/// Validate a .forge.ron scene and its asset references.
|
||||
Validate {
|
||||
/// Scene file to validate.
|
||||
scene: PathBuf,
|
||||
/// Project root; discovered by walking up from the scene when omitted.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
project: Option<PathBuf>,
|
||||
/// Machine-readable output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Export a scene as Bevy 0.20 BSN or imperative Rust code.
|
||||
Export {
|
||||
/// Scene file to export.
|
||||
scene: PathBuf,
|
||||
/// Generated code style.
|
||||
#[arg(long, value_enum, value_name = "FORMAT", default_value_t = FormatArg::Bsn)]
|
||||
format: FormatArg,
|
||||
/// Output file; code is printed to stdout when omitted.
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
output: Option<PathBuf>,
|
||||
/// Name of the generated Rust function.
|
||||
#[arg(long, value_name = "NAME", default_value = "scene")]
|
||||
function: String,
|
||||
},
|
||||
/// Print scene information and validation status.
|
||||
Info {
|
||||
/// Scene file to inspect.
|
||||
scene: PathBuf,
|
||||
/// Machine-readable output.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// List the scenes of a Bevy project, one absolute path per line.
|
||||
Scenes {
|
||||
/// Bevy project root.
|
||||
project: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub(crate) enum TemplateArg {
|
||||
Empty,
|
||||
#[value(name = "2d")]
|
||||
TwoD,
|
||||
#[value(name = "3d")]
|
||||
ThreeD,
|
||||
}
|
||||
|
||||
impl From<TemplateArg> for ProjectTemplate {
|
||||
fn from(value: TemplateArg) -> Self {
|
||||
match value {
|
||||
TemplateArg::Empty => Self::Empty,
|
||||
TemplateArg::TwoD => Self::TwoD,
|
||||
TemplateArg::ThreeD => Self::ThreeD,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub(crate) enum FormatArg {
|
||||
Bsn,
|
||||
Rust,
|
||||
}
|
||||
|
||||
impl From<FormatArg> for ExportFormat {
|
||||
fn from(value: FormatArg) -> Self {
|
||||
match value {
|
||||
FormatArg::Bsn => Self::Bsn,
|
||||
FormatArg::Rust => Self::Rust,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Clap handles `--help` (exit 0) and usage errors (exit 2) itself.
|
||||
let cli = Cli::parse();
|
||||
match commands::run(cli.command) {
|
||||
Ok(code) => ExitCode::from(code),
|
||||
Err(error) => {
|
||||
eprintln!("forge: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Project scaffolding for `forge new`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use bevy_forge_core::codegen::{CodegenOptions, ExportFormat};
|
||||
use bevy_forge_core::{samples, Project, ProjectTemplate};
|
||||
|
||||
/// Default scene written into a freshly scaffolded project.
|
||||
const DEMO_SCENE_FILE: &str = "demo.forge.ron";
|
||||
|
||||
/// A project created by [`create`].
|
||||
pub(crate) struct CreatedProject {
|
||||
pub name: String,
|
||||
pub root: PathBuf,
|
||||
pub scene_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Create a Bevy 0.20 project in `dir` with a starter scene for `template`.
|
||||
///
|
||||
/// `dir` must not exist yet or must be an empty directory. Existing content is
|
||||
/// never overwritten: the emptiness check runs before the first write.
|
||||
pub(crate) fn create(
|
||||
dir: &Path,
|
||||
name: Option<&str>,
|
||||
template: ProjectTemplate,
|
||||
) -> Result<CreatedProject> {
|
||||
ensure_empty_target(dir)?;
|
||||
let package_name = match name {
|
||||
Some(name) => validate_package_name(name)?,
|
||||
None => default_package_name(dir),
|
||||
};
|
||||
|
||||
fs::create_dir_all(dir)
|
||||
.with_context(|| format!("could not create target directory {}", dir.display()))?;
|
||||
for subdirectory in ["src", "assets", "scenes"] {
|
||||
fs::create_dir_all(dir.join(subdirectory))
|
||||
.with_context(|| format!("could not create {}", dir.join(subdirectory).display()))?;
|
||||
}
|
||||
|
||||
write_file(&dir.join("Cargo.toml"), &manifest(&package_name))?;
|
||||
write_file(&dir.join("assets/.gitkeep"), "")?;
|
||||
write_file(&dir.join(".gitignore"), GITIGNORE)?;
|
||||
write_file(&dir.join("README.md"), &readme(&package_name, template))?;
|
||||
|
||||
// Re-open the project so the starter scene goes through the same atomic
|
||||
// serialisation path the editor uses.
|
||||
let project = Project::open(dir).with_context(|| {
|
||||
format!(
|
||||
"could not open the newly created project at {}",
|
||||
dir.display()
|
||||
)
|
||||
})?;
|
||||
let document = samples::for_template(template);
|
||||
let scene_path = project.scenes_dir().join(DEMO_SCENE_FILE);
|
||||
project
|
||||
.save_scene(&document, Some(&scene_path))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"could not write the starter scene to {}",
|
||||
scene_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Make `cargo run` show the starter scene immediately instead of a blank
|
||||
// window: generate the BSN scene module and call it from a startup system.
|
||||
if template != ProjectTemplate::Empty {
|
||||
let code = ExportFormat::Bsn
|
||||
.generate(
|
||||
&document,
|
||||
&CodegenOptions {
|
||||
function_name: "scene".to_string(),
|
||||
include_header: true,
|
||||
},
|
||||
)
|
||||
.context("could not generate starter BSN scene")?;
|
||||
write_file(&dir.join("src/scene.rs"), &code)?;
|
||||
let needs_meshes = code.contains("meshes: &mut Assets<Mesh>");
|
||||
let main = if needs_meshes {
|
||||
r#"//! Bevy 0.20 app scaffolded by Bevy Forge.
|
||||
|
||||
mod scene;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(Startup, setup)
|
||||
.run();
|
||||
}
|
||||
|
||||
fn setup(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
commands.spawn_scene_list(scene::scene(&mut meshes, &mut materials));
|
||||
}
|
||||
"#
|
||||
} else {
|
||||
r#"//! Bevy 0.20 app scaffolded by Bevy Forge.
|
||||
|
||||
mod scene;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(Startup, setup)
|
||||
.run();
|
||||
}
|
||||
|
||||
fn setup(mut commands: Commands) {
|
||||
commands.spawn_scene_list(scene::scene());
|
||||
}
|
||||
"#
|
||||
};
|
||||
write_file(&dir.join("src/main.rs"), main)?;
|
||||
} else {
|
||||
write_file(&dir.join("src/main.rs"), MAIN_RS)?;
|
||||
}
|
||||
|
||||
Ok(CreatedProject {
|
||||
name: project.name.clone(),
|
||||
root: project.root.clone(),
|
||||
scene_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse to scaffold into a non-empty directory.
|
||||
fn ensure_empty_target(dir: &Path) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
bail!(
|
||||
"target path exists and is not a directory: {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
let mut entries = fs::read_dir(dir)
|
||||
.with_context(|| format!("could not read target directory {}", dir.display()))?;
|
||||
if let Some(entry) = entries.next() {
|
||||
let entry =
|
||||
entry.with_context(|| format!("could not read the contents of {}", dir.display()))?;
|
||||
bail!(
|
||||
"the target directory already exists and is not empty: {} (found {})",
|
||||
dir.display(),
|
||||
entry.file_name().to_string_lossy()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a Cargo package name from the target directory name.
|
||||
fn default_package_name(dir: &Path) -> String {
|
||||
let base = dir
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("bevy-forge-project");
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
for character in base.chars() {
|
||||
let character = character.to_ascii_lowercase();
|
||||
if character.is_ascii_alphanumeric() || character == '_' {
|
||||
slug.push(character);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"bevy-forge-project".to_string()
|
||||
} else if slug.starts_with(|character: char| character.is_ascii_digit()) {
|
||||
format!("forge-{slug}")
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject `--name` values Cargo would refuse.
|
||||
fn validate_package_name(name: &str) -> Result<String> {
|
||||
if name.is_empty() {
|
||||
bail!("invalid package name: the name must not be empty");
|
||||
}
|
||||
let valid_first = name
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| character.is_ascii_alphabetic() || character == '_');
|
||||
if !valid_first {
|
||||
bail!("invalid package name {name:?}: it must start with an ASCII letter or '_'");
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
|
||||
{
|
||||
bail!("invalid package name {name:?}: use letters, digits, '-' and '_' only");
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, contents: &str) -> Result<()> {
|
||||
fs::write(path, contents).with_context(|| format!("could not write {}", path.display()))
|
||||
}
|
||||
|
||||
fn manifest(package_name: &str) -> String {
|
||||
format!(
|
||||
"[package]\n\
|
||||
name = \"{package_name}\"\n\
|
||||
version = \"0.1.0\"\n\
|
||||
edition = \"2021\"\n\
|
||||
\n\
|
||||
[dependencies]\n\
|
||||
bevy = \"0.20\"\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn readme(package_name: &str, template: ProjectTemplate) -> String {
|
||||
let scene_hint = match template {
|
||||
ProjectTemplate::Empty => {
|
||||
"`scenes/demo.forge.ron` is an empty starter scene. Add entities in the Bevy Forge\n\
|
||||
editor, then export it to paste-ready Rust."
|
||||
}
|
||||
_ => {
|
||||
"`scenes/demo.forge.ron` is a starter scene. Open it with the Bevy Forge\n\
|
||||
editor, or export it to paste-ready Rust."
|
||||
}
|
||||
};
|
||||
let spawn_hint = match template {
|
||||
ProjectTemplate::Empty => {
|
||||
"Once the scene has entities, export it with `forge export`; an empty scene\n\
|
||||
cannot be exported until something is added."
|
||||
}
|
||||
_ => {
|
||||
"The starter scenes have multiple roots, so spawn the exported function with\n\
|
||||
`commands.spawn_scene_list(scene())`. A single-root scene uses\n\
|
||||
`commands.spawn_scene(scene())`; mesh scenes also need the `meshes` and\n\
|
||||
`materials` parameters. Add `use bevy::scene::SceneList` (or `Scene`) if the\n\
|
||||
trait is not already in scope."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"# {package_name}\n\
|
||||
\n\
|
||||
A Bevy 0.20 project scaffolded with [Bevy Forge](https://github.com/bevy-forge/bevy-forge).\n\
|
||||
\n\
|
||||
## Run\n\
|
||||
\n\
|
||||
```sh\n\
|
||||
cargo run\n\
|
||||
```\n\
|
||||
\n\
|
||||
## Scene\n\
|
||||
\n\
|
||||
{scene_hint}\n\
|
||||
\n\
|
||||
```sh\n\
|
||||
forge export scenes/demo.forge.ron --format bsn -o src/scene.rs\n\
|
||||
```\n\
|
||||
\n\
|
||||
Generated BSN code is ordinary Rust for Bevy 0.20. {spawn_hint}\n"
|
||||
)
|
||||
}
|
||||
|
||||
const MAIN_RS: &str = r#"//! Minimal Bevy 0.20 app scaffolded by Bevy Forge.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
fn main() {
|
||||
App::new().add_plugins(DefaultPlugins).run();
|
||||
}
|
||||
"#;
|
||||
|
||||
const GITIGNORE: &str = "/target\n.forge/\n";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn package_name_validation_accepts_common_names() {
|
||||
assert_eq!(validate_package_name("my-game").unwrap(), "my-game");
|
||||
assert_eq!(validate_package_name("game_2").unwrap(), "game_2");
|
||||
assert!(validate_package_name("").is_err());
|
||||
assert!(validate_package_name("2fast").is_err());
|
||||
assert!(validate_package_name("my game").is_err());
|
||||
assert!(validate_package_name("game!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_package_name_sanitises_directory_names() {
|
||||
assert_eq!(
|
||||
default_package_name(Path::new("/tmp/My Game 2")),
|
||||
"my-game-2"
|
||||
);
|
||||
assert_eq!(
|
||||
default_package_name(Path::new("/tmp/***")),
|
||||
"bevy-forge-project"
|
||||
);
|
||||
assert_eq!(default_package_name(Path::new("/tmp/3d")), "forge-3d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_target_is_accepted_but_non_empty_is_rejected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert!(ensure_empty_target(&tmp.path().join("fresh")).is_ok());
|
||||
let empty = tmp.path().join("empty-target");
|
||||
std::fs::create_dir_all(&empty).unwrap();
|
||||
assert!(ensure_empty_target(&empty).is_ok());
|
||||
std::fs::write(tmp.path().join("occupied.txt"), "x").unwrap();
|
||||
let error = ensure_empty_target(tmp.path()).unwrap_err().to_string();
|
||||
assert!(error.contains("not empty"), "unexpected error: {error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
//! End-to-end tests for the `forge` binary.
|
||||
//!
|
||||
//! Scenes are produced through the core API so these tests exercise the real
|
||||
//! serialisation path instead of hand-written RON.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use bevy_forge_core::{ComponentData, Project, SceneDoc, SpriteData, Text2dData};
|
||||
use serde_json::Value;
|
||||
|
||||
fn forge(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_forge"))
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("failed to spawn the forge binary")
|
||||
}
|
||||
|
||||
fn exit_code(output: &Output) -> i32 {
|
||||
output
|
||||
.status
|
||||
.code()
|
||||
.expect("the forge process was terminated by a signal")
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
}
|
||||
|
||||
/// Create a minimal Bevy project (manifest + empty assets dir) in `dir`.
|
||||
fn create_project(dir: &Path) -> Project {
|
||||
fs::create_dir_all(dir.join("assets")).unwrap();
|
||||
fs::write(
|
||||
dir.join("Cargo.toml"),
|
||||
"[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
Project::open(dir).unwrap()
|
||||
}
|
||||
|
||||
fn save_scene(project: &Project, file: &str, document: &SceneDoc) -> PathBuf {
|
||||
let path = project.scenes_dir().join(file);
|
||||
project.save_scene(document, Some(&path)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// A tiny scene with one textured sprite and one text entity.
|
||||
fn sprite_scene(name: &str, image: &str) -> SceneDoc {
|
||||
let mut document = SceneDoc::new(name);
|
||||
let player = document.add_entity("Player", None);
|
||||
document.add_component(
|
||||
player,
|
||||
ComponentData::Sprite(SpriteData {
|
||||
image: Some(image.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let label = document.add_entity("Label", None);
|
||||
document.add_component(
|
||||
label,
|
||||
ComponentData::Text2d(Text2dData {
|
||||
text: "Hello".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
document
|
||||
}
|
||||
|
||||
/// Run `forge new` and return the project root plus its demo scene path.
|
||||
fn scaffold_2d(dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let output = forge(&["new", dir.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"forge new failed\nstdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let project = Project::open(dir).unwrap();
|
||||
let scene = project.scenes_dir().join("demo.forge.ron");
|
||||
(project.root.clone(), scene)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level CLI behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_subcommand() {
|
||||
let output = forge(&["--help"]);
|
||||
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
for subcommand in ["new", "validate", "export", "info", "scenes"] {
|
||||
assert!(
|
||||
text.contains(subcommand),
|
||||
"`{subcommand}` missing from --help output:\n{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_prints_the_package_version() {
|
||||
let output = forge(&["--version"]);
|
||||
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
|
||||
assert!(
|
||||
stdout(&output).contains(env!("CARGO_PKG_VERSION")),
|
||||
"unexpected version output: {}",
|
||||
stdout(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_exits_with_a_usage_error() {
|
||||
let output = forge(&["frobnicate"]);
|
||||
assert_eq!(exit_code(&output), 2);
|
||||
assert!(!stderr(&output).is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// new
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_creates_a_valid_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("my-game");
|
||||
let output = forge(&["new", dir.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"forge new failed\nstdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
|
||||
for relative in [
|
||||
"Cargo.toml",
|
||||
"src/main.rs",
|
||||
"assets/.gitkeep",
|
||||
"scenes/demo.forge.ron",
|
||||
".gitignore",
|
||||
"README.md",
|
||||
] {
|
||||
assert!(dir.join(relative).is_file(), "missing {relative}");
|
||||
}
|
||||
|
||||
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
|
||||
assert!(
|
||||
manifest.contains("name = \"my-game\""),
|
||||
"manifest: {manifest}"
|
||||
);
|
||||
assert!(manifest.contains("bevy = \"0.20\""), "manifest: {manifest}");
|
||||
|
||||
let project = Project::open(&dir).unwrap();
|
||||
let scene_path = project.scenes_dir().join("demo.forge.ron");
|
||||
let document = project.load_scene(&scene_path).unwrap();
|
||||
assert!(
|
||||
!document.entities.is_empty(),
|
||||
"starter scene should contain entities"
|
||||
);
|
||||
assert!(
|
||||
!document.roots.is_empty(),
|
||||
"starter scene should have roots"
|
||||
);
|
||||
|
||||
let output = forge(&["validate", scene_path.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"scaffolded scene should validate\nstdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_refuses_a_non_empty_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("occupied");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("keep.txt"), "keep me").unwrap();
|
||||
|
||||
let output = forge(&["new", dir.to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 2);
|
||||
assert!(
|
||||
stderr(&output).contains("not empty"),
|
||||
"stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(!dir.join("Cargo.toml").exists());
|
||||
assert_eq!(fs::read_to_string(dir.join("keep.txt")).unwrap(), "keep me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_fills_an_existing_empty_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("already-there");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let output = forge(&["new", dir.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(dir.join("Cargo.toml").is_file());
|
||||
assert!(dir.join("scenes/demo.forge.ron").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_name_and_3d_template() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("space");
|
||||
let output = forge(&[
|
||||
"new",
|
||||
dir.to_str().unwrap(),
|
||||
"--name",
|
||||
"space-game",
|
||||
"--template",
|
||||
"3d",
|
||||
]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
|
||||
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
|
||||
assert!(
|
||||
manifest.contains("name = \"space-game\""),
|
||||
"manifest: {manifest}"
|
||||
);
|
||||
|
||||
let project = Project::open(&dir).unwrap();
|
||||
let document = project
|
||||
.load_scene(project.scenes_dir().join("demo.forge.ron"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
document.stats().meshes > 0,
|
||||
"the 3D starter scene should contain mesh entities"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_a_good_scene_and_emits_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_root, scene) = scaffold_2d(&tmp.path().join("good-project"));
|
||||
|
||||
let output = forge(&["validate", scene.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).contains("Summary:"));
|
||||
|
||||
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(exit_code(&output), 0);
|
||||
let value: Value =
|
||||
serde_json::from_str(&stdout(&output)).expect("validate --json must emit JSON");
|
||||
assert_eq!(value["valid"], true);
|
||||
assert_eq!(value["errors"], 0);
|
||||
assert!(value["warnings"].is_u64());
|
||||
assert!(value["issues"].is_array());
|
||||
for issue in value["issues"].as_array().unwrap() {
|
||||
for key in ["severity", "entity", "field", "message"] {
|
||||
assert!(issue.get(key).is_some(), "issue missing `{key}`: {issue}");
|
||||
}
|
||||
assert!(["error", "warning", "info"].contains(&issue["severity"].as_str().unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_reports_parse_errors_with_exit_code_two() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let broken = tmp.path().join("broken.forge.ron");
|
||||
fs::write(&broken, "this is definitely not RON").unwrap();
|
||||
|
||||
let output = forge(&["validate", broken.to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 2);
|
||||
assert!(
|
||||
stderr(&output).contains("parse"),
|
||||
"stderr should explain the parse error: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_reports_a_missing_asset_as_a_validation_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let document = sprite_scene("Missing asset", "textures/missing.png");
|
||||
let scene = save_scene(&project, "missing.forge.ron", &document);
|
||||
|
||||
let output = forge(&["validate", scene.to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 1);
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("error"), "stdout: {text}");
|
||||
assert!(text.contains("textures/missing.png"), "stdout: {text}");
|
||||
assert!(text.contains("Summary:"), "stdout: {text}");
|
||||
|
||||
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(exit_code(&output), 1);
|
||||
let value: Value = serde_json::from_str(&stdout(&output)).unwrap();
|
||||
assert_eq!(value["valid"], false);
|
||||
assert!(value["errors"].as_u64().unwrap() >= 1);
|
||||
let issues = value["issues"].as_array().unwrap();
|
||||
assert!(issues.iter().any(|issue| issue["message"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("textures/missing.png"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_uses_an_explicit_project_for_assets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The scene lives outside the project; `--project` must still resolve assets.
|
||||
let project = create_project(&tmp.path().join("project"));
|
||||
let outside = tmp.path().join("outside");
|
||||
fs::create_dir_all(&outside).unwrap();
|
||||
let document = sprite_scene("Outside", "textures/hero.png");
|
||||
fs::create_dir_all(project.assets_dir.join("textures")).unwrap();
|
||||
fs::write(project.assets_dir.join("textures/hero.png"), b"png").unwrap();
|
||||
// Serialise inside the project, then move the file out of it.
|
||||
let saved = save_scene(&project, "outside.forge.ron", &document);
|
||||
let scene = outside.join("outside.forge.ron");
|
||||
fs::copy(&saved, &scene).unwrap();
|
||||
fs::remove_file(&saved).unwrap();
|
||||
|
||||
let output = forge(&[
|
||||
"validate",
|
||||
scene.to_str().unwrap(),
|
||||
"--project",
|
||||
project.root.to_str().unwrap(),
|
||||
]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn export_bsn_to_stdout_and_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let document = sprite_scene("Export scene", "textures/player.png");
|
||||
let scene = save_scene(&project, "export.forge.ron", &document);
|
||||
|
||||
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let stdout_code = stdout(&output);
|
||||
assert!(
|
||||
stdout_code.starts_with("use bevy::prelude::*;"),
|
||||
"generated code must start with the Bevy prelude import:\n{stdout_code}"
|
||||
);
|
||||
assert!(stdout_code.ends_with('\n'));
|
||||
assert!(
|
||||
stdout_code.contains("bsn!") || stdout_code.contains("bsn_list!"),
|
||||
"expected BSN macro code:\n{stdout_code}"
|
||||
);
|
||||
|
||||
let target = tmp.path().join("generated/nested/scene.rs");
|
||||
let output = forge(&[
|
||||
"export",
|
||||
scene.to_str().unwrap(),
|
||||
"--format",
|
||||
"bsn",
|
||||
"-o",
|
||||
target.to_str().unwrap(),
|
||||
]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let written = fs::read_to_string(&target).expect("output file must exist");
|
||||
assert_eq!(written, stdout_code);
|
||||
assert!(
|
||||
target.parent().unwrap().is_dir(),
|
||||
"parent directories must be created"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_bsn_single_root_uses_the_bsn_macro() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let mut document = SceneDoc::new("Single root");
|
||||
let player = document.add_entity("Player", None);
|
||||
document.add_component(
|
||||
player,
|
||||
ComponentData::Sprite(SpriteData {
|
||||
image: Some("textures/player.png".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let scene = save_scene(&project, "single.forge.ron", &document);
|
||||
|
||||
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let code = stdout(&output);
|
||||
assert!(code.contains("bsn! {"), "code:\n{code}");
|
||||
assert!(!code.contains("bsn_list!"), "code:\n{code}");
|
||||
assert!(code.contains("-> impl Scene"), "code:\n{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rust_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let document = sprite_scene("Export scene", "textures/player.png");
|
||||
let scene = save_scene(&project, "export.forge.ron", &document);
|
||||
|
||||
let output = forge(&["export", scene.to_str().unwrap(), "--format", "rust"]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let code = stdout(&output);
|
||||
assert!(code.starts_with("use bevy::prelude::*;"), "code:\n{code}");
|
||||
assert!(
|
||||
code.contains("Commands"),
|
||||
"expected a Commands parameter:\n{code}"
|
||||
);
|
||||
assert!(
|
||||
code.contains("commands.spawn"),
|
||||
"expected imperative spawn code:\n{code}"
|
||||
);
|
||||
assert!(code.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rejects_an_invalid_function_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let document = sprite_scene("Export scene", "textures/player.png");
|
||||
let scene = save_scene(&project, "export.forge.ron", &document);
|
||||
|
||||
let output = forge(&[
|
||||
"export",
|
||||
scene.to_str().unwrap(),
|
||||
"--format",
|
||||
"bsn",
|
||||
"--function",
|
||||
"not a valid name",
|
||||
]);
|
||||
assert_eq!(exit_code(&output), 1);
|
||||
assert!(
|
||||
stderr(&output).contains("identifier"),
|
||||
"stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_reports_parse_errors_with_exit_code_two() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let broken = tmp.path().join("broken.forge.ron");
|
||||
fs::write(&broken, "not ron at all").unwrap();
|
||||
let target = tmp.path().join("never-written.rs");
|
||||
|
||||
let output = forge(&[
|
||||
"export",
|
||||
broken.to_str().unwrap(),
|
||||
"-o",
|
||||
target.to_str().unwrap(),
|
||||
]);
|
||||
assert_eq!(exit_code(&output), 2);
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"no output must be written on a parse error"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// info
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn info_json_has_the_documented_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
let document = sprite_scene("Info scene", "textures/hero.png");
|
||||
let scene = save_scene(&project, "info.forge.ron", &document);
|
||||
|
||||
let output = forge(&["info", scene.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let value: Value = serde_json::from_str(&stdout(&output)).expect("info --json must emit JSON");
|
||||
assert_eq!(value["name"], "Info scene");
|
||||
assert_eq!(value["entities"], 2);
|
||||
assert_eq!(value["roots"], 2);
|
||||
assert!(value["path"]
|
||||
.as_str()
|
||||
.is_some_and(|path| Path::new(path).is_absolute()));
|
||||
assert_eq!(value["components"]["sprite"], 1);
|
||||
assert_eq!(value["components"]["text2d"], 1);
|
||||
let references = value["asset_references"].as_array().unwrap();
|
||||
assert!(references
|
||||
.iter()
|
||||
.any(|reference| reference["path"] == "textures/hero.png"));
|
||||
assert!(value["validation"].is_object());
|
||||
// A missing texture is an error, but `info` itself always succeeds.
|
||||
assert_eq!(value["validation"]["valid"], false);
|
||||
|
||||
let output = forge(&["info", scene.to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 0);
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("Scene:"));
|
||||
assert!(text.contains("Entities: 2"));
|
||||
assert!(text.contains("Validation:"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scenes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn scenes_lists_absolute_paths_in_sorted_order() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = create_project(tmp.path());
|
||||
save_scene(&project, "beta.forge.ron", &SceneDoc::new("Beta"));
|
||||
save_scene(&project, "alpha.forge.ron", &SceneDoc::new("Alpha"));
|
||||
|
||||
let output = forge(&["scenes", project.root.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
let lines: Vec<String> = stdout(&output).lines().map(ToOwned::to_owned).collect();
|
||||
let expected: Vec<String> = project
|
||||
.list_scenes()
|
||||
.iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(lines, expected);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.iter().all(|line| Path::new(line).is_absolute()));
|
||||
assert!(lines[0].ends_with("alpha.forge.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenes_rejects_a_directory_that_is_not_a_bevy_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"not-bevy\"\nversion = \"0.1.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = forge(&["scenes", tmp.path().to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 2);
|
||||
assert!(
|
||||
stderr(&output).contains("not a Bevy project"),
|
||||
"stderr: {}",
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demo project integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn demo_project_scene_validates_and_exports() {
|
||||
let demo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project");
|
||||
let scene = demo.join("scenes/garden.forge.ron");
|
||||
assert!(
|
||||
scene.is_file(),
|
||||
"demo project scene is missing: {}",
|
||||
scene.display()
|
||||
);
|
||||
|
||||
let output = forge(&["validate", scene.to_str().unwrap()]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"demo garden scene should validate\nstdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
|
||||
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
|
||||
assert_eq!(
|
||||
exit_code(&output),
|
||||
0,
|
||||
"stdout: {}\nstderr: {}",
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
assert!(stdout(&output).starts_with("use bevy::prelude::*;"));
|
||||
|
||||
let output = forge(&["scenes", demo.to_str().unwrap()]);
|
||||
assert_eq!(exit_code(&output), 0);
|
||||
assert!(stdout(&output).contains("garden.forge.ron"));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "bevy_forge_core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Scene model, project handling and Bevy 0.20 BSN code generation for Bevy Forge"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
ron.workspace = true
|
||||
indexmap.workspace = true
|
||||
thiserror.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Asset discovery for the project's Bevy `assets/` folder.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Deepest directory nesting the scanner descends into, counting the assets
|
||||
/// directory itself as level 0. Anything deeper is ignored so that pathological
|
||||
/// or cyclic-looking trees cannot exhaust the stack.
|
||||
const MAX_SCAN_DEPTH: usize = 32;
|
||||
|
||||
/// Directory names that are never part of a Bevy asset tree but can be huge
|
||||
/// (or self-referential) when a project root is used as the assets directory.
|
||||
const SKIPPED_DIR_NAMES: &[&str] = &["target", "build", "node_modules"];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum AssetKind {
|
||||
Image,
|
||||
Mesh,
|
||||
Audio,
|
||||
Font,
|
||||
Scene,
|
||||
Shader,
|
||||
Material,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl AssetKind {
|
||||
pub fn from_path(path: &Path) -> Self {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" | "jpg" | "jpeg" | "webp" | "bmp" | "tga" | "ktx2" | "dds" => Self::Image,
|
||||
"gltf" | "glb" | "obj" | "fbx" => Self::Mesh,
|
||||
"ogg" | "wav" | "mp3" | "flac" => Self::Audio,
|
||||
"ttf" | "otf" => Self::Font,
|
||||
"scn" | "ron" => Self::Scene,
|
||||
"wgsl" | "wesl" => Self::Shader,
|
||||
"material" | "toml" | "material.ron" => Self::Material,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Image => "Images",
|
||||
Self::Mesh => "Meshes",
|
||||
Self::Audio => "Audio",
|
||||
Self::Font => "Fonts",
|
||||
Self::Scene => "Scenes",
|
||||
Self::Shader => "Shaders",
|
||||
Self::Material => "Materials",
|
||||
Self::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn all() -> [Self; 8] {
|
||||
[
|
||||
Self::Image,
|
||||
Self::Mesh,
|
||||
Self::Audio,
|
||||
Self::Font,
|
||||
Self::Scene,
|
||||
Self::Shader,
|
||||
Self::Material,
|
||||
Self::Other,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetEntry {
|
||||
/// Path relative to the Bevy `assets/` directory, using `/` separators.
|
||||
pub path: String,
|
||||
pub abs_path: PathBuf,
|
||||
pub kind: AssetKind,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
impl AssetEntry {
|
||||
pub fn file_name(&self) -> &str {
|
||||
self.path.rsplit('/').next().unwrap_or(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AssetIndex {
|
||||
pub entries: Vec<AssetEntry>,
|
||||
pub scanned_dir: PathBuf,
|
||||
/// Set when the assets directory could not be read.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl AssetIndex {
|
||||
/// Recursively scan `assets_dir`, skipping hidden files, symlinks and
|
||||
/// editor/build directories. Results are sorted by path.
|
||||
pub fn scan(assets_dir: &Path) -> Self {
|
||||
let mut index = Self {
|
||||
entries: Vec::new(),
|
||||
scanned_dir: assets_dir.to_path_buf(),
|
||||
error: None,
|
||||
};
|
||||
match std::fs::read_dir(assets_dir) {
|
||||
Ok(read_dir) => {
|
||||
let mut components = Vec::new();
|
||||
collect_files(read_dir, &mut components, 0, &mut index.entries);
|
||||
}
|
||||
Err(error) => {
|
||||
index.error = Some(format!(
|
||||
"cannot read assets directory `{}`: {error}",
|
||||
assets_dir.display()
|
||||
));
|
||||
return index;
|
||||
}
|
||||
}
|
||||
index.entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
// A lossy conversion of exotic non-UTF-8 names can theoretically map two
|
||||
// distinct files onto the same asset path; never expose duplicates.
|
||||
index.entries.dedup_by(|a, b| a.path == b.path);
|
||||
index
|
||||
}
|
||||
|
||||
/// Rescan the directory used by the last call to [`Self::scan`].
|
||||
pub fn refresh(&mut self) {
|
||||
let scanned_dir = self.scanned_dir.clone();
|
||||
*self = Self::scan(&scanned_dir);
|
||||
}
|
||||
|
||||
pub fn find(&self, path: &str) -> Option<&AssetEntry> {
|
||||
self.entries.iter().find(|entry| entry.path == path)
|
||||
}
|
||||
|
||||
pub fn by_kind(&self, kind: AssetKind) -> Vec<&AssetEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.kind == kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn images(&self) -> Vec<&AssetEntry> {
|
||||
self.by_kind(AssetKind::Image)
|
||||
}
|
||||
|
||||
/// Case-insensitive substring search on the asset path.
|
||||
pub fn search(&self, query: &str) -> Vec<&AssetEntry> {
|
||||
let query = query.trim().to_ascii_lowercase();
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|entry| query.is_empty() || entry.path.to_ascii_lowercase().contains(&query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one already-opened directory, appending regular files to `entries`.
|
||||
///
|
||||
/// `components` is the sequence of directory names between the scanned root and
|
||||
/// the directory being walked. Unreadable entries and subdirectories are skipped
|
||||
/// silently; only the root read error is surfaced by [`AssetIndex::scan`].
|
||||
fn collect_files(
|
||||
read_dir: std::fs::ReadDir,
|
||||
components: &mut Vec<String>,
|
||||
depth: usize,
|
||||
entries: &mut Vec<AssetEntry>,
|
||||
) {
|
||||
for item in read_dir {
|
||||
let entry = match item {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// `DirEntry::file_type` does not follow links, so this skips symlinks
|
||||
// to both files and directories without touching their targets.
|
||||
if file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
if depth >= MAX_SCAN_DEPTH || SKIPPED_DIR_NAMES.contains(&name.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
components.push(name.into_owned());
|
||||
if let Ok(child_read_dir) = std::fs::read_dir(entry.path()) {
|
||||
collect_files(child_read_dir, components, depth + 1, entries);
|
||||
}
|
||||
components.pop();
|
||||
} else if file_type.is_file() {
|
||||
let path = join_relative(components, &name);
|
||||
let size_bytes = entry.metadata().map(|metadata| metadata.len()).unwrap_or(0);
|
||||
entries.push(AssetEntry {
|
||||
path,
|
||||
abs_path: entry.path(),
|
||||
kind: AssetKind::from_path(Path::new(name.as_ref())),
|
||||
size_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Join directory components and a file name into a `/`-separated asset path.
|
||||
fn join_relative(components: &[String], file_name: &str) -> String {
|
||||
let mut path = String::with_capacity(
|
||||
file_name.len() + components.iter().map(|c| c.len() + 1).sum::<usize>(),
|
||||
);
|
||||
for component in components {
|
||||
path.push_str(component);
|
||||
path.push('/');
|
||||
}
|
||||
path.push_str(file_name);
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn touch(root: &Path, relative: &str, contents: &[u8]) -> PathBuf {
|
||||
let path = root.join(relative);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn asset_paths(index: &AssetIndex) -> Vec<&str> {
|
||||
index
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn search_paths<'a>(index: &'a AssetIndex, query: &str) -> Vec<&'a str> {
|
||||
index
|
||||
.search(query)
|
||||
.into_iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_common_extensions() {
|
||||
assert_eq!(AssetKind::from_path(Path::new("a.png")), AssetKind::Image);
|
||||
assert_eq!(AssetKind::from_path(Path::new("a.GLB")), AssetKind::Mesh);
|
||||
assert_eq!(AssetKind::from_path(Path::new("a.ogg")), AssetKind::Audio);
|
||||
assert_eq!(AssetKind::from_path(Path::new("a.ron")), AssetKind::Scene);
|
||||
assert_eq!(AssetKind::from_path(Path::new("a.xyz")), AssetKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_nested_project_tree_with_slash_separated_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
let hero = touch(&assets, "textures/hero.png", b"png-bytes");
|
||||
touch(&assets, "audio/loop.ogg", b"ogg-bytes!");
|
||||
touch(&assets, "models/robot.glb", b"glb");
|
||||
touch(&assets, ".git/config", b"[core]");
|
||||
touch(&assets, ".hidden.png", b"hidden");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&hero, assets.join("textures/hero-link.png")).unwrap();
|
||||
std::os::unix::fs::symlink(assets.join("textures"), assets.join("linked")).unwrap();
|
||||
}
|
||||
|
||||
let index = AssetIndex::scan(&assets);
|
||||
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(index.scanned_dir, assets);
|
||||
assert_eq!(
|
||||
asset_paths(&index),
|
||||
vec!["audio/loop.ogg", "models/robot.glb", "textures/hero.png"]
|
||||
);
|
||||
|
||||
let entry = index.find("textures/hero.png").unwrap();
|
||||
assert_eq!(entry.kind, AssetKind::Image);
|
||||
assert_eq!(entry.size_bytes, 9);
|
||||
assert_eq!(entry.abs_path, hero);
|
||||
assert_eq!(entry.file_name(), "hero.png");
|
||||
assert!(entry.abs_path.is_file());
|
||||
assert_eq!(index.find("audio/loop.ogg").unwrap().kind, AssetKind::Audio);
|
||||
assert_eq!(
|
||||
index.find("models/robot.glb").unwrap().kind,
|
||||
AssetKind::Mesh
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_entries_and_build_directories() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "textures/visible.png", b"x");
|
||||
touch(&assets, ".hidden.png", b"x");
|
||||
touch(&assets, ".cache/secret.png", b"x");
|
||||
touch(&assets, "target/debug/cache.bin", b"x");
|
||||
touch(&assets, "build/out.bin", b"x");
|
||||
touch(&assets, "node_modules/pkg/icon.png", b"x");
|
||||
|
||||
let index = AssetIndex::scan(&assets);
|
||||
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(asset_paths(&index), vec!["textures/visible.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_assets_directory_is_empty_with_error() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let missing = temp.path().join("does-not-exist");
|
||||
|
||||
let index = AssetIndex::scan(&missing);
|
||||
|
||||
assert!(index.is_empty());
|
||||
assert_eq!(index.scanned_dir, missing);
|
||||
let error = index.error.expect("a missing directory must set an error");
|
||||
assert!(
|
||||
error.contains("cannot read assets directory"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
assert!(
|
||||
error.contains("does-not-exist"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_sorted_by_path_and_scans_are_deterministic() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "z.png", b"z");
|
||||
touch(&assets, "A.PNG", b"a");
|
||||
touch(&assets, "m/z.png", b"z");
|
||||
touch(&assets, "m/a.png", b"a");
|
||||
touch(&assets, "b.txt", b"b");
|
||||
|
||||
let first = AssetIndex::scan(&assets);
|
||||
let second = AssetIndex::scan(&assets);
|
||||
|
||||
let paths = asset_paths(&first);
|
||||
assert_eq!(paths, vec!["A.PNG", "b.txt", "m/a.png", "m/z.png", "z.png"]);
|
||||
let mut sorted = paths.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(paths, sorted);
|
||||
assert_eq!(first.entries, second.entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_filters_case_insensitively_and_kind_helpers_work() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "textures/Hero.PNG", b"x");
|
||||
touch(&assets, "audio/Loop.OGG", b"x");
|
||||
touch(&assets, "fonts/ui.ttf", b"x");
|
||||
touch(&assets, "models/Robot.GLB", b"x");
|
||||
touch(&assets, "notes.txt", b"x");
|
||||
|
||||
let index = AssetIndex::scan(&assets);
|
||||
|
||||
assert_eq!(index.search("").len(), 5);
|
||||
assert_eq!(search_paths(&index, "hero"), vec!["textures/Hero.PNG"]);
|
||||
assert_eq!(search_paths(&index, "HERO"), vec!["textures/Hero.PNG"]);
|
||||
assert_eq!(search_paths(&index, "png"), vec!["textures/Hero.PNG"]);
|
||||
assert!(index.search("does-not-exist").is_empty());
|
||||
assert_eq!(index.by_kind(AssetKind::Image).len(), 1);
|
||||
assert_eq!(index.images().len(), 1);
|
||||
assert_eq!(index.by_kind(AssetKind::Audio).len(), 1);
|
||||
assert_eq!(index.by_kind(AssetKind::Font).len(), 1);
|
||||
assert_eq!(index.by_kind(AssetKind::Mesh).len(), 1);
|
||||
assert_eq!(index.by_kind(AssetKind::Other).len(), 1);
|
||||
assert_eq!(
|
||||
search_paths(&index, ""),
|
||||
vec![
|
||||
"audio/Loop.OGG",
|
||||
"fonts/ui.ttf",
|
||||
"models/Robot.GLB",
|
||||
"notes.txt",
|
||||
"textures/Hero.PNG",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_picks_up_added_and_removed_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
let hero = touch(&assets, "textures/hero.png", b"x");
|
||||
let mut index = AssetIndex::scan(&assets);
|
||||
assert_eq!(asset_paths(&index), vec!["textures/hero.png"]);
|
||||
|
||||
fs::remove_file(&hero).unwrap();
|
||||
touch(&assets, "sprites/new.png", b"x");
|
||||
index.refresh();
|
||||
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(index.scanned_dir, assets);
|
||||
assert_eq!(asset_paths(&index), vec!["sprites/new.png"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_reports_error_when_directory_disappears() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "textures/hero.png", b"x");
|
||||
let mut index = AssetIndex::scan(&assets);
|
||||
assert!(index.error.is_none());
|
||||
|
||||
fs::remove_dir_all(&assets).unwrap();
|
||||
index.refresh();
|
||||
|
||||
assert!(index.is_empty());
|
||||
assert!(index.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_directory_recursion_depth() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "shallow.png", b"x");
|
||||
let mut deep = assets.clone();
|
||||
for _ in 0..40 {
|
||||
deep.push("d");
|
||||
}
|
||||
fs::create_dir_all(&deep).unwrap();
|
||||
fs::write(deep.join("deep.png"), b"x").unwrap();
|
||||
|
||||
let index = AssetIndex::scan(&assets);
|
||||
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(asset_paths(&index), vec!["shallow.png"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_subdirectory_is_skipped_without_failing_the_scan() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let assets = temp.path().join("assets");
|
||||
touch(&assets, "visible.png", b"x");
|
||||
touch(&assets, "locked/secret.png", b"x");
|
||||
let locked = assets.join("locked");
|
||||
let mut permissions = fs::metadata(&locked).unwrap().permissions();
|
||||
permissions.set_mode(0o000);
|
||||
fs::set_permissions(&locked, permissions).unwrap();
|
||||
|
||||
let index = AssetIndex::scan(&assets);
|
||||
|
||||
let mut permissions = fs::metadata(&locked).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&locked, permissions).unwrap();
|
||||
|
||||
assert_eq!(index.error, None);
|
||||
assert!(index.find("visible.png").is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Shared code-generation options and dispatch.
|
||||
|
||||
use crate::error::CodegenError;
|
||||
use crate::scene::SceneDoc;
|
||||
|
||||
pub mod bsn;
|
||||
pub mod rust;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExportFormat {
|
||||
/// Bevy 0.20 BSN (`bsn!` / `bsn_list!`) Rust code.
|
||||
Bsn,
|
||||
/// Imperative `Commands::spawn` Rust code.
|
||||
Rust,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bsn => "BSN scene",
|
||||
Self::Rust => "Rust spawn code",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bsn => "Modern Bevy 0.20 scene notation, ready to paste into a system",
|
||||
Self::Rust => "Imperative Commands::spawn code for any Bevy 0.15+ project",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn extension(self) -> &'static str {
|
||||
"rs"
|
||||
}
|
||||
|
||||
pub fn generate(
|
||||
self,
|
||||
doc: &SceneDoc,
|
||||
options: &CodegenOptions,
|
||||
) -> Result<String, CodegenError> {
|
||||
match self {
|
||||
Self::Bsn => bsn::generate(doc, options),
|
||||
Self::Rust => rust::generate(doc, options),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CodegenOptions {
|
||||
/// Name of the generated function; must be a valid Rust identifier.
|
||||
pub function_name: String,
|
||||
/// Include a "generated by Bevy Forge" header with usage hints.
|
||||
pub include_header: bool,
|
||||
}
|
||||
|
||||
impl Default for CodegenOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
function_name: "scene".to_string(),
|
||||
include_header: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodegenOptions {
|
||||
pub fn with_function_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.function_name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn validate(&self) -> Result<(), CodegenError> {
|
||||
if !crate::components::is_rust_ident(&self.function_name) {
|
||||
return Err(CodegenError::InvalidIdentifier {
|
||||
what: "function name".into(),
|
||||
value: self.function_name.clone(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,553 @@
|
||||
//! Component data authored in the editor and emitted as Bevy 0.20 BSN components.
|
||||
|
||||
use crate::math::{Color, Vec2, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The built-in component types the inspector can add.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentKind {
|
||||
Sprite,
|
||||
Text2d,
|
||||
Camera2d,
|
||||
Camera3d,
|
||||
PointLight,
|
||||
DirectionalLight,
|
||||
Mesh3d,
|
||||
Visibility,
|
||||
}
|
||||
|
||||
impl ComponentKind {
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::Sprite,
|
||||
Self::Text2d,
|
||||
Self::Camera2d,
|
||||
Self::Camera3d,
|
||||
Self::PointLight,
|
||||
Self::DirectionalLight,
|
||||
Self::Mesh3d,
|
||||
Self::Visibility,
|
||||
];
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sprite => "Sprite",
|
||||
Self::Text2d => "Text2d",
|
||||
Self::Camera2d => "Camera2d",
|
||||
Self::Camera3d => "Camera3d",
|
||||
Self::PointLight => "PointLight",
|
||||
Self::DirectionalLight => "DirectionalLight",
|
||||
Self::Mesh3d => "Mesh3d",
|
||||
Self::Visibility => "Visibility",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sprite => "sprite",
|
||||
Self::Text2d => "text2d",
|
||||
Self::Camera2d => "camera2d",
|
||||
Self::Camera3d => "camera3d",
|
||||
Self::PointLight => "point_light",
|
||||
Self::DirectionalLight => "directional_light",
|
||||
Self::Mesh3d => "mesh3d",
|
||||
Self::Visibility => "visibility",
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line description used in the "Add component" menu.
|
||||
pub const fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sprite => "2D sprite with an optional texture",
|
||||
Self::Text2d => "World-space text",
|
||||
Self::Camera2d => "2D camera",
|
||||
Self::Camera3d => "3D perspective camera",
|
||||
Self::PointLight => "Point light with range",
|
||||
Self::DirectionalLight => "Directional (sun) light",
|
||||
Self::Mesh3d => "3D primitive or mesh asset with a material",
|
||||
Self::Visibility => "Inherited, visible or hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One component attached to an entity.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum ComponentData {
|
||||
Sprite(SpriteData),
|
||||
Text2d(Text2dData),
|
||||
Camera2d,
|
||||
Camera3d,
|
||||
PointLight(PointLightData),
|
||||
DirectionalLight(DirectionalLightData),
|
||||
Mesh3d(Mesh3dData),
|
||||
Visibility(VisibilityMode),
|
||||
Custom(CustomComponent),
|
||||
}
|
||||
|
||||
impl ComponentData {
|
||||
/// Stable identity used to prevent duplicate components of the same type.
|
||||
///
|
||||
/// Custom components are keyed by their full Rust type path, so a project can
|
||||
/// attach `my_game::Health` and `my_game::Mana` side by side.
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
Self::Sprite(_) => "sprite".into(),
|
||||
Self::Text2d(_) => "text2d".into(),
|
||||
Self::Camera2d => "camera2d".into(),
|
||||
Self::Camera3d => "camera3d".into(),
|
||||
Self::PointLight(_) => "point_light".into(),
|
||||
Self::DirectionalLight(_) => "directional_light".into(),
|
||||
Self::Mesh3d(_) => "mesh3d".into(),
|
||||
Self::Visibility(_) => "visibility".into(),
|
||||
Self::Custom(custom) => format!("custom:{}", custom.type_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Option<ComponentKind> {
|
||||
match self {
|
||||
Self::Sprite(_) => Some(ComponentKind::Sprite),
|
||||
Self::Text2d(_) => Some(ComponentKind::Text2d),
|
||||
Self::Camera2d => Some(ComponentKind::Camera2d),
|
||||
Self::Camera3d => Some(ComponentKind::Camera3d),
|
||||
Self::PointLight(_) => Some(ComponentKind::PointLight),
|
||||
Self::DirectionalLight(_) => Some(ComponentKind::DirectionalLight),
|
||||
Self::Mesh3d(_) => Some(ComponentKind::Mesh3d),
|
||||
Self::Visibility(_) => Some(ComponentKind::Visibility),
|
||||
Self::Custom(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Custom(custom) => custom.short_name().to_string(),
|
||||
other => other
|
||||
.kind()
|
||||
.map(|k| k.label().to_string())
|
||||
.unwrap_or_else(|| "Component".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default value for a built-in component.
|
||||
pub fn from_kind(kind: ComponentKind) -> Self {
|
||||
match kind {
|
||||
ComponentKind::Sprite => Self::Sprite(SpriteData::default()),
|
||||
ComponentKind::Text2d => Self::Text2d(Text2dData::default()),
|
||||
ComponentKind::Camera2d => Self::Camera2d,
|
||||
ComponentKind::Camera3d => Self::Camera3d,
|
||||
ComponentKind::PointLight => Self::PointLight(PointLightData::default()),
|
||||
ComponentKind::DirectionalLight => {
|
||||
Self::DirectionalLight(DirectionalLightData::default())
|
||||
}
|
||||
ComponentKind::Mesh3d => Self::Mesh3d(Mesh3dData::default()),
|
||||
ComponentKind::Visibility => Self::Visibility(VisibilityMode::Visible),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asset paths referenced by this component, with a human-readable field name.
|
||||
pub fn asset_references(&self) -> Vec<(&'static str, &str)> {
|
||||
match self {
|
||||
Self::Sprite(sprite) => sprite
|
||||
.image
|
||||
.as_deref()
|
||||
.map(|path| vec![("image", path)])
|
||||
.unwrap_or_default(),
|
||||
Self::Mesh3d(mesh) => match &mesh.mesh {
|
||||
MeshSource::Asset(path) => vec![("mesh", path.as_str())],
|
||||
MeshSource::Primitive(_) => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_builtin(&self) -> bool {
|
||||
!matches!(self, Self::Custom(_))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sprite
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SpriteData {
|
||||
/// Path relative to the Bevy `assets/` folder, e.g. `textures/player.png`.
|
||||
pub image: Option<String>,
|
||||
pub color: Color,
|
||||
/// `None` uses the texture's native size.
|
||||
pub custom_size: Option<Vec2>,
|
||||
pub flip_x: bool,
|
||||
pub flip_y: bool,
|
||||
}
|
||||
|
||||
impl Default for SpriteData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
image: None,
|
||||
color: Color::WHITE,
|
||||
custom_size: None,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Text2dData {
|
||||
pub text: String,
|
||||
pub font_size: f32,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
impl Default for Text2dData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text: "Text".into(),
|
||||
font_size: 32.0,
|
||||
color: Color::WHITE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PointLightData {
|
||||
pub color: Color,
|
||||
pub intensity: f32,
|
||||
pub range: f32,
|
||||
pub shadows: bool,
|
||||
}
|
||||
|
||||
impl Default for PointLightData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color: Color::srgba(1.0, 0.95, 0.9, 1.0),
|
||||
intensity: 800.0,
|
||||
range: 20.0,
|
||||
shadows: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DirectionalLightData {
|
||||
pub color: Color,
|
||||
pub illuminance: f32,
|
||||
pub shadows: bool,
|
||||
}
|
||||
|
||||
impl Default for DirectionalLightData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color: Color::WHITE,
|
||||
illuminance: 10_000.0,
|
||||
shadows: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3D meshes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mesh3dData {
|
||||
pub mesh: MeshSource,
|
||||
pub material: MaterialData,
|
||||
}
|
||||
|
||||
impl Default for Mesh3dData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { size: Vec3::ONE }),
|
||||
material: MaterialData::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "source", content = "value", rename_all = "snake_case")]
|
||||
pub enum MeshSource {
|
||||
/// A procedural Bevy primitive, emitted as `meshes.add(Cuboid::new(..))`.
|
||||
Primitive(PrimitiveShape),
|
||||
/// A mesh asset path such as `models/robot.gltf#Mesh0/Primitive0`.
|
||||
Asset(String),
|
||||
}
|
||||
|
||||
impl MeshSource {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Primitive(shape) => shape.label().to_string(),
|
||||
Self::Asset(path) => path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PrimitiveShape {
|
||||
Cuboid { size: Vec3 },
|
||||
Sphere { radius: f32 },
|
||||
Plane { size: Vec2 },
|
||||
Cylinder { radius: f32, height: f32 },
|
||||
Capsule { radius: f32, length: f32 },
|
||||
}
|
||||
|
||||
impl PrimitiveShape {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Cuboid { .. } => "Cube",
|
||||
Self::Sphere { .. } => "Sphere",
|
||||
Self::Plane { .. } => "Plane",
|
||||
Self::Cylinder { .. } => "Cylinder",
|
||||
Self::Capsule { .. } => "Capsule",
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate local-space bounds, used by the 3D preview and picking.
|
||||
pub fn half_extents(&self) -> Vec3 {
|
||||
match self {
|
||||
Self::Cuboid { size } => *size * 0.5,
|
||||
Self::Sphere { radius } => Vec3::splat(*radius),
|
||||
Self::Plane { size } => Vec3::new(size.x * 0.5, 0.01, size.y * 0.5),
|
||||
Self::Cylinder { radius, height } => Vec3::new(*radius, *height * 0.5, *radius),
|
||||
Self::Capsule { radius, length } => {
|
||||
Vec3::new(*radius, *length * 0.5 + *radius, *radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MaterialData {
|
||||
pub base_color: Color,
|
||||
pub metallic: f32,
|
||||
pub perceptual_roughness: f32,
|
||||
pub unlit: bool,
|
||||
}
|
||||
|
||||
impl Default for MaterialData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_color: Color::srgba(0.45, 0.62, 0.95, 1.0),
|
||||
metallic: 0.0,
|
||||
perceptual_roughness: 0.5,
|
||||
unlit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visibility & custom components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum VisibilityMode {
|
||||
Inherited,
|
||||
Visible,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl VisibilityMode {
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inherited => "Inherited",
|
||||
Self::Visible => "Visible",
|
||||
Self::Hidden => "Hidden",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape hatch for project-specific Bevy components.
|
||||
///
|
||||
/// Field values are raw Rust/BSN expressions (`10.0`, `Vec3::new(0.0, 1.0, 0.0)`,
|
||||
/// `Color::WHITE`, ...). The editor stores them verbatim and emits them into the
|
||||
/// generated struct literal, so the project's component type must be in scope.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CustomComponent {
|
||||
pub type_path: String,
|
||||
pub fields: Vec<CustomField>,
|
||||
}
|
||||
|
||||
impl Default for CustomComponent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
type_path: "MyComponent".into(),
|
||||
fields: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomComponent {
|
||||
pub fn short_name(&self) -> &str {
|
||||
self.type_path
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.unwrap_or(self.type_path.as_str())
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
is_rust_path(&self.type_path) && self.fields.iter().all(|field| is_rust_ident(&field.name))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CustomField {
|
||||
pub name: String,
|
||||
pub expr: String,
|
||||
}
|
||||
|
||||
/// Whether a string is a valid (possibly qualified) Rust type path.
|
||||
pub fn is_rust_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value
|
||||
.split("::")
|
||||
.all(|segment| is_rust_ident(segment.trim()))
|
||||
}
|
||||
|
||||
/// Whether a string is a reserved Rust keyword (including `_`), which cannot be
|
||||
/// used as an identifier even though it is lexically valid.
|
||||
pub fn is_rust_keyword(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"abstract"
|
||||
| "as"
|
||||
| "async"
|
||||
| "await"
|
||||
| "become"
|
||||
| "box"
|
||||
| "break"
|
||||
| "const"
|
||||
| "continue"
|
||||
| "crate"
|
||||
| "do"
|
||||
| "dyn"
|
||||
| "else"
|
||||
| "enum"
|
||||
| "extern"
|
||||
| "false"
|
||||
| "final"
|
||||
| "fn"
|
||||
| "for"
|
||||
| "gen"
|
||||
| "if"
|
||||
| "impl"
|
||||
| "in"
|
||||
| "let"
|
||||
| "loop"
|
||||
| "macro"
|
||||
| "match"
|
||||
| "mod"
|
||||
| "move"
|
||||
| "mut"
|
||||
| "override"
|
||||
| "priv"
|
||||
| "pub"
|
||||
| "ref"
|
||||
| "return"
|
||||
| "self"
|
||||
| "Self"
|
||||
| "static"
|
||||
| "struct"
|
||||
| "super"
|
||||
| "trait"
|
||||
| "true"
|
||||
| "try"
|
||||
| "type"
|
||||
| "typeof"
|
||||
| "unsafe"
|
||||
| "unsized"
|
||||
| "use"
|
||||
| "virtual"
|
||||
| "where"
|
||||
| "while"
|
||||
| "yield"
|
||||
| "_"
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a string is a valid Rust identifier (ASCII subset + keyword check;
|
||||
/// good enough for component/field names and generated identifiers).
|
||||
pub fn is_rust_ident(value: &str) -> bool {
|
||||
if value.is_empty() || is_rust_keyword(value) {
|
||||
return false;
|
||||
}
|
||||
let mut chars = value.chars();
|
||||
match chars.next() {
|
||||
Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn component_keys_are_stable_and_unique_per_custom_type() {
|
||||
assert_eq!(
|
||||
ComponentData::from_kind(ComponentKind::Sprite).key(),
|
||||
"sprite"
|
||||
);
|
||||
let a = ComponentData::Custom(CustomComponent {
|
||||
type_path: "game::Health".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
let b = ComponentData::Custom(CustomComponent {
|
||||
type_path: "game::Mana".into(),
|
||||
fields: vec![],
|
||||
});
|
||||
assert_ne!(a.key(), b.key());
|
||||
assert_eq!(a.label(), "Health");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_ident_validation() {
|
||||
assert!(is_rust_ident("Health"));
|
||||
assert!(is_rust_ident("_health2"));
|
||||
assert!(!is_rust_ident("2health"));
|
||||
assert!(!is_rust_ident("my health"));
|
||||
assert!(is_rust_path("game::components::Health"));
|
||||
assert!(!is_rust_path("game::"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_references_finds_sprite_image() {
|
||||
let component = ComponentData::Sprite(SpriteData {
|
||||
image: Some("textures/hero.png".into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
component.asset_references(),
|
||||
vec![("image", "textures/hero.png")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ron_round_trip_of_all_builtin_kinds() {
|
||||
for kind in ComponentKind::ALL {
|
||||
let original = ComponentData::from_kind(kind);
|
||||
let text = ron::ser::to_string(&original).unwrap();
|
||||
let parsed: ComponentData = ron::from_str(&text).unwrap();
|
||||
assert_eq!(original, parsed, "round-trip failed for {kind:?}: {text}");
|
||||
}
|
||||
let custom = ComponentData::Custom(CustomComponent {
|
||||
type_path: "game::Health".into(),
|
||||
fields: vec![CustomField {
|
||||
name: "current".into(),
|
||||
expr: "10.0".into(),
|
||||
}],
|
||||
});
|
||||
let text = ron::ser::to_string(&custom).unwrap();
|
||||
assert_eq!(custom, ron::from_str(&text).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Persistent editor preferences (recent projects, grid, autosave interval).
|
||||
|
||||
use crate::error::ConfigError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const CONFIG_FILE: &str = "config.ron";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EditorConfig {
|
||||
pub recent_projects: Vec<PathBuf>,
|
||||
pub last_project: Option<PathBuf>,
|
||||
pub show_grid: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub grid_size: f32,
|
||||
pub show_assets: bool,
|
||||
pub autosave_secs: u64,
|
||||
pub welcome_dismissed: bool,
|
||||
}
|
||||
|
||||
impl Default for EditorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
recent_projects: Vec::new(),
|
||||
last_project: None,
|
||||
show_grid: true,
|
||||
snap_to_grid: false,
|
||||
grid_size: 1.0,
|
||||
show_assets: true,
|
||||
autosave_secs: 60,
|
||||
welcome_dismissed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorConfig {
|
||||
/// Platform config directory, honouring `XDG_CONFIG_HOME` on Unix.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
if !dir.is_empty() {
|
||||
return Some(PathBuf::from(dir).join("bevy-forge"));
|
||||
}
|
||||
}
|
||||
std::env::var_os("HOME")
|
||||
.filter(|home| !home.is_empty())
|
||||
.map(|home| PathBuf::from(home).join(".config/bevy-forge"))
|
||||
}
|
||||
|
||||
pub fn config_path() -> Option<PathBuf> {
|
||||
Self::config_dir().map(|dir| dir.join(CONFIG_FILE))
|
||||
}
|
||||
|
||||
/// Load the config, returning defaults when the file is missing or unreadable.
|
||||
pub fn load_lossy() -> Self {
|
||||
Self::config_path()
|
||||
.and_then(|path| Self::load_from(&path).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
|
||||
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
ron::from_str(&text).map_err(|source| ConfigError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), ConfigError> {
|
||||
let Some(path) = Self::config_path() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.save_to(&path)
|
||||
}
|
||||
|
||||
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|source| ConfigError::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let pretty = ron::ser::PrettyConfig::default()
|
||||
.new_line("\n".to_string())
|
||||
.indentor(" ".to_string());
|
||||
let text = ron::ser::to_string_pretty(self, pretty)?;
|
||||
std::fs::write(path, text).map_err(|source| ConfigError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a project as most-recent, deduplicating and capping the list.
|
||||
pub fn push_recent(&mut self, project: &Path) {
|
||||
let project = project.to_path_buf();
|
||||
self.recent_projects.retain(|entry| entry != &project);
|
||||
self.recent_projects.insert(0, project.clone());
|
||||
self.recent_projects.truncate(10);
|
||||
self.last_project = Some(project);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("config.ron");
|
||||
let mut config = EditorConfig::default();
|
||||
config.push_recent(Path::new("/tmp/game-one"));
|
||||
config.push_recent(Path::new("/tmp/game-two"));
|
||||
config.push_recent(Path::new("/tmp/game-one"));
|
||||
assert_eq!(config.recent_projects.len(), 2);
|
||||
assert_eq!(config.recent_projects[0], PathBuf::from("/tmp/game-one"));
|
||||
config.save_to(&path).unwrap();
|
||||
let loaded = EditorConfig::load_from(&path).unwrap();
|
||||
assert_eq!(loaded, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_is_default() {
|
||||
assert!(EditorConfig::load_from(Path::new("/definitely/not/here.ron")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Error types shared by the core, CLI and editor.
|
||||
|
||||
use crate::scene::EntityId;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SceneError {
|
||||
#[error("entity {0} was not found")]
|
||||
EntityNotFound(EntityId),
|
||||
#[error("parent entity {0} was not found")]
|
||||
ParentNotFound(EntityId),
|
||||
#[error("entity {0} cannot be parented to itself")]
|
||||
SelfParent(EntityId),
|
||||
#[error("reparenting entity {entity} under {parent} would create a cycle")]
|
||||
Cycle { entity: EntityId, parent: EntityId },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ProjectError {
|
||||
#[error("not a Bevy project (Cargo.toml with a `bevy` dependency not found): {0}")]
|
||||
NotAProject(PathBuf),
|
||||
#[error("I/O error at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("could not parse Cargo.toml: {0}")]
|
||||
Manifest(#[from] toml::de::Error),
|
||||
#[error("could not parse scene {path}: {source}")]
|
||||
SceneParse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: ron::de::SpannedError,
|
||||
},
|
||||
#[error("could not serialize scene: {0}")]
|
||||
SceneSerialize(#[from] ron::Error),
|
||||
#[error("scene path {0} is not inside the project")]
|
||||
SceneOutsideProject(PathBuf),
|
||||
#[error("invalid scene file extension (expected .forge.ron): {0}")]
|
||||
InvalidSceneExtension(PathBuf),
|
||||
#[error("the target directory already exists and is not empty: {0}")]
|
||||
DirectoryNotEmpty(PathBuf),
|
||||
}
|
||||
|
||||
impl ProjectError {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodegenError {
|
||||
#[error("cannot generate code for a scene with no entities")]
|
||||
EmptyScene,
|
||||
#[error("non-finite {field} on entity {entity:?}: {value}")]
|
||||
NonFinite {
|
||||
entity: Option<EntityId>,
|
||||
field: String,
|
||||
value: f32,
|
||||
},
|
||||
#[error("invalid Rust identifier for {what}: {value:?}")]
|
||||
InvalidIdentifier { what: String, value: String },
|
||||
#[error("scene contains invalid custom component data: {0}")]
|
||||
InvalidCustomComponent(String),
|
||||
#[error("entity {entity} has more than one {key} component")]
|
||||
DuplicateComponent { entity: EntityId, key: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("I/O error at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("could not parse config {path}: {source}")]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: ron::de::SpannedError,
|
||||
},
|
||||
#[error("could not serialize config: {0}")]
|
||||
Serialize(#[from] ron::Error),
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Snapshot-based undo/redo and the editing session wrapper.
|
||||
|
||||
use crate::scene::SceneDoc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: SceneDoc,
|
||||
}
|
||||
|
||||
/// Bounded undo/redo stacks of full document snapshots.
|
||||
///
|
||||
/// Scene files are small (hundreds of entities at most), so snapshots are simpler
|
||||
/// and far less error-prone than per-operation inverse commands.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct History {
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
impl History {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
limit: limit.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&mut self, label: impl Into<String>, before: SceneDoc) {
|
||||
self.redo.clear();
|
||||
self.undo.push(Snapshot {
|
||||
label: label.into(),
|
||||
doc: before,
|
||||
});
|
||||
if self.undo.len() > self.limit {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, current: &mut SceneDoc) -> Option<String> {
|
||||
let snapshot = self.undo.pop()?;
|
||||
self.redo.push(Snapshot {
|
||||
label: snapshot.label.clone(),
|
||||
doc: current.clone(),
|
||||
});
|
||||
*current = snapshot.doc;
|
||||
Some(snapshot.label)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, current: &mut SceneDoc) -> Option<String> {
|
||||
let snapshot = self.redo.pop()?;
|
||||
self.undo.push(Snapshot {
|
||||
label: snapshot.label.clone(),
|
||||
doc: current.clone(),
|
||||
});
|
||||
*current = snapshot.doc;
|
||||
Some(snapshot.label)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
!self.undo.is_empty()
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
!self.redo.is_empty()
|
||||
}
|
||||
|
||||
pub fn undo_label(&self) -> Option<&str> {
|
||||
self.undo.last().map(|snapshot| snapshot.label.as_str())
|
||||
}
|
||||
|
||||
pub fn redo_label(&self) -> Option<&str> {
|
||||
self.redo.last().map(|snapshot| snapshot.label.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for History {
|
||||
fn default() -> Self {
|
||||
Self::new(100)
|
||||
}
|
||||
}
|
||||
|
||||
/// A scene document plus undo history and dirty tracking.
|
||||
///
|
||||
/// All undoable mutations should go through [`Session::edit`] (or
|
||||
/// [`Session::commit`] for continuous drags). [`Session::doc_mut`] is an escape
|
||||
/// hatch for read-modify-write UI code and marks the session dirty without adding
|
||||
/// an undo entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Session {
|
||||
doc: SceneDoc,
|
||||
history: History,
|
||||
dirty: bool,
|
||||
/// Snapshot of the last saved revision, used to clear the dirty flag when
|
||||
/// undo/redo brings the document back to exactly that state.
|
||||
saved: SceneDoc,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(doc: SceneDoc) -> Self {
|
||||
Self {
|
||||
saved: doc.clone(),
|
||||
doc,
|
||||
history: History::default(),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_history_limit(doc: SceneDoc, limit: usize) -> Self {
|
||||
Self {
|
||||
saved: doc.clone(),
|
||||
doc,
|
||||
history: History::new(limit),
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_dirty(&mut self) {
|
||||
self.dirty = self.doc != self.saved;
|
||||
}
|
||||
|
||||
pub fn doc(&self) -> &SceneDoc {
|
||||
&self.doc
|
||||
}
|
||||
|
||||
/// Mutable access without an undo snapshot. Prefer [`Session::edit`].
|
||||
pub fn doc_mut(&mut self) -> &mut SceneDoc {
|
||||
self.dirty = true;
|
||||
&mut self.doc
|
||||
}
|
||||
|
||||
/// Apply an edit, recording an undo snapshot when the document changed.
|
||||
pub fn edit<R>(&mut self, label: impl Into<String>, f: impl FnOnce(&mut SceneDoc) -> R) -> R {
|
||||
let before = self.doc.clone();
|
||||
let result = f(&mut self.doc);
|
||||
if self.doc != before {
|
||||
self.history.record(label, before);
|
||||
self.refresh_dirty();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Commit a pre-edit snapshot, for continuous interactions (gizmo drags) that
|
||||
/// update the document every frame and should produce one undo entry.
|
||||
pub fn commit(&mut self, before: SceneDoc, label: impl Into<String>) {
|
||||
if self.doc != before {
|
||||
self.history.record(label, before);
|
||||
self.refresh_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Option<String> {
|
||||
let label = self.history.undo(&mut self.doc)?;
|
||||
self.refresh_dirty();
|
||||
Some(label)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Option<String> {
|
||||
let label = self.history.redo(&mut self.doc)?;
|
||||
self.refresh_dirty();
|
||||
Some(label)
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.history.can_undo()
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.history.can_redo()
|
||||
}
|
||||
|
||||
pub fn undo_label(&self) -> Option<&str> {
|
||||
self.history.undo_label()
|
||||
}
|
||||
|
||||
pub fn redo_label(&self) -> Option<&str> {
|
||||
self.history.redo_label()
|
||||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.dirty
|
||||
}
|
||||
|
||||
pub fn mark_saved(&mut self) {
|
||||
self.saved = self.doc.clone();
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
/// Replace the document and clear history (opening a scene).
|
||||
pub fn replace(&mut self, doc: SceneDoc) {
|
||||
self.saved = doc.clone();
|
||||
self.doc = doc;
|
||||
self.history.clear();
|
||||
self.dirty = false;
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &History {
|
||||
&self.history
|
||||
}
|
||||
|
||||
pub fn history_mut(&mut self) -> &mut History {
|
||||
&mut self.history
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc_with_entity() -> SceneDoc {
|
||||
let mut doc = SceneDoc::new("Test");
|
||||
doc.add_entity("A", None);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_records_only_when_changed() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
session.edit("noop", |_| {});
|
||||
assert!(!session.can_undo());
|
||||
session.edit("add", |doc| {
|
||||
doc.add_entity("B", None);
|
||||
});
|
||||
assert!(session.can_undo());
|
||||
assert_eq!(session.undo_label(), Some("add"));
|
||||
assert!(session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_redo_round_trip() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
session.edit("add", |doc| {
|
||||
doc.add_entity("B", None);
|
||||
});
|
||||
let after_add = session.doc().clone();
|
||||
assert_eq!(session.undo().as_deref(), Some("add"));
|
||||
assert_eq!(session.doc().entities.len(), 1);
|
||||
assert_eq!(session.redo().as_deref(), Some("add"));
|
||||
assert_eq!(session.doc(), &after_add);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_clears_redo() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
session.edit("add B", |doc| {
|
||||
doc.add_entity("B", None);
|
||||
});
|
||||
session.undo();
|
||||
assert!(session.can_redo());
|
||||
session.edit("add C", |doc| {
|
||||
doc.add_entity("C", None);
|
||||
});
|
||||
assert!(!session.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_uses_external_snapshot() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
let before = session.doc().clone();
|
||||
session.doc_mut().add_entity("B", None);
|
||||
session.commit(before, "drag");
|
||||
assert_eq!(session.undo_label(), Some("drag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_clears_state() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
session.edit("add", |doc| {
|
||||
doc.add_entity("B", None);
|
||||
});
|
||||
session.replace(doc_with_entity());
|
||||
assert!(!session.can_undo());
|
||||
assert!(!session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_to_saved_revision_clears_dirty() {
|
||||
let mut session = Session::new(doc_with_entity());
|
||||
session.edit("add", |doc| {
|
||||
doc.add_entity("B", None);
|
||||
});
|
||||
session.mark_saved();
|
||||
session.edit("rename", |doc| {
|
||||
doc.rename(1, "Renamed");
|
||||
});
|
||||
assert!(session.is_dirty());
|
||||
session.undo();
|
||||
assert!(!session.is_dirty(), "undo to the saved revision is clean");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_limit_drops_oldest() {
|
||||
let mut history = History::new(2);
|
||||
for i in 0..4 {
|
||||
history.record(format!("edit {i}"), SceneDoc::new("x"));
|
||||
}
|
||||
assert_eq!(history.undo.len(), 2);
|
||||
assert_eq!(history.undo[0].label, "edit 2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! # Bevy Forge Core
|
||||
//!
|
||||
//! Scene model, project handling, validation and code generation for
|
||||
//! [Bevy Forge](https://github.com/bevy-forge/bevy-forge), a visual scene editor
|
||||
//! for the Bevy game engine (Bevy 0.20 BSN).
|
||||
//!
|
||||
//! Scene files use the `.forge.ron` extension and are human-readable RON. They are
|
||||
//! intentionally independent from Bevy's dynamic scene serialization: the editor
|
||||
//! exports BSN or imperative Rust code, which is compiled by the game.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use bevy_forge_core::codegen::{CodegenOptions, ExportFormat};
|
||||
//! use bevy_forge_core::samples;
|
||||
//!
|
||||
//! let doc = samples::starter_2d();
|
||||
//! let code = ExportFormat::Bsn.generate(&doc, &CodegenOptions::default()).unwrap();
|
||||
//! assert!(code.contains("bsn!"));
|
||||
//! ```
|
||||
|
||||
pub mod assets;
|
||||
pub mod codegen;
|
||||
pub mod components;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod history;
|
||||
pub mod math;
|
||||
pub mod project;
|
||||
pub mod samples;
|
||||
pub mod scene;
|
||||
pub mod validation;
|
||||
|
||||
pub use assets::{AssetEntry, AssetIndex, AssetKind};
|
||||
pub use codegen::{CodegenOptions, ExportFormat};
|
||||
pub use components::{
|
||||
ComponentData, ComponentKind, CustomComponent, CustomField, DirectionalLightData, MaterialData,
|
||||
Mesh3dData, MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData, VisibilityMode,
|
||||
};
|
||||
pub use config::EditorConfig;
|
||||
pub use error::{CodegenError, ConfigError, ProjectError, SceneError};
|
||||
pub use history::{History, Session, Snapshot};
|
||||
pub use math::{Bounds2, Color, Transform, Vec2, Vec3};
|
||||
pub use project::{Project, ProjectTemplate, SCENE_EXTENSION};
|
||||
pub use scene::{EntityId, EntityNode, SceneDoc, SceneStats, FORMAT_VERSION};
|
||||
pub use validation::{validate_scene, Issue, Severity, ValidationReport};
|
||||
@@ -0,0 +1,593 @@
|
||||
//! Small, dependency-free math types used by the scene model.
|
||||
//!
|
||||
//! Bevy Forge stores rotations as XYZ Euler angles in degrees because that is what
|
||||
//! humans edit in the inspector and what the viewport gizmos manipulate. Conversion
|
||||
//! helpers to quaternions are used for hierarchy composition and 3D preview.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::{Add, Div, Mul, Neg, Sub};
|
||||
|
||||
/// A two-dimensional vector in world units or pixels, depending on context.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec2 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Vec2 {
|
||||
pub const ZERO: Self = Self::new(0.0, 0.0);
|
||||
pub const ONE: Self = Self::new(1.0, 1.0);
|
||||
pub const X: Self = Self::new(1.0, 0.0);
|
||||
pub const Y: Self = Self::new(0.0, 1.0);
|
||||
|
||||
pub const fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
|
||||
pub fn splat(v: f32) -> Self {
|
||||
Self::new(v, v)
|
||||
}
|
||||
|
||||
pub fn length(self) -> f32 {
|
||||
self.length_squared().sqrt()
|
||||
}
|
||||
|
||||
pub fn length_squared(self) -> f32 {
|
||||
self.x * self.x + self.y * self.y
|
||||
}
|
||||
|
||||
pub fn distance(self, other: Self) -> f32 {
|
||||
(self - other).length()
|
||||
}
|
||||
|
||||
pub fn normalized_or_zero(self) -> Self {
|
||||
let len = self.length();
|
||||
if len > f32::EPSILON {
|
||||
self / len
|
||||
} else {
|
||||
Self::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dot(self, other: Self) -> f32 {
|
||||
self.x * other.x + self.y * other.y
|
||||
}
|
||||
|
||||
pub fn min(self, other: Self) -> Self {
|
||||
Self::new(self.x.min(other.x), self.y.min(other.y))
|
||||
}
|
||||
|
||||
pub fn max(self, other: Self) -> Self {
|
||||
Self::new(self.x.max(other.x), self.y.max(other.y))
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite()
|
||||
}
|
||||
|
||||
pub const fn to_array(self) -> [f32; 2] {
|
||||
[self.x, self.y]
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec2 {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self {
|
||||
Self::new(self.x + rhs.x, self.y + rhs.y)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec2 {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self {
|
||||
Self::new(self.x - rhs.x, self.y - rhs.y)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f32> for Vec2 {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: f32) -> Self {
|
||||
Self::new(self.x * rhs, self.y * rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f32> for Vec2 {
|
||||
type Output = Self;
|
||||
fn div(self, rhs: f32) -> Self {
|
||||
Self::new(self.x / rhs, self.y / rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec2 {
|
||||
type Output = Self;
|
||||
fn neg(self) -> Self {
|
||||
Self::new(-self.x, -self.y)
|
||||
}
|
||||
}
|
||||
|
||||
/// A three-dimensional vector.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vec3 {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub z: f32,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
pub const ZERO: Self = Self::new(0.0, 0.0, 0.0);
|
||||
pub const ONE: Self = Self::new(1.0, 1.0, 1.0);
|
||||
pub const X: Self = Self::new(1.0, 0.0, 0.0);
|
||||
pub const Y: Self = Self::new(0.0, 1.0, 0.0);
|
||||
pub const Z: Self = Self::new(0.0, 0.0, 1.0);
|
||||
|
||||
pub const fn new(x: f32, y: f32, z: f32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
pub fn splat(v: f32) -> Self {
|
||||
Self::new(v, v, v)
|
||||
}
|
||||
|
||||
pub fn length(self) -> f32 {
|
||||
self.length_squared().sqrt()
|
||||
}
|
||||
|
||||
pub fn length_squared(self) -> f32 {
|
||||
self.x * self.x + self.y * self.y + self.z * self.z
|
||||
}
|
||||
|
||||
pub fn distance(self, other: Self) -> f32 {
|
||||
(self - other).length()
|
||||
}
|
||||
|
||||
pub fn normalized_or_zero(self) -> Self {
|
||||
let len = self.length();
|
||||
if len > f32::EPSILON {
|
||||
self / len
|
||||
} else {
|
||||
Self::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dot(self, other: Self) -> f32 {
|
||||
self.x * other.x + self.y * other.y + self.z * other.z
|
||||
}
|
||||
|
||||
pub fn cross(self, other: Self) -> Self {
|
||||
Self::new(
|
||||
self.y * other.z - self.z * other.y,
|
||||
self.z * other.x - self.x * other.z,
|
||||
self.x * other.y - self.y * other.x,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
|
||||
}
|
||||
|
||||
pub const fn to_array(self) -> [f32; 3] {
|
||||
[self.x, self.y, self.z]
|
||||
}
|
||||
|
||||
/// XY part as a 2D vector (useful for picking sprites in the 3D view).
|
||||
pub const fn xy(self) -> Vec2 {
|
||||
Vec2::new(self.x, self.y)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Vec3 {
|
||||
type Output = Self;
|
||||
fn add(self, rhs: Self) -> Self {
|
||||
Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Vec3 {
|
||||
type Output = Self;
|
||||
fn sub(self, rhs: Self) -> Self {
|
||||
Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f32> for Vec3 {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: f32) -> Self {
|
||||
Self::new(self.x * rhs, self.y * rhs, self.z * rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Vec3> for Vec3 {
|
||||
type Output = Self;
|
||||
fn mul(self, rhs: Vec3) -> Self {
|
||||
Self::new(self.x * rhs.x, self.y * rhs.y, self.z * rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f32> for Vec3 {
|
||||
type Output = Self;
|
||||
fn div(self, rhs: f32) -> Self {
|
||||
Self::new(self.x / rhs, self.y / rhs, self.z / rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Vec3 {
|
||||
type Output = Self;
|
||||
fn neg(self) -> Self {
|
||||
Self::new(-self.x, -self.y, -self.z)
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear RGBA color with components in `0.0..=1.0`.
|
||||
///
|
||||
/// The editor exposes these as sRGB values; conversion functions are provided for
|
||||
/// hex display and for Bevy's `Color::srgba` in generated code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Color {
|
||||
pub r: f32,
|
||||
pub g: f32,
|
||||
pub b: f32,
|
||||
pub a: f32,
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
fn default() -> Self {
|
||||
Self::WHITE
|
||||
}
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const WHITE: Self = Self::srgba(1.0, 1.0, 1.0, 1.0);
|
||||
pub const BLACK: Self = Self::srgba(0.0, 0.0, 0.0, 1.0);
|
||||
pub const TRANSPARENT: Self = Self::srgba(0.0, 0.0, 0.0, 0.0);
|
||||
pub const RED: Self = Self::srgba(0.9, 0.25, 0.25, 1.0);
|
||||
pub const GREEN: Self = Self::srgba(0.35, 0.8, 0.45, 1.0);
|
||||
pub const BLUE: Self = Self::srgba(0.35, 0.55, 0.95, 1.0);
|
||||
|
||||
pub const fn srgba(r: f32, g: f32, b: f32, a: f32) -> Self {
|
||||
Self { r, g, b, a }
|
||||
}
|
||||
|
||||
pub const fn srgb(r: f32, g: f32, b: f32) -> Self {
|
||||
Self::srgba(r, g, b, 1.0)
|
||||
}
|
||||
|
||||
pub fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
|
||||
Self::srgba(
|
||||
r as f32 / 255.0,
|
||||
g as f32 / 255.0,
|
||||
b as f32 / 255.0,
|
||||
a as f32 / 255.0,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn to_rgba8(self) -> [u8; 4] {
|
||||
let channel = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
[
|
||||
channel(self.r),
|
||||
channel(self.g),
|
||||
channel(self.b),
|
||||
channel(self.a),
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse `#RRGGBB` or `#RRGGBBAA` (the leading `#` is optional).
|
||||
///
|
||||
/// Non-ASCII input returns `None`; never panics on arbitrary user text.
|
||||
pub fn from_hex(hex: &str) -> Option<Self> {
|
||||
let hex = hex.trim().trim_start_matches('#');
|
||||
if !hex.is_ascii() || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let parse = |start: usize| u8::from_str_radix(&hex[start..start + 2], 16).ok();
|
||||
match hex.len() {
|
||||
6 => Some(Self::from_rgba8(parse(0)?, parse(2)?, parse(4)?, 255)),
|
||||
8 => Some(Self::from_rgba8(parse(0)?, parse(2)?, parse(4)?, parse(6)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format as `#RRGGBB` (alpha is dropped).
|
||||
pub fn to_hex(self) -> String {
|
||||
let [r, g, b, _] = self.to_rgba8();
|
||||
format!("#{r:02X}{g:02X}{b:02X}")
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.r.is_finite() && self.g.is_finite() && self.b.is_finite() && self.a.is_finite()
|
||||
}
|
||||
|
||||
pub const fn to_array(self) -> [f32; 4] {
|
||||
[self.r, self.g, self.b, self.a]
|
||||
}
|
||||
}
|
||||
|
||||
/// A local transform, mirroring the fields of Bevy's `Transform`.
|
||||
///
|
||||
/// `rotation` is stored as XYZ Euler angles in degrees. Bevy itself stores a
|
||||
/// quaternion; the generated code converts back with
|
||||
/// `Quat::from_euler(EulerRot::XYZ, x, y, z)`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Transform {
|
||||
pub translation: Vec3,
|
||||
pub rotation: Vec3,
|
||||
pub scale: Vec3,
|
||||
}
|
||||
|
||||
impl Default for Transform {
|
||||
fn default() -> Self {
|
||||
Self::IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
pub const IDENTITY: Self = Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: Vec3::ZERO,
|
||||
scale: Vec3::ONE,
|
||||
};
|
||||
|
||||
pub const fn from_translation(translation: Vec3) -> Self {
|
||||
Self {
|
||||
translation,
|
||||
rotation: Vec3::ZERO,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn from_xyz(x: f32, y: f32, z: f32) -> Self {
|
||||
Self::from_translation(Vec3::new(x, y, z))
|
||||
}
|
||||
|
||||
pub const fn with_rotation(mut self, rotation: Vec3) -> Self {
|
||||
self.rotation = rotation;
|
||||
self
|
||||
}
|
||||
|
||||
pub const fn with_scale(mut self, scale: Vec3) -> Self {
|
||||
self.scale = scale;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.translation.is_finite() && self.rotation.is_finite() && self.scale.is_finite()
|
||||
}
|
||||
|
||||
pub fn is_identity(self) -> bool {
|
||||
self.translation == Vec3::ZERO && self.rotation == Vec3::ZERO && self.scale == Vec3::ONE
|
||||
}
|
||||
|
||||
/// Compose a parent transform with a child local transform.
|
||||
pub fn compose(parent: Self, child: Self) -> Self {
|
||||
let parent_q = quat_from_euler_deg(parent.rotation);
|
||||
let child_q = quat_from_euler_deg(child.rotation);
|
||||
let q = quat_mul(parent_q, child_q);
|
||||
Self {
|
||||
translation: parent.translation
|
||||
+ quat_rotate_vec3(parent_q, parent.scale * child.translation),
|
||||
rotation: quat_to_euler_deg(quat_normalize(q)),
|
||||
scale: parent.scale * child.scale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform a point by this transform.
|
||||
pub fn transform_point(self, point: Vec3) -> Vec3 {
|
||||
self.translation + quat_rotate_vec3(quat_from_euler_deg(self.rotation), self.scale * point)
|
||||
}
|
||||
|
||||
/// Transform a direction (ignores translation, applies scale and rotation).
|
||||
pub fn transform_direction(self, direction: Vec3) -> Vec3 {
|
||||
quat_rotate_vec3(quat_from_euler_deg(self.rotation), self.scale * direction)
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned 2D rectangle used for viewport fitting and picking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Bounds2 {
|
||||
pub min: Vec2,
|
||||
pub max: Vec2,
|
||||
}
|
||||
|
||||
impl Bounds2 {
|
||||
pub fn new(min: Vec2, max: Vec2) -> Self {
|
||||
Self {
|
||||
min: min.min(max),
|
||||
max: min.max(max),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_center_size(center: Vec2, size: Vec2) -> Self {
|
||||
let half = size * 0.5;
|
||||
Self::new(center - half, center + half)
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
min: Vec2::splat(f32::INFINITY),
|
||||
max: Vec2::splat(f32::NEG_INFINITY),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.min.x > self.max.x || self.min.y > self.max.y
|
||||
}
|
||||
|
||||
pub fn center(self) -> Vec2 {
|
||||
(self.min + self.max) * 0.5
|
||||
}
|
||||
|
||||
pub fn size(self) -> Vec2 {
|
||||
self.max - self.min
|
||||
}
|
||||
|
||||
pub fn contains(self, point: Vec2) -> bool {
|
||||
point.x >= self.min.x
|
||||
&& point.x <= self.max.x
|
||||
&& point.y >= self.min.y
|
||||
&& point.y <= self.max.y
|
||||
}
|
||||
|
||||
pub fn union(self, other: Self) -> Self {
|
||||
if self.is_empty() {
|
||||
return other;
|
||||
}
|
||||
if other.is_empty() {
|
||||
return self;
|
||||
}
|
||||
Self::new(self.min.min(other.min), self.max.max(other.max))
|
||||
}
|
||||
|
||||
pub fn expand(self, amount: f32) -> Self {
|
||||
Self::new(
|
||||
self.min - Vec2::splat(amount),
|
||||
self.max + Vec2::splat(amount),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn corners(self) -> [Vec2; 4] {
|
||||
[
|
||||
self.min,
|
||||
Vec2::new(self.max.x, self.min.y),
|
||||
self.max,
|
||||
Vec2::new(self.min.x, self.max.y),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Quaternion as `[x, y, z, w]`.
|
||||
pub type Quat = [f32; 4];
|
||||
|
||||
pub const QUAT_IDENTITY: Quat = [0.0, 0.0, 0.0, 1.0];
|
||||
|
||||
pub fn quat_from_euler_deg(euler: Vec3) -> Quat {
|
||||
let (x, y, z) = (
|
||||
euler.x.to_radians() * 0.5,
|
||||
euler.y.to_radians() * 0.5,
|
||||
euler.z.to_radians() * 0.5,
|
||||
);
|
||||
let (sx, cx) = x.sin_cos();
|
||||
let (sy, cy) = y.sin_cos();
|
||||
let (sz, cz) = z.sin_cos();
|
||||
// XYZ order, matching Bevy's `Quat::from_euler(EulerRot::XYZ, ..)`.
|
||||
[
|
||||
sx * cy * cz + cx * sy * sz,
|
||||
cx * sy * cz - sx * cy * sz,
|
||||
cx * cy * sz + sx * sy * cz,
|
||||
cx * cy * cz - sx * sy * sz,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn quat_mul(a: Quat, b: Quat) -> Quat {
|
||||
let [ax, ay, az, aw] = a;
|
||||
let [bx, by, bz, bw] = b;
|
||||
[
|
||||
aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn quat_normalize(q: Quat) -> Quat {
|
||||
let len = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
|
||||
if len > f32::EPSILON {
|
||||
[q[0] / len, q[1] / len, q[2] / len, q[3] / len]
|
||||
} else {
|
||||
QUAT_IDENTITY
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quat_rotate_vec3(q: Quat, v: Vec3) -> Vec3 {
|
||||
let [x, y, z, w] = quat_normalize(q);
|
||||
// q * v * q^-1, expanded.
|
||||
let uv = Vec3::new(y * v.z - z * v.y, z * v.x - x * v.z, x * v.y - y * v.x);
|
||||
let uuv = Vec3::new(
|
||||
y * uv.z - z * uv.y,
|
||||
z * uv.x - x * uv.z,
|
||||
x * uv.y - y * uv.x,
|
||||
);
|
||||
v + (uv * w + uuv) * 2.0
|
||||
}
|
||||
|
||||
pub fn quat_to_euler_deg(q: Quat) -> Vec3 {
|
||||
// Inverse of `quat_from_euler_deg` for intrinsic XYZ, matching glam's
|
||||
// `Quat::to_euler(EulerRot::XYZ)`. The rotation matrix is stored row-major;
|
||||
// glam indexes columns first, hence the transposed reads in the formulas.
|
||||
let [x, y, z, w] = quat_normalize(q);
|
||||
let m00 = 1.0 - 2.0 * (y * y + z * z);
|
||||
let m01 = 2.0 * (x * y - w * z);
|
||||
let m02 = 2.0 * (x * z + w * y);
|
||||
let m11 = 1.0 - 2.0 * (x * x + z * z);
|
||||
let m12 = 2.0 * (y * z - w * x);
|
||||
let m21 = 2.0 * (y * z + w * x);
|
||||
let m22 = 1.0 - 2.0 * (x * x + y * y);
|
||||
|
||||
let cy = (m00 * m00 + m01 * m01).sqrt();
|
||||
let (ex, ey, ez) = if cy > 16.0 * f32::EPSILON {
|
||||
(m12.atan2(m22), (-m02).atan2(cy), m01.atan2(m00))
|
||||
} else {
|
||||
// Gimbal lock: only x and z are coupled; keep z at zero.
|
||||
((-m21).atan2(m11), (-m02).atan2(cy), 0.0)
|
||||
};
|
||||
|
||||
Vec3::new((-ex).to_degrees(), (-ey).to_degrees(), (-ez).to_degrees())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn color_hex_round_trip() {
|
||||
let color = Color::from_hex("#ff8000").unwrap();
|
||||
assert_eq!(color.to_rgba8(), [255, 128, 0, 255]);
|
||||
assert_eq!(color.to_hex(), "#FF8000");
|
||||
assert_eq!(Color::from_hex("#FF800080").unwrap().a, 128.0 / 255.0);
|
||||
assert!(Color::from_hex("nope").is_none());
|
||||
assert!(Color::from_hex("aéabc").is_none());
|
||||
assert!(Color::from_hex("#123456").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_composition_is_neutral() {
|
||||
let t = Transform::from_xyz(1.0, 2.0, 3.0).with_rotation(Vec3::new(10.0, 20.0, 30.0));
|
||||
let composed = Transform::compose(Transform::IDENTITY, t);
|
||||
assert!((composed.translation - t.translation).length() < 1e-5);
|
||||
assert!((composed.rotation - t.rotation).length() < 1e-3);
|
||||
assert!((composed.scale - t.scale).length() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_translation_rotates_child_offset() {
|
||||
let parent = Transform::from_xyz(10.0, 0.0, 0.0).with_rotation(Vec3::new(0.0, 0.0, 90.0));
|
||||
let child = Transform::from_xyz(0.0, 2.0, 0.0);
|
||||
let world = Transform::compose(parent, child);
|
||||
assert!(
|
||||
(world.translation.x - 8.0).abs() < 1e-4,
|
||||
"{:?}",
|
||||
world.translation
|
||||
);
|
||||
assert!(
|
||||
(world.translation.y - 0.0).abs() < 1e-4,
|
||||
"{:?}",
|
||||
world.translation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quat_rotates_z_by_ninety_degrees() {
|
||||
let q = quat_from_euler_deg(Vec3::new(0.0, 0.0, 90.0));
|
||||
let v = quat_rotate_vec3(q, Vec3::new(1.0, 0.0, 0.0));
|
||||
assert!((v.x - 0.0).abs() < 1e-5);
|
||||
assert!((v.y - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_union_and_empty() {
|
||||
let a = Bounds2::from_center_size(Vec2::new(0.0, 0.0), Vec2::splat(2.0));
|
||||
let b = Bounds2::from_center_size(Vec2::new(5.0, 5.0), Vec2::splat(2.0));
|
||||
let u = a.union(b);
|
||||
assert_eq!(u.min, Vec2::new(-1.0, -1.0));
|
||||
assert_eq!(u.max, Vec2::new(6.0, 6.0));
|
||||
assert!(Bounds2::empty().union(a) == a);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Opening Bevy projects and reading/writing `.forge.ron` scene files.
|
||||
|
||||
use crate::error::ProjectError;
|
||||
use crate::scene::{SceneDoc, FORMAT_VERSION};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const SCENE_EXTENSION: &str = "forge.ron";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Project {
|
||||
pub root: PathBuf,
|
||||
pub name: String,
|
||||
pub assets_dir: PathBuf,
|
||||
pub bevy_version: Option<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Open a Bevy project rooted at `root`.
|
||||
///
|
||||
/// A valid project has a `Cargo.toml` with a `bevy` dependency. The assets
|
||||
/// directory defaults to `<root>/assets` when it exists, otherwise `<root>`.
|
||||
pub fn open(root: impl AsRef<Path>) -> Result<Self, ProjectError> {
|
||||
let root = root
|
||||
.as_ref()
|
||||
.canonicalize()
|
||||
.map_err(|error| ProjectError::io(root.as_ref().to_path_buf(), error))?;
|
||||
let manifest_path = root.join("Cargo.toml");
|
||||
let manifest_text = std::fs::read_to_string(&manifest_path)
|
||||
.map_err(|_| ProjectError::NotAProject(root.clone()))?;
|
||||
let manifest: toml::Value = toml::from_str(&manifest_text)?;
|
||||
let bevy_version = bevy_dependency_version(&manifest);
|
||||
if bevy_version.is_none() {
|
||||
return Err(ProjectError::NotAProject(root));
|
||||
}
|
||||
let name = manifest
|
||||
.get("package")
|
||||
.and_then(|package| package.get("name"))
|
||||
.and_then(|name| name.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
root.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("Bevy project")
|
||||
.to_string()
|
||||
});
|
||||
let assets_dir = if root.join("assets").is_dir() {
|
||||
root.join("assets")
|
||||
} else {
|
||||
root.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
root,
|
||||
name,
|
||||
assets_dir,
|
||||
bevy_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn scenes_dir(&self) -> PathBuf {
|
||||
self.root.join("scenes")
|
||||
}
|
||||
|
||||
pub fn forge_dir(&self) -> PathBuf {
|
||||
self.root.join(".forge")
|
||||
}
|
||||
|
||||
pub fn autosave_path(&self) -> PathBuf {
|
||||
self.forge_dir().join("autosave.forge.ron")
|
||||
}
|
||||
|
||||
/// Suggested path for a new scene with the given display name.
|
||||
pub fn scene_path(&self, name: &str) -> PathBuf {
|
||||
self.scenes_dir()
|
||||
.join(format!("{}.{SCENE_EXTENSION}", slugify(name)))
|
||||
}
|
||||
|
||||
pub fn load_scene(&self, path: impl AsRef<Path>) -> Result<SceneDoc, ProjectError> {
|
||||
let path = path.as_ref();
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|error| ProjectError::io(path.to_path_buf(), error))?;
|
||||
let mut doc: SceneDoc =
|
||||
ron::from_str(&text).map_err(|source| ProjectError::SceneParse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
// Be permissive with older files: normalise the format version but keep data.
|
||||
if doc.format_version == 0 {
|
||||
doc.format_version = FORMAT_VERSION;
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Save a scene into the project's `scenes/` directory.
|
||||
///
|
||||
/// Returns the path written. When `path` is `None`, the scene name is used.
|
||||
pub fn save_scene(&self, doc: &SceneDoc, path: Option<&Path>) -> Result<PathBuf, ProjectError> {
|
||||
let requested = match path {
|
||||
Some(path) if path.is_absolute() => path.to_path_buf(),
|
||||
Some(path) => self.root.join(path),
|
||||
None => self.scene_path(&doc.name),
|
||||
};
|
||||
let file_name = requested
|
||||
.file_name()
|
||||
.ok_or_else(|| ProjectError::SceneOutsideProject(requested.clone()))?
|
||||
.to_owned();
|
||||
if !file_name.to_string_lossy().ends_with(".forge.ron") {
|
||||
return Err(ProjectError::InvalidSceneExtension(requested));
|
||||
}
|
||||
|
||||
// Reject lexical escapes before creating any directories outside the
|
||||
// project. `Path::starts_with` alone accepts `root/../outside`.
|
||||
let lexical = normalize_path(&requested);
|
||||
if !lexical.starts_with(&self.root) {
|
||||
return Err(ProjectError::SceneOutsideProject(lexical));
|
||||
}
|
||||
|
||||
let parent = lexical.parent().unwrap_or(&self.root);
|
||||
// Check the nearest existing ancestor before creating directories, so a
|
||||
// symlinked parent cannot make us create directories outside the project
|
||||
// and only discover it afterwards.
|
||||
if let Some(existing) = nearest_existing_ancestor(parent) {
|
||||
let canonical = existing
|
||||
.canonicalize()
|
||||
.map_err(|error| ProjectError::io(existing.to_path_buf(), error))?;
|
||||
if !canonical.starts_with(&self.root) {
|
||||
return Err(ProjectError::SceneOutsideProject(lexical));
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| ProjectError::io(parent.to_path_buf(), error))?;
|
||||
// Resolve symlinks in the parent so an in-project symlink cannot redirect
|
||||
// the write outside the project.
|
||||
let canonical_parent = parent
|
||||
.canonicalize()
|
||||
.map_err(|error| ProjectError::io(parent.to_path_buf(), error))?;
|
||||
let path = canonical_parent.join(&file_name);
|
||||
if !path.starts_with(&self.root) {
|
||||
return Err(ProjectError::SceneOutsideProject(path));
|
||||
}
|
||||
|
||||
let pretty = ron::ser::PrettyConfig::default()
|
||||
.new_line("\n".to_string())
|
||||
.indentor(" ".to_string())
|
||||
.struct_names(false);
|
||||
let text = ron::ser::to_string_pretty(doc, pretty)?;
|
||||
atomic_write(&path, text.as_bytes())
|
||||
.map_err(|error| ProjectError::io(path.clone(), error))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Resolve an editor-authored asset path (relative to `assets/`) to a file.
|
||||
pub fn resolve_asset(&self, asset_path: &str) -> PathBuf {
|
||||
self.assets_dir.join(asset_path)
|
||||
}
|
||||
|
||||
/// Convert an absolute path inside the assets directory to a Bevy asset path.
|
||||
pub fn asset_path_for(&self, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(&self.assets_dir).ok()?;
|
||||
let parts: Vec<String> = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
/// All `.forge.ron` scenes directly inside `scenes/`.
|
||||
pub fn list_scenes(&self) -> Vec<PathBuf> {
|
||||
let mut scenes: Vec<PathBuf> = std::fs::read_dir(self.scenes_dir())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.extension().and_then(|ext| ext.to_str()) == Some("ron")
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".forge.ron"))
|
||||
})
|
||||
.collect();
|
||||
scenes.sort();
|
||||
scenes
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk up from `start` looking for a directory with a `Cargo.toml`.
|
||||
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
|
||||
let start = if start.is_file() {
|
||||
start.parent()?
|
||||
} else {
|
||||
start
|
||||
};
|
||||
let mut current = Some(start);
|
||||
while let Some(dir) = current {
|
||||
if dir.join("Cargo.toml").is_file() {
|
||||
return Some(dir.to_path_buf());
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the Bevy dependency version from a parsed Cargo.toml.
|
||||
pub fn bevy_dependency_version(manifest: &toml::Value) -> Option<String> {
|
||||
let candidates = [
|
||||
manifest
|
||||
.get("dependencies")
|
||||
.and_then(|deps| deps.get("bevy")),
|
||||
manifest
|
||||
.get("workspace")
|
||||
.and_then(|workspace| workspace.get("dependencies"))
|
||||
.and_then(|deps| deps.get("bevy")),
|
||||
manifest
|
||||
.get("dev-dependencies")
|
||||
.and_then(|deps| deps.get("bevy")),
|
||||
];
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
match candidate {
|
||||
toml::Value::String(version) => return Some(version.clone()),
|
||||
toml::Value::Table(table) => {
|
||||
if let Some(version) = table.get("version").and_then(|v| v.as_str()) {
|
||||
return Some(version.to_string());
|
||||
}
|
||||
if table.get("path").is_some() {
|
||||
return Some("path".to_string());
|
||||
}
|
||||
if table
|
||||
.get("workspace")
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("workspace".to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Sanitise a scene name into a portable file stem.
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
slug.push(c.to_ascii_lowercase());
|
||||
} else if c.is_whitespace() || matches!(c, '-' | '_' | '.') {
|
||||
if !slug.ends_with('-') && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
"scene".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
fn nearest_existing_ancestor(path: &Path) -> Option<&Path> {
|
||||
let mut current = Some(path);
|
||||
while let Some(candidate) = current {
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
current = candidate.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Lexically remove `.` and `..` components without touching the filesystem.
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
use std::path::Component;
|
||||
let mut out = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("scene.forge.ron");
|
||||
let temp = parent.join(format!(".{file_name}.tmp-{}", std::process::id()));
|
||||
std::fs::write(&temp, contents)?;
|
||||
std::fs::rename(&temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A template used by `forge new`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ProjectTemplate {
|
||||
#[default]
|
||||
Empty,
|
||||
TwoD,
|
||||
ThreeD,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write_project(dir: &Path, name: &str) -> Project {
|
||||
std::fs::write(
|
||||
dir.join("Cargo.toml"),
|
||||
format!(
|
||||
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(dir.join("assets/textures")).unwrap();
|
||||
Project::open(dir).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_bevy_project_and_reads_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = write_project(tmp.path(), "demo");
|
||||
assert_eq!(project.name, "demo");
|
||||
assert_eq!(project.bevy_version.as_deref(), Some("0.20"));
|
||||
assert_eq!(project.assets_dir, tmp.path().join("assets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_directory_without_bevy() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
|
||||
assert!(matches!(
|
||||
Project::open(tmp.path()),
|
||||
Err(ProjectError::NotAProject(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_save_and_load_round_trip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = write_project(tmp.path(), "demo");
|
||||
let mut doc = SceneDoc::new("Level 01");
|
||||
doc.add_entity("Player", None);
|
||||
let path = project.save_scene(&doc, None).unwrap();
|
||||
assert_eq!(path, project.scenes_dir().join("level-01.forge.ron"));
|
||||
let loaded = project.load_scene(&path).unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert_eq!(project.list_scenes(), vec![path]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_paths_are_relative_to_assets_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = write_project(tmp.path(), "demo");
|
||||
let abs = project.assets_dir.join("textures/hero.png");
|
||||
assert_eq!(
|
||||
project.asset_path_for(&abs).as_deref(),
|
||||
Some("textures/hero.png")
|
||||
);
|
||||
assert_eq!(project.resolve_asset("textures/hero.png"), abs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_is_safe() {
|
||||
assert_eq!(slugify("Level 01"), "level-01");
|
||||
assert_eq!(slugify("My Scene!!"), "my-scene");
|
||||
assert_eq!(slugify("***"), "scene");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_can_have_version_table() {
|
||||
let manifest: toml::Value =
|
||||
toml::from_str("[dependencies.bevy]\nversion = \"0.20\"\nfeatures = [\"2d\"]\n")
|
||||
.unwrap();
|
||||
assert_eq!(bevy_dependency_version(&manifest).as_deref(), Some("0.20"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,570 @@
|
||||
//! Scene document model and structural operations.
|
||||
//!
|
||||
//! A scene is a forest of entities. Every entity has a mandatory local transform
|
||||
//! and an ordered list of optional components. `entities` preserves insertion order
|
||||
//! for stable serialization; display order is defined by `roots` and each node's
|
||||
//! `children` list.
|
||||
|
||||
use crate::components::{ComponentData, VisibilityMode};
|
||||
use crate::error::SceneError;
|
||||
use crate::math::{Bounds2, Transform, Vec2};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Stable identifier of an entity inside a single scene file.
|
||||
pub type EntityId = u64;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EntityNode {
|
||||
pub id: EntityId,
|
||||
pub name: String,
|
||||
pub parent: Option<EntityId>,
|
||||
pub children: Vec<EntityId>,
|
||||
pub transform: Transform,
|
||||
pub components: Vec<ComponentData>,
|
||||
/// Editor-only lock: the entity cannot be transformed by viewport gizmos.
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl EntityNode {
|
||||
pub fn has_component(&self, key: &str) -> bool {
|
||||
self.components
|
||||
.iter()
|
||||
.any(|component| component.key() == key)
|
||||
}
|
||||
|
||||
pub fn component(&self, key: &str) -> Option<&ComponentData> {
|
||||
self.components
|
||||
.iter()
|
||||
.find(|component| component.key() == key)
|
||||
}
|
||||
|
||||
pub fn component_mut(&mut self, key: &str) -> Option<&mut ComponentData> {
|
||||
self.components
|
||||
.iter_mut()
|
||||
.find(|component| component.key() == key)
|
||||
}
|
||||
|
||||
pub fn visibility(&self) -> VisibilityMode {
|
||||
match self.component("visibility") {
|
||||
Some(ComponentData::Visibility(mode)) => *mode,
|
||||
_ => VisibilityMode::Inherited,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.visibility() == VisibilityMode::Hidden
|
||||
}
|
||||
|
||||
pub fn has_component_kind(&self, kind: crate::components::ComponentKind) -> bool {
|
||||
self.components
|
||||
.iter()
|
||||
.any(|component| component.kind() == Some(kind))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SceneStats {
|
||||
pub entities: usize,
|
||||
pub roots: usize,
|
||||
pub sprites: usize,
|
||||
pub meshes: usize,
|
||||
pub lights: usize,
|
||||
pub cameras: usize,
|
||||
pub custom_components: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SceneDoc {
|
||||
pub format_version: u32,
|
||||
pub name: String,
|
||||
pub entities: IndexMap<EntityId, EntityNode>,
|
||||
pub roots: Vec<EntityId>,
|
||||
pub next_id: EntityId,
|
||||
}
|
||||
|
||||
impl Default for SceneDoc {
|
||||
fn default() -> Self {
|
||||
Self::new("Untitled")
|
||||
}
|
||||
}
|
||||
|
||||
impl SceneDoc {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
format_version: FORMAT_VERSION,
|
||||
name: name.into(),
|
||||
entities: IndexMap::new(),
|
||||
roots: Vec::new(),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an empty entity (identity transform, no components) as a root or child.
|
||||
pub fn add_entity(&mut self, name: impl Into<String>, parent: Option<EntityId>) -> EntityId {
|
||||
let id = self.allocate_entity_id();
|
||||
let node = EntityNode {
|
||||
id,
|
||||
name: name.into(),
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
transform: Transform::IDENTITY,
|
||||
components: Vec::new(),
|
||||
locked: false,
|
||||
};
|
||||
self.insert_entity(node, parent, None)
|
||||
}
|
||||
|
||||
/// Insert a prepared node under `parent` at `index` (append when `None`).
|
||||
///
|
||||
/// The node's `id` must be unique and its `children` are preserved as-is; the
|
||||
/// caller is responsible for inserting descendants. `next_id` is advanced past
|
||||
/// the inserted id.
|
||||
pub fn insert_entity(
|
||||
&mut self,
|
||||
mut node: EntityNode,
|
||||
parent: Option<EntityId>,
|
||||
index: Option<usize>,
|
||||
) -> EntityId {
|
||||
let id = if self.entities.contains_key(&node.id) {
|
||||
// Hand-edited files and third-party callers may provide a duplicate
|
||||
// id; never overwrite an existing entity.
|
||||
self.allocate_entity_id()
|
||||
} else {
|
||||
node.id
|
||||
};
|
||||
node.id = id;
|
||||
// A missing parent is treated as a root insert instead of panicking; the
|
||||
// editor normally supplies valid parents, but public callers should not
|
||||
// be able to crash the process.
|
||||
let parent = parent.filter(|parent_id| self.entities.contains_key(parent_id));
|
||||
node.parent = parent;
|
||||
self.next_id = match id.checked_add(1) {
|
||||
Some(next) => self.next_id.max(next),
|
||||
None => self.next_id,
|
||||
};
|
||||
let siblings = match parent {
|
||||
Some(parent_id) => self
|
||||
.entity_mut(parent_id)
|
||||
.map(|parent_node| &mut parent_node.children)
|
||||
.expect("insert_entity: parent must exist"),
|
||||
None => &mut self.roots,
|
||||
};
|
||||
let index = index.unwrap_or(siblings.len()).min(siblings.len());
|
||||
siblings.insert(index, id);
|
||||
self.entities.insert(id, node);
|
||||
id
|
||||
}
|
||||
|
||||
/// Remove an entity and all descendants. Returns removed ids depth-first.
|
||||
pub fn remove_entity(&mut self, id: EntityId) -> Vec<EntityId> {
|
||||
let mut removed = Vec::new();
|
||||
self.collect_subtree_ids(id, &mut removed);
|
||||
if removed.is_empty() {
|
||||
return removed;
|
||||
}
|
||||
let parent = self.entity(id).and_then(|node| node.parent);
|
||||
match parent {
|
||||
Some(parent_id) => {
|
||||
if let Some(parent_node) = self.entity_mut(parent_id) {
|
||||
parent_node.children.retain(|child| *child != id);
|
||||
}
|
||||
}
|
||||
None => self.roots.retain(|root| *root != id),
|
||||
}
|
||||
for removed_id in &removed {
|
||||
self.entities.shift_remove(removed_id);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Deep-copy an entity (and its subtree), inserting the copy right after the
|
||||
/// original among its siblings. The copy's name is suffixed with `" Copy"`.
|
||||
pub fn duplicate_entity(&mut self, id: EntityId) -> Option<EntityId> {
|
||||
let original = self.entity(id)?.clone();
|
||||
let parent = original.parent;
|
||||
let sibling_index = self
|
||||
.siblings(parent)
|
||||
.iter()
|
||||
.position(|sibling| *sibling == id)
|
||||
.unwrap_or(0);
|
||||
let mut visited = HashSet::new();
|
||||
let new_id = self.clone_subtree(id, parent, sibling_index + 1, true, &mut visited)?;
|
||||
Some(new_id)
|
||||
}
|
||||
|
||||
/// Move `id` under `new_parent` (or to the roots) at `index`.
|
||||
///
|
||||
/// Fails when the entity would become its own ancestor, when either entity is
|
||||
/// missing, or when `new_parent` is the entity itself.
|
||||
pub fn reparent(
|
||||
&mut self,
|
||||
id: EntityId,
|
||||
new_parent: Option<EntityId>,
|
||||
index: Option<usize>,
|
||||
) -> Result<(), SceneError> {
|
||||
if self.entity(id).is_none() {
|
||||
return Err(SceneError::EntityNotFound(id));
|
||||
}
|
||||
if new_parent == Some(id) {
|
||||
return Err(SceneError::SelfParent(id));
|
||||
}
|
||||
if let Some(parent_id) = new_parent {
|
||||
if self.entity(parent_id).is_none() {
|
||||
return Err(SceneError::ParentNotFound(parent_id));
|
||||
}
|
||||
if self.is_ancestor(id, parent_id) {
|
||||
return Err(SceneError::Cycle {
|
||||
entity: id,
|
||||
parent: parent_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
let old_parent = self.entity(id).and_then(|node| node.parent);
|
||||
// Detach first so index calculations are relative to the destination list.
|
||||
match old_parent {
|
||||
Some(parent_id) => {
|
||||
if let Some(parent_node) = self.entity_mut(parent_id) {
|
||||
parent_node.children.retain(|child| *child != id);
|
||||
}
|
||||
}
|
||||
None => self.roots.retain(|root| *root != id),
|
||||
}
|
||||
if let Some(node) = self.entity_mut(id) {
|
||||
node.parent = new_parent;
|
||||
}
|
||||
let siblings = match new_parent {
|
||||
Some(parent_id) => self
|
||||
.entity_mut(parent_id)
|
||||
.map(|parent_node| &mut parent_node.children)
|
||||
.ok_or(SceneError::ParentNotFound(parent_id))?,
|
||||
None => &mut self.roots,
|
||||
};
|
||||
let index = index.unwrap_or(siblings.len()).min(siblings.len());
|
||||
siblings.insert(index, id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entity(&self, id: EntityId) -> Option<&EntityNode> {
|
||||
self.entities.get(&id)
|
||||
}
|
||||
|
||||
pub fn entity_mut(&mut self, id: EntityId) -> Option<&mut EntityNode> {
|
||||
self.entities.get_mut(&id)
|
||||
}
|
||||
|
||||
pub fn parent(&self, id: EntityId) -> Option<EntityId> {
|
||||
self.entity(id).and_then(|node| node.parent)
|
||||
}
|
||||
|
||||
/// Children of an entity, or the scene roots when `parent` is `None`.
|
||||
pub fn siblings(&self, parent: Option<EntityId>) -> &[EntityId] {
|
||||
match parent {
|
||||
Some(parent_id) => self
|
||||
.entity(parent_id)
|
||||
.map(|node| node.children.as_slice())
|
||||
.unwrap_or(&[]),
|
||||
None => &self.roots,
|
||||
}
|
||||
}
|
||||
|
||||
/// World transform of the entity's parent (identity for roots).
|
||||
pub fn parent_world_transform(&self, id: EntityId) -> Transform {
|
||||
let mut chain = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut current = self.entity(id).and_then(|node| node.parent);
|
||||
while let Some(parent_id) = current {
|
||||
if !seen.insert(parent_id) {
|
||||
break;
|
||||
}
|
||||
let Some(node) = self.entity(parent_id) else {
|
||||
break;
|
||||
};
|
||||
chain.push(node.transform);
|
||||
current = node.parent;
|
||||
}
|
||||
let mut world = Transform::IDENTITY;
|
||||
for transform in chain.into_iter().rev() {
|
||||
world = Transform::compose(world, transform);
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
/// World transform of an entity, or `None` when the id is unknown.
|
||||
pub fn world_transform(&self, id: EntityId) -> Option<Transform> {
|
||||
let local = self.entity(id)?.transform;
|
||||
Some(Transform::compose(self.parent_world_transform(id), local))
|
||||
}
|
||||
|
||||
/// True when `ancestor` is an ancestor of (or equal to) `descendant`.
|
||||
///
|
||||
/// Uses a visited set instead of a fixed depth cap, so arbitrarily deep valid
|
||||
/// hierarchies are handled and `reparent` cannot create a cycle at depth 257.
|
||||
pub fn is_ancestor(&self, ancestor: EntityId, descendant: EntityId) -> bool {
|
||||
if ancestor == descendant {
|
||||
return true;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut current = self.parent(descendant);
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
// Pre-existing cycle that does not contain `ancestor`.
|
||||
return false;
|
||||
}
|
||||
if id == ancestor {
|
||||
return true;
|
||||
}
|
||||
current = self.parent(id);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Entity ids in depth-first display order.
|
||||
pub fn depth_first_ids(&self) -> Vec<EntityId> {
|
||||
let mut out = Vec::with_capacity(self.entities.len());
|
||||
for root in &self.roots {
|
||||
self.collect_subtree_ids(*root, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn find_by_name(&self, name: &str) -> Option<EntityId> {
|
||||
self.entities
|
||||
.values()
|
||||
.find(|node| node.name == name)
|
||||
.map(|node| node.id)
|
||||
}
|
||||
|
||||
/// World-space bounds of the scene's 2D content, used by "fit view".
|
||||
///
|
||||
/// Sprites use their custom size or a 1x1 fallback; text uses an estimate based
|
||||
/// on glyph count. Entities without 2D content are ignored.
|
||||
pub fn bounds_2d(&self) -> Bounds2 {
|
||||
let mut bounds = Bounds2::empty();
|
||||
for node in self.entities.values() {
|
||||
let world = self.world_transform(node.id).unwrap_or(node.transform);
|
||||
let size = match node.component("sprite") {
|
||||
Some(ComponentData::Sprite(sprite)) => {
|
||||
Some(sprite.custom_size.unwrap_or_else(|| Vec2::new(1.0, 1.0)))
|
||||
}
|
||||
_ => match node.component("text2d") {
|
||||
Some(ComponentData::Text2d(text)) => {
|
||||
let width = (text.text.chars().count() as f32 * text.font_size * 0.55)
|
||||
.max(text.font_size);
|
||||
Some(Vec2::new(width, text.font_size * 1.4))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
if let Some(size) = size {
|
||||
let center = Vec2::new(world.translation.x, world.translation.y);
|
||||
let scaled = Vec2::new(size.x * world.scale.x, size.y * world.scale.y);
|
||||
bounds = bounds.union(Bounds2::from_center_size(center, scaled));
|
||||
}
|
||||
}
|
||||
if bounds.is_empty() {
|
||||
Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0))
|
||||
} else {
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a component, refusing duplicates of the same component key.
|
||||
pub fn add_component(&mut self, id: EntityId, component: ComponentData) -> bool {
|
||||
let Some(node) = self.entity_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
let key = component.key();
|
||||
if node.has_component(&key) {
|
||||
return false;
|
||||
}
|
||||
node.components.push(component);
|
||||
true
|
||||
}
|
||||
|
||||
/// Remove the component at `index`; returns it when it existed.
|
||||
pub fn remove_component(&mut self, id: EntityId, index: usize) -> Option<ComponentData> {
|
||||
let node = self.entity_mut(id)?;
|
||||
if index < node.components.len() {
|
||||
Some(node.components.remove(index))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the component with the same key, or append it when absent.
|
||||
pub fn set_component(&mut self, id: EntityId, component: ComponentData) -> bool {
|
||||
let Some(node) = self.entity_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
let key = component.key();
|
||||
if let Some(existing) = node
|
||||
.components
|
||||
.iter_mut()
|
||||
.find(|existing| existing.key() == key)
|
||||
{
|
||||
*existing = component;
|
||||
} else {
|
||||
node.components.push(component);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn rename(&mut self, id: EntityId, name: impl Into<String>) -> bool {
|
||||
match self.entity_mut(id) {
|
||||
Some(node) => {
|
||||
node.name = name.into();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle between hidden and visible (used by the hierarchy eye button).
|
||||
pub fn toggle_hidden(&mut self, id: EntityId) -> bool {
|
||||
let Some(node) = self.entity_mut(id) else {
|
||||
return false;
|
||||
};
|
||||
let hidden = matches!(
|
||||
node.component("visibility"),
|
||||
Some(ComponentData::Visibility(VisibilityMode::Hidden))
|
||||
);
|
||||
if hidden {
|
||||
node.components
|
||||
.retain(|component| component.key() != "visibility");
|
||||
} else {
|
||||
node.components
|
||||
.retain(|component| component.key() != "visibility");
|
||||
node.components
|
||||
.push(ComponentData::Visibility(VisibilityMode::Hidden));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> SceneStats {
|
||||
let mut stats = SceneStats {
|
||||
entities: self.entities.len(),
|
||||
roots: self.roots.len(),
|
||||
..Default::default()
|
||||
};
|
||||
for node in self.entities.values() {
|
||||
for component in &node.components {
|
||||
match component {
|
||||
ComponentData::Sprite(_) => stats.sprites += 1,
|
||||
ComponentData::Mesh3d(_) => stats.meshes += 1,
|
||||
ComponentData::PointLight(_) | ComponentData::DirectionalLight(_) => {
|
||||
stats.lights += 1
|
||||
}
|
||||
ComponentData::Camera2d | ComponentData::Camera3d => stats.cameras += 1,
|
||||
ComponentData::Custom(_) => stats.custom_components += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
/// Structural invariant check used by tests and by `forge validate`.
|
||||
pub fn check_integrity(&self) -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
for (id, node) in &self.entities {
|
||||
if node.id != *id {
|
||||
problems.push(format!("entity {id}: node id mismatch ({})", node.id));
|
||||
}
|
||||
if node.parent.is_some() && !self.entities.contains_key(&node.parent.unwrap()) {
|
||||
problems.push(format!("entity {id}: parent does not exist"));
|
||||
}
|
||||
for child in &node.children {
|
||||
let Some(child_node) = self.entities.get(child) else {
|
||||
problems.push(format!("entity {id}: child {child} does not exist"));
|
||||
continue;
|
||||
};
|
||||
if child_node.parent != Some(*id) {
|
||||
problems.push(format!("entity {id}: child {child} has wrong parent"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for root in &self.roots {
|
||||
match self.entities.get(root) {
|
||||
None => problems.push(format!("root {root} does not exist")),
|
||||
Some(node) if node.parent.is_some() => {
|
||||
problems.push(format!("root {root} has a parent"))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let referenced: usize = self.entities.values().map(|n| n.children.len()).sum();
|
||||
if referenced + self.roots.len() != self.entities.len() {
|
||||
problems.push("entity reference count does not match entity count".into());
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Allocate an unused id, repairing a corrupt `next_id` instead of reusing
|
||||
/// or overwriting an existing entity.
|
||||
fn allocate_entity_id(&mut self) -> EntityId {
|
||||
let start = self.next_id.max(1);
|
||||
let mut candidate = start;
|
||||
loop {
|
||||
if !self.entities.contains_key(&candidate) {
|
||||
self.next_id = candidate.checked_add(1).unwrap_or(candidate);
|
||||
return candidate;
|
||||
}
|
||||
candidate = match candidate.checked_add(1) {
|
||||
Some(next) => next,
|
||||
None => 1,
|
||||
};
|
||||
if candidate == start {
|
||||
panic!("scene has no free entity ids");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_subtree_ids(&self, id: EntityId, out: &mut Vec<EntityId>) {
|
||||
let mut stack = vec![id];
|
||||
let mut visited = HashSet::new();
|
||||
while let Some(current) = stack.pop() {
|
||||
if !visited.insert(current) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = self.entity(current) {
|
||||
out.push(current);
|
||||
for child in node.children.iter().rev() {
|
||||
stack.push(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_subtree(
|
||||
&mut self,
|
||||
source_id: EntityId,
|
||||
new_parent: Option<EntityId>,
|
||||
index: usize,
|
||||
rename: bool,
|
||||
visited: &mut HashSet<EntityId>,
|
||||
) -> Option<EntityId> {
|
||||
if !visited.insert(source_id) {
|
||||
return None;
|
||||
}
|
||||
let source = self.entity(source_id)?.clone();
|
||||
let new_id = self.allocate_entity_id();
|
||||
let mut copy = source.clone();
|
||||
copy.id = new_id;
|
||||
copy.parent = new_parent;
|
||||
copy.children.clear();
|
||||
if rename {
|
||||
copy.name = format!("{} Copy", source.name);
|
||||
}
|
||||
self.insert_entity(copy, new_parent, Some(index));
|
||||
for child in &source.children {
|
||||
// `insert_entity` links each copy into its parent's `children` list.
|
||||
self.clone_subtree(*child, Some(new_id), usize::MAX, false, visited)?;
|
||||
}
|
||||
Some(new_id)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,764 @@
|
||||
//! Public-API integration tests for both code generators (contracts §3).
|
||||
|
||||
mod common;
|
||||
|
||||
use bevy_forge_core::{
|
||||
components::{
|
||||
ComponentData, CustomComponent, CustomField, DirectionalLightData, MaterialData,
|
||||
Mesh3dData, MeshSource, PointLightData, PrimitiveShape, SpriteData, Text2dData,
|
||||
VisibilityMode,
|
||||
},
|
||||
math::{Color, Transform, Vec2, Vec3},
|
||||
samples, CodegenError, CodegenOptions, EntityId, ExportFormat, Project, SceneDoc,
|
||||
};
|
||||
|
||||
fn generate(doc: &SceneDoc, format: ExportFormat) -> String {
|
||||
format
|
||||
.generate(doc, &CodegenOptions::default())
|
||||
.unwrap_or_else(|error| panic!("{} generation failed: {error}", format.label()))
|
||||
}
|
||||
|
||||
fn options_without_header() -> CodegenOptions {
|
||||
CodegenOptions {
|
||||
include_header: false,
|
||||
..CodegenOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_with(component: ComponentData) -> (SceneDoc, EntityId) {
|
||||
let mut doc = SceneDoc::new("Codegen");
|
||||
let id = doc.add_entity("Entity", None);
|
||||
assert!(doc.add_component(id, component), "component was rejected");
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn string_component(text: &str) -> ComponentData {
|
||||
ComponentData::Text2d(Text2dData {
|
||||
text: text.to_string(),
|
||||
font_size: 24.0,
|
||||
color: Color::srgb(0.1, 0.2, 0.3),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_single_root_emits_the_expected_component_lines() {
|
||||
let mut doc = SceneDoc::new("Single");
|
||||
let player = doc.add_entity("Player", None);
|
||||
doc.entity_mut(player).unwrap().transform =
|
||||
Transform::from_xyz(1.0, 2.0, 3.0).with_rotation(Vec3::new(0.0, 0.0, 90.0));
|
||||
doc.add_component(
|
||||
player,
|
||||
common::sprite(Some("textures/player.png"), Some(Vec2::new(64.0, 32.0))),
|
||||
);
|
||||
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_generated_shape(&code, ExportFormat::Bsn);
|
||||
assert!(code.starts_with("// Generated by Bevy Forge."));
|
||||
assert!(code.contains("use bevy::prelude::*;\n\n"));
|
||||
assert!(code.contains("pub fn scene() -> impl Scene {\n"));
|
||||
common::assert_line(&code, " bsn! {");
|
||||
common::assert_line(&code, " Name(\"Player\")");
|
||||
common::assert_line(&code, " Transform {");
|
||||
common::assert_line(&code, " translation: Vec3::new(1.0, 2.0, 3.0),");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" rotation: Quat::from_euler(EulerRot::XYZ, 0.0f32.to_radians(), 0.0f32.to_radians(), 90.0f32.to_radians()),",
|
||||
);
|
||||
common::assert_line(&code, " scale: Vec3::new(1.0, 1.0, 1.0),");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Sprite { image: \"textures/player.png\", color: Color::srgba(1.0, 0.5, 0.25, 1.0), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: false, flip_y: false }",
|
||||
);
|
||||
common::assert_line(&code, " }");
|
||||
assert!(
|
||||
!code.contains("bsn_list"),
|
||||
"single root must not import bsn_list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_multiple_roots_use_bsn_list_and_impl_scene_list() {
|
||||
let mut doc = SceneDoc::new("Multi");
|
||||
let left = doc.add_entity("Left", None);
|
||||
doc.entity_mut(left).unwrap().transform = Transform::from_xyz(-2.0, 0.0, 0.0);
|
||||
let right = doc.add_entity("Right", None);
|
||||
doc.entity_mut(right).unwrap().transform = Transform::from_xyz(2.0, 0.0, 0.0);
|
||||
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_generated_shape(&code, ExportFormat::Bsn);
|
||||
assert!(code.contains("use bevy::prelude::*;\nuse bevy::scene::bsn_list;\n"));
|
||||
assert!(code.contains("pub fn scene() -> impl SceneList {"));
|
||||
common::assert_line(&code, " bsn_list! {");
|
||||
common::assert_line(&code, " --");
|
||||
common::assert_line(&code, " translation: Vec3::new(-2.0, 0.0, 0.0),");
|
||||
|
||||
let left_index = code.find("Name(\"Left\")").unwrap();
|
||||
let separator = code.find(" --\n").unwrap();
|
||||
let right_index = code.find("Name(\"Right\")").unwrap();
|
||||
assert!(left_index < separator && separator < right_index);
|
||||
assert!(!code.contains(" bsn! {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_nested_children_use_children_blocks_and_preserve_order() {
|
||||
let mut doc = SceneDoc::new("Nested");
|
||||
let parent = doc.add_entity("Parent", None);
|
||||
let first = doc.add_entity("Child A", Some(parent));
|
||||
let second = doc.add_entity("Child B", Some(parent));
|
||||
let grandchild = doc.add_entity("Grandchild", Some(second));
|
||||
doc.entity_mut(grandchild).unwrap().transform = Transform::from_xyz(0.0, 1.0, 0.0);
|
||||
doc.reparent(first, Some(parent), Some(1)).unwrap();
|
||||
assert_eq!(doc.entity(parent).unwrap().children, vec![second, first]);
|
||||
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_line(&code, " Children [");
|
||||
common::assert_line(&code, " Children [");
|
||||
common::assert_line(&code, " Name(\"Child B\")");
|
||||
common::assert_line(&code, " Name(\"Child A\")");
|
||||
common::assert_line(&code, " Name(\"Grandchild\")");
|
||||
assert_eq!(code.matches(" --").count(), 1);
|
||||
|
||||
let child_b = code.find("Name(\"Child B\")").unwrap();
|
||||
let grandchild_index = code.find("Name(\"Grandchild\")").unwrap();
|
||||
let child_a = code.find("Name(\"Child A\")").unwrap();
|
||||
assert!(child_b < grandchild_index && grandchild_index < child_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_emits_every_builtin_component_with_verified_syntax() {
|
||||
let mut doc = SceneDoc::new("All");
|
||||
let entity = doc.add_entity("Everything", None);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::Sprite(SpriteData {
|
||||
image: Some("textures/hero.png".into()),
|
||||
color: Color::srgba(0.5, 0.25, 0.125, 0.75),
|
||||
custom_size: Some(Vec2::new(64.0, 32.0)),
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
}),
|
||||
);
|
||||
doc.add_component(entity, string_component("Hello Forge"));
|
||||
doc.add_component(entity, ComponentData::Camera2d);
|
||||
doc.add_component(entity, ComponentData::Camera3d);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::PointLight(PointLightData {
|
||||
color: Color::srgb(1.0, 0.5, 0.2),
|
||||
intensity: -5.0,
|
||||
range: 20.0,
|
||||
shadows: true,
|
||||
}),
|
||||
);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::DirectionalLight(DirectionalLightData {
|
||||
color: Color::WHITE,
|
||||
illuminance: 10_000.0,
|
||||
shadows: false,
|
||||
}),
|
||||
);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Cuboid {
|
||||
size: Vec3::new(1.0, 2.0, 3.0),
|
||||
}),
|
||||
material: MaterialData::default(),
|
||||
}),
|
||||
);
|
||||
doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden));
|
||||
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Sprite { image: \"textures/hero.png\", color: Color::srgba(0.5, 0.25, 0.125, 0.75), custom_size: { Some(Vec2::new(64.0, 32.0)) }, flip_x: true, flip_y: false }",
|
||||
);
|
||||
common::assert_line(&code, " Text2d(\"Hello Forge\")");
|
||||
common::assert_line(&code, " TextFont { font_size: FontSize::Px(24.0) }");
|
||||
common::assert_line(&code, " TextColor(Color::srgba(0.1, 0.2, 0.3, 1.0))");
|
||||
common::assert_line(&code, " Camera2d");
|
||||
common::assert_line(&code, " Camera3d::default()");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" PointLight { color: Color::srgba(1.0, 0.5, 0.2, 1.0), intensity: { -5.0f32 }, range: 20.0, shadow_maps_enabled: true }",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: false }",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Mesh3d({ meshes.add(Cuboid::new(1.0, 2.0, 3.0)) })",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" MeshMaterial3d::<StandardMaterial>({ materials.add(StandardMaterial {",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" base_color: Color::srgba(0.45, 0.62, 0.95, 1.0),",
|
||||
);
|
||||
common::assert_line(&code, " metallic: 0.0,");
|
||||
common::assert_line(&code, " perceptual_roughness: 0.5,");
|
||||
common::assert_line(&code, " unlit: false,");
|
||||
common::assert_line(&code, " ..default()");
|
||||
common::assert_line(&code, " }) })");
|
||||
common::assert_line(&code, " Visibility::Hidden");
|
||||
assert!(code.contains(
|
||||
"pub fn scene(\n meshes: &mut Assets<Mesh>,\n materials: &mut Assets<StandardMaterial>,\n) -> impl Scene {"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_primitive_meshes_use_verified_constructors_and_required_params() {
|
||||
let cases = [
|
||||
(
|
||||
PrimitiveShape::Cuboid {
|
||||
size: Vec3::new(2.0, 3.0, 4.0),
|
||||
},
|
||||
"Mesh3d({ meshes.add(Cuboid::new(2.0, 3.0, 4.0)) })",
|
||||
),
|
||||
(
|
||||
PrimitiveShape::Sphere { radius: 1.5 },
|
||||
"Mesh3d({ meshes.add(Sphere::new(1.5)) })",
|
||||
),
|
||||
(
|
||||
PrimitiveShape::Plane {
|
||||
size: Vec2::new(10.0, 6.0),
|
||||
},
|
||||
"Mesh3d({ meshes.add(Plane3d::new(Vec3::Y, Vec2::new(5.0, 3.0))) })",
|
||||
),
|
||||
(
|
||||
PrimitiveShape::Cylinder {
|
||||
radius: 0.5,
|
||||
height: 2.0,
|
||||
},
|
||||
"Mesh3d({ meshes.add(Cylinder::new(0.5, 2.0)) })",
|
||||
),
|
||||
(
|
||||
PrimitiveShape::Capsule {
|
||||
radius: 0.25,
|
||||
length: 1.0,
|
||||
},
|
||||
"Mesh3d({ meshes.add(Capsule3d::new(0.25, 1.0)) })",
|
||||
),
|
||||
];
|
||||
for (shape, expected) in cases {
|
||||
let (doc, _) = doc_with(common::primitive_mesh(shape));
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_line(&code, &format!(" {expected}"));
|
||||
assert!(
|
||||
code.contains("meshes: &mut Assets<Mesh>"),
|
||||
"missing meshes param for {expected}"
|
||||
);
|
||||
assert!(code.contains("materials: &mut Assets<StandardMaterial>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_asset_meshes_use_path_handles_and_only_require_materials() {
|
||||
let (doc, _) = doc_with(ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Asset("models/robot.gltf#Mesh0/Primitive0".into()),
|
||||
material: MaterialData::default(),
|
||||
}));
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Mesh3d(\"models/robot.gltf#Mesh0/Primitive0\")",
|
||||
);
|
||||
assert!(code.contains("pub fn scene(materials: &mut Assets<StandardMaterial>) -> impl Scene {"));
|
||||
assert!(!code.contains("meshes: &mut Assets<Mesh>"));
|
||||
common::assert_line(
|
||||
&code,
|
||||
" MeshMaterial3d::<StandardMaterial>({ materials.add(StandardMaterial {",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_custom_components_brace_expression_forms() {
|
||||
let mut doc = SceneDoc::new("Custom");
|
||||
let health = doc.add_entity("Player", None);
|
||||
doc.add_component(
|
||||
health,
|
||||
common::custom("game::Health", &[("current", "10.0"), ("max", "Vec3::X")]),
|
||||
);
|
||||
let marker = doc.add_entity("Marker", None);
|
||||
doc.add_component(marker, common::custom("game::Marker", &[]));
|
||||
let extras = doc.add_entity("Extras", None);
|
||||
doc.add_component(
|
||||
extras,
|
||||
common::custom(
|
||||
"game::Extras",
|
||||
&[("icon", "Some(Vec2::new(1.0, 2.0))"), ("offset", "-5.0")],
|
||||
),
|
||||
);
|
||||
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
// Three roots -> list form; inspect the component lines only.
|
||||
common::assert_line(
|
||||
&code,
|
||||
" game::Health { current: 10.0, max: Vec3::X }",
|
||||
);
|
||||
common::assert_line(&code, " game::Marker");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" game::Extras { icon: { Some(Vec2::new(1.0, 2.0)) }, offset: { -5.0f32 } }",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bsn_omits_optional_sprite_fields_and_escapes_strings() {
|
||||
let (doc, _) = doc_with(common::sprite(None, None));
|
||||
let code = generate(&doc, ExportFormat::Bsn);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Sprite { color: Color::srgba(1.0, 0.5, 0.25, 1.0), flip_x: false, flip_y: false }",
|
||||
);
|
||||
assert!(!code.contains("image:"));
|
||||
assert!(!code.contains("custom_size:"));
|
||||
|
||||
let mut escaped = SceneDoc::new("Escaping");
|
||||
let entity = escaped.add_entity("Line \"one\"\\two\nthree\ttab ✓", None);
|
||||
escaped.add_component(entity, string_component("say \"hi\"\nback\\slash"));
|
||||
let code = generate(&escaped, ExportFormat::Bsn);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Name(\"Line \\\"one\\\"\\\\two\\nthree\\ttab ✓\")",
|
||||
);
|
||||
common::assert_line(&code, " Text2d(\"say \\\"hi\\\"\\nback\\\\slash\")");
|
||||
assert!(code.contains('✓'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_toggle_and_single_trailing_newline_are_stable() {
|
||||
let mut doc = SceneDoc::new("Header");
|
||||
doc.add_entity("Root", None);
|
||||
|
||||
let with_header = generate(&doc, ExportFormat::Bsn);
|
||||
assert!(with_header.starts_with("// Generated by Bevy Forge."));
|
||||
assert!(with_header.contains("\nuse bevy::prelude::*;\n"));
|
||||
assert!(with_header.ends_with('\n') && !with_header.ends_with("\n\n"));
|
||||
|
||||
let without_header = ExportFormat::Bsn
|
||||
.generate(&doc, &options_without_header())
|
||||
.unwrap();
|
||||
assert!(without_header.starts_with("use bevy::prelude::*;\n"));
|
||||
assert!(!without_header.contains("Generated by Bevy Forge"));
|
||||
assert!(without_header.ends_with('\n') && !without_header.ends_with("\n\n"));
|
||||
|
||||
let rust_with_header = generate(&doc, ExportFormat::Rust);
|
||||
assert!(rust_with_header.starts_with("// Generated by Bevy Forge."));
|
||||
assert!(rust_with_header.ends_with('\n') && !rust_with_header.ends_with("\n\n"));
|
||||
let rust_without_header = ExportFormat::Rust
|
||||
.generate(&doc, &options_without_header())
|
||||
.unwrap();
|
||||
assert!(rust_without_header.starts_with("use bevy::prelude::*;\n"));
|
||||
assert!(!rust_without_header.contains("Generated by Bevy Forge"));
|
||||
assert!(rust_without_header.ends_with('\n') && !rust_without_header.ends_with("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_reject_empty_and_rootless_scenes() {
|
||||
for format in [ExportFormat::Bsn, ExportFormat::Rust] {
|
||||
let empty = SceneDoc::new("Empty");
|
||||
assert!(
|
||||
matches!(
|
||||
format.generate(&empty, &CodegenOptions::default()),
|
||||
Err(CodegenError::EmptyScene)
|
||||
),
|
||||
"{} accepted an empty scene",
|
||||
format.label()
|
||||
);
|
||||
|
||||
let mut rootless = SceneDoc::new("Rootless");
|
||||
rootless.add_entity("Detached", None);
|
||||
rootless.roots.clear();
|
||||
assert!(
|
||||
matches!(
|
||||
format.generate(&rootless, &CodegenOptions::default()),
|
||||
Err(CodegenError::EmptyScene)
|
||||
),
|
||||
"{} accepted a rootless scene",
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_reject_invalid_function_names() {
|
||||
let mut doc = SceneDoc::new("Names");
|
||||
doc.add_entity("Root", None);
|
||||
|
||||
for name in ["", "my scene", "1scene", "scene-name", "fn"] {
|
||||
for format in [ExportFormat::Bsn, ExportFormat::Rust] {
|
||||
let options = CodegenOptions::default().with_function_name(name);
|
||||
match format.generate(&doc, &options) {
|
||||
Err(CodegenError::InvalidIdentifier { what, value }) => {
|
||||
assert_eq!(what, "function name", "{} for {name:?}", format.label());
|
||||
assert_eq!(value, name, "{} for {name:?}", format.label());
|
||||
}
|
||||
other => panic!(
|
||||
"{} should reject function name {name:?}, got {other:?}",
|
||||
format.label()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let valid = CodegenOptions::default().with_function_name("spawn_garden");
|
||||
assert!(ExportFormat::Bsn
|
||||
.generate(&doc, &valid)
|
||||
.unwrap()
|
||||
.contains("pub fn spawn_garden() -> impl Scene"));
|
||||
assert!(ExportFormat::Rust
|
||||
.generate(&doc, &valid)
|
||||
.unwrap()
|
||||
.contains("pub fn spawn_garden(commands: &mut Commands)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_reject_non_finite_values() {
|
||||
for format in [ExportFormat::Bsn, ExportFormat::Rust] {
|
||||
let mut doc = SceneDoc::new("NaN");
|
||||
let entity = doc.add_entity("Broken", None);
|
||||
doc.entity_mut(entity).unwrap().transform.translation.x = f32::NAN;
|
||||
match format.generate(&doc, &CodegenOptions::default()) {
|
||||
Err(CodegenError::NonFinite {
|
||||
entity: Some(id),
|
||||
field,
|
||||
value,
|
||||
}) => {
|
||||
assert_eq!(id, entity, "{}", format.label());
|
||||
assert_eq!(field, "transform.translation.x", "{}", format.label());
|
||||
assert!(value.is_nan());
|
||||
}
|
||||
other => panic!("{} should reject NaN transform: {other:?}", format.label()),
|
||||
}
|
||||
|
||||
let (material_doc, _) = doc_with(ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Sphere { radius: 1.0 }),
|
||||
material: MaterialData {
|
||||
base_color: Color::WHITE,
|
||||
metallic: f32::INFINITY,
|
||||
perceptual_roughness: 0.5,
|
||||
unlit: false,
|
||||
},
|
||||
}));
|
||||
assert!(
|
||||
matches!(
|
||||
format.generate(&material_doc, &CodegenOptions::default()),
|
||||
Err(CodegenError::NonFinite { value, .. }) if value.is_infinite()
|
||||
),
|
||||
"{} should reject an infinite material value",
|
||||
format.label()
|
||||
);
|
||||
|
||||
let light = ComponentData::PointLight(PointLightData {
|
||||
color: Color::WHITE,
|
||||
intensity: 1.0,
|
||||
range: f32::NAN,
|
||||
shadows: false,
|
||||
});
|
||||
let (light_doc, _) = doc_with(light);
|
||||
assert!(
|
||||
matches!(
|
||||
format.generate(&light_doc, &CodegenOptions::default()),
|
||||
Err(CodegenError::NonFinite { value, .. }) if value.is_nan()
|
||||
),
|
||||
"{} should reject a NaN light range",
|
||||
format.label()
|
||||
);
|
||||
|
||||
let (sprite_doc, _) = doc_with(common::sprite(None, Some(Vec2::new(f32::NAN, 1.0))));
|
||||
assert!(
|
||||
matches!(
|
||||
format.generate(&sprite_doc, &CodegenOptions::default()),
|
||||
Err(CodegenError::NonFinite { field, .. }) if field.contains("custom_size")
|
||||
),
|
||||
"{} should reject a NaN sprite size",
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_formats_reject_invalid_custom_components() {
|
||||
let invalid = [
|
||||
("invalid type path", common::custom("not a path", &[])),
|
||||
(
|
||||
"invalid field name",
|
||||
common::custom("game::Health", &[("1current", "10.0")]),
|
||||
),
|
||||
(
|
||||
"empty expression",
|
||||
common::custom("game::Health", &[("current", " ")]),
|
||||
),
|
||||
("empty type path", common::custom("", &[])),
|
||||
];
|
||||
|
||||
for (label, component) in invalid {
|
||||
for format in [ExportFormat::Bsn, ExportFormat::Rust] {
|
||||
let (doc, _) = doc_with(component.clone());
|
||||
match format.generate(&doc, &CodegenOptions::default()) {
|
||||
Err(CodegenError::InvalidCustomComponent(message)) => {
|
||||
assert!(!message.is_empty(), "{} {label}", format.label());
|
||||
}
|
||||
other => panic!("{} should reject {label}, got {other:?}", format.label()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_parameters_are_conditional_on_used_assets() {
|
||||
let mut plain = SceneDoc::new("Plain");
|
||||
plain.add_entity("Empty", None);
|
||||
let code = generate(&plain, ExportFormat::Rust);
|
||||
assert!(code.contains("pub fn scene(commands: &mut Commands) {"));
|
||||
assert!(!code.contains("asset_server"));
|
||||
assert!(!code.contains("Assets<Mesh>"));
|
||||
assert!(!code.contains("Assets<StandardMaterial>"));
|
||||
|
||||
let (sprite_with_image, _) = doc_with(common::sprite(Some("textures/hero.png"), None));
|
||||
let code = generate(&sprite_with_image, ExportFormat::Rust);
|
||||
assert!(code.contains(
|
||||
"pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n) {"
|
||||
));
|
||||
assert!(!code.contains("Assets<Mesh>"));
|
||||
|
||||
let (sprite_without_image, _) = doc_with(common::sprite(None, Some(Vec2::splat(8.0))));
|
||||
let code = generate(&sprite_without_image, ExportFormat::Rust);
|
||||
assert!(code.contains("pub fn scene(commands: &mut Commands) {"));
|
||||
assert!(!code.contains("asset_server"));
|
||||
|
||||
let (primitive, _) = doc_with(common::primitive_mesh(PrimitiveShape::Cuboid {
|
||||
size: Vec3::ONE,
|
||||
}));
|
||||
let code = generate(&primitive, ExportFormat::Rust);
|
||||
assert!(code.contains(
|
||||
"pub fn scene(\n commands: &mut Commands,\n meshes: &mut Assets<Mesh>,\n materials: &mut Assets<StandardMaterial>,\n) {"
|
||||
));
|
||||
assert!(!code.contains("asset_server"));
|
||||
|
||||
let (asset_mesh, _) = doc_with(ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Asset("models/robot.gltf".into()),
|
||||
material: MaterialData::default(),
|
||||
}));
|
||||
let code = generate(&asset_mesh, ExportFormat::Rust);
|
||||
assert!(code.contains(" commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets<Mesh>,\n materials: &mut Assets<StandardMaterial>,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_hierarchy_uses_with_children_in_depth_first_order() {
|
||||
let mut doc = SceneDoc::new("Tree");
|
||||
let root = doc.add_entity("Root", None);
|
||||
let child = doc.add_entity("Child", Some(root));
|
||||
doc.add_entity("Grandchild", Some(child));
|
||||
|
||||
let code = generate(&doc, ExportFormat::Rust);
|
||||
common::assert_generated_shape(&code, ExportFormat::Rust);
|
||||
assert_eq!(code.matches(".spawn((").count(), 3);
|
||||
assert_eq!(code.matches("commands.spawn((").count(), 1);
|
||||
assert_eq!(code.matches("parent.spawn((").count(), 2);
|
||||
assert_eq!(code.matches("with_children").count(), 2);
|
||||
assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n"));
|
||||
assert!(code.contains(" )).with_children(|parent| {\n parent.spawn((\n"));
|
||||
|
||||
let root_at = code.find("Name::new(\"Root\")").unwrap();
|
||||
let child_at = code.find("Name::new(\"Child\")").unwrap();
|
||||
let grandchild_at = code.find("Name::new(\"Grandchild\")").unwrap();
|
||||
assert!(root_at < child_at && child_at < grandchild_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_emits_all_components_with_paste_ready_syntax() {
|
||||
let mut doc = SceneDoc::new("All Rust");
|
||||
let entity = doc.add_entity("Everything", None);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::Sprite(SpriteData {
|
||||
image: Some("textures/hero.png".into()),
|
||||
color: Color::srgba(1.0, 0.5, 0.25, 0.8),
|
||||
custom_size: Some(Vec2::new(64.0, 32.0)),
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
}),
|
||||
);
|
||||
doc.add_component(entity, string_component("Hello Forge"));
|
||||
doc.add_component(entity, ComponentData::Camera2d);
|
||||
doc.add_component(entity, ComponentData::Camera3d);
|
||||
doc.add_component(entity, ComponentData::PointLight(PointLightData::default()));
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::DirectionalLight(DirectionalLightData::default()),
|
||||
);
|
||||
doc.add_component(entity, ComponentData::Mesh3d(Mesh3dData::default()));
|
||||
doc.add_component(entity, ComponentData::Visibility(VisibilityMode::Hidden));
|
||||
|
||||
let code = generate(&doc, ExportFormat::Rust);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Sprite { image: asset_server.load(\"textures/hero.png\"), color: Color::srgba(1.0, 0.5, 0.25, 0.8), custom_size: Some(Vec2::new(64.0, 32.0)), flip_x: true, flip_y: false, ..default() },",
|
||||
);
|
||||
common::assert_line(&code, " Text2d::new(\"Hello Forge\"),");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" TextFont { font_size: FontSize::Px(24.0), ..default() },",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" TextColor(Color::srgba(0.1, 0.2, 0.3, 1.0)),",
|
||||
);
|
||||
common::assert_line(&code, " Camera2d,");
|
||||
common::assert_line(&code, " Camera3d::default(),");
|
||||
common::assert_line(
|
||||
&code,
|
||||
" PointLight { color: Color::srgba(1.0, 0.95, 0.9, 1.0), intensity: 800.0, range: 20.0, shadow_maps_enabled: true, ..default() },",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" DirectionalLight { color: Color::srgba(1.0, 1.0, 1.0, 1.0), illuminance: 10000.0, shadow_maps_enabled: true, ..default() },",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),",
|
||||
);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgba(0.45, 0.62, 0.95, 1.0), metallic: 0.0, perceptual_roughness: 0.5, unlit: false, ..default() })),",
|
||||
);
|
||||
common::assert_line(&code, " Visibility::Hidden,");
|
||||
assert!(code.contains(
|
||||
"pub fn scene(\n commands: &mut Commands,\n asset_server: &AssetServer,\n meshes: &mut Assets<Mesh>,\n materials: &mut Assets<StandardMaterial>,\n) {"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_custom_components_use_struct_literals() {
|
||||
let mut doc = SceneDoc::new("Custom Rust");
|
||||
let entity = doc.add_entity("Player", None);
|
||||
doc.add_component(
|
||||
entity,
|
||||
ComponentData::Custom(CustomComponent {
|
||||
type_path: "my_game::Health".into(),
|
||||
fields: vec![
|
||||
CustomField {
|
||||
name: "current".into(),
|
||||
expr: " 10.0 ".into(),
|
||||
},
|
||||
CustomField {
|
||||
name: "max".into(),
|
||||
expr: "100.0".into(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let marker = doc.add_entity("Marker", None);
|
||||
doc.add_component(
|
||||
marker,
|
||||
ComponentData::Custom(CustomComponent {
|
||||
type_path: "my_game::Marker".into(),
|
||||
fields: Vec::new(),
|
||||
}),
|
||||
);
|
||||
|
||||
let code = generate(&doc, ExportFormat::Rust);
|
||||
common::assert_line(
|
||||
&code,
|
||||
" my_game::Health { current: 10.0, max: 100.0 },",
|
||||
);
|
||||
common::assert_line(&code, " my_game::Marker,");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_output_is_deterministic_and_header_optional() {
|
||||
let mut doc = SceneDoc::new("Rust Stable");
|
||||
let root = doc.add_entity("Root", None);
|
||||
let child = doc.add_entity("Child", Some(root));
|
||||
doc.add_component(
|
||||
child,
|
||||
common::sprite(Some("textures/hero.png"), Some(Vec2::splat(8.0))),
|
||||
);
|
||||
|
||||
let first = generate(&doc, ExportFormat::Rust);
|
||||
let second = generate(&doc, ExportFormat::Rust);
|
||||
assert_eq!(first, second);
|
||||
common::assert_generated_shape(&first, ExportFormat::Rust);
|
||||
assert!(first.contains("commands.spawn(("));
|
||||
assert!(first.contains("with_children"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_code_for_all_demo_and_starter_scenes_is_deterministic_and_non_empty() {
|
||||
let demo = common::demo_project_dir();
|
||||
let project = Project::open(&demo).expect("demo project opens");
|
||||
|
||||
let mut scenes = vec![
|
||||
("starter_2d", samples::starter_2d()),
|
||||
("starter_3d", samples::starter_3d()),
|
||||
("garden_demo", samples::garden_demo()),
|
||||
("platformer_demo", samples::platformer_demo()),
|
||||
];
|
||||
for (file, expected) in [
|
||||
("garden.forge.ron", samples::garden_demo()),
|
||||
("platformer.forge.ron", samples::platformer_demo()),
|
||||
] {
|
||||
let loaded = project
|
||||
.load_scene(project.scenes_dir().join(file))
|
||||
.expect("demo scene loads");
|
||||
assert_eq!(
|
||||
loaded, expected,
|
||||
"{file} must match its builder before codegen"
|
||||
);
|
||||
scenes.push((file, loaded));
|
||||
}
|
||||
|
||||
for (label, doc) in scenes {
|
||||
for format in [ExportFormat::Bsn, ExportFormat::Rust] {
|
||||
let first = format
|
||||
.generate(&doc, &CodegenOptions::default())
|
||||
.unwrap_or_else(|error| panic!("{label} {}: {error}", format.label()));
|
||||
let second = format
|
||||
.generate(&doc, &CodegenOptions::default())
|
||||
.unwrap_or_else(|error| panic!("{label} {} (second): {error}", format.label()));
|
||||
assert_eq!(
|
||||
first,
|
||||
second,
|
||||
"{label} {} is nondeterministic",
|
||||
format.label()
|
||||
);
|
||||
assert!(
|
||||
!first.trim().is_empty(),
|
||||
"{label} {} is empty",
|
||||
format.label()
|
||||
);
|
||||
let name_marker = match format {
|
||||
ExportFormat::Bsn => "Name(",
|
||||
ExportFormat::Rust => "Name::new(",
|
||||
};
|
||||
assert!(
|
||||
first.contains(name_marker),
|
||||
"{label} {} lost names",
|
||||
format.label()
|
||||
);
|
||||
common::assert_generated_shape(&first, format);
|
||||
|
||||
if format == ExportFormat::Bsn {
|
||||
assert!(
|
||||
first.contains("bsn! {") || first.contains("bsn_list! {"),
|
||||
"{label} BSN lacks a macro invocation"
|
||||
);
|
||||
assert!(
|
||||
first.contains("impl Scene") || first.contains("impl SceneList"),
|
||||
"{label} BSN lacks a scene return type"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
first.contains("commands.spawn(("),
|
||||
"{label} Rust lost spawns"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Shared helpers for the public-API integration tests.
|
||||
//!
|
||||
//! Everything here goes through `bevy_forge_core`'s public API; no private module
|
||||
//! items are used. The module is compiled into each integration-test binary, so
|
||||
//! helpers only used by some binaries are allowed to be dead there.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy_forge_core::components::{ComponentData, CustomComponent, CustomField, PrimitiveShape};
|
||||
use bevy_forge_core::math::{Vec2, Vec3};
|
||||
use bevy_forge_core::project::Project;
|
||||
use bevy_forge_core::ProjectError;
|
||||
|
||||
/// Repository `examples/demo-project` directory (scene + asset fixture).
|
||||
pub fn demo_project_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../examples/demo-project")
|
||||
.canonicalize()
|
||||
.expect("examples/demo-project must exist")
|
||||
}
|
||||
|
||||
pub fn demo_assets_dir() -> PathBuf {
|
||||
demo_project_dir().join("assets")
|
||||
}
|
||||
|
||||
/// Write a `Cargo.toml` in `root`.
|
||||
pub fn write_manifest(root: &Path, contents: &str) {
|
||||
std::fs::write(root.join("Cargo.toml"), contents).expect("write Cargo.toml");
|
||||
}
|
||||
|
||||
/// Standard manifest declaring `bevy = "0.20"`.
|
||||
pub fn bevy_manifest(name: &str) -> String {
|
||||
format!(
|
||||
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a minimal valid project at `root` and open it.
|
||||
pub fn create_project(root: &Path, name: &str) -> Project {
|
||||
write_manifest(root, &bevy_manifest(name));
|
||||
Project::open(root).expect("valid minimal Bevy project")
|
||||
}
|
||||
|
||||
/// A temporary directory plus an opened project rooted in it.
|
||||
pub fn temp_project(name: &str) -> (tempfile::TempDir, Project) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let project = create_project(dir.path(), name);
|
||||
(dir, project)
|
||||
}
|
||||
|
||||
/// `Some` image / optional custom size sprite.
|
||||
pub fn sprite(image: Option<&str>, custom_size: Option<Vec2>) -> ComponentData {
|
||||
ComponentData::Sprite(bevy_forge_core::SpriteData {
|
||||
image: image.map(str::to_string),
|
||||
color: bevy_forge_core::Color::srgba(1.0, 0.5, 0.25, 1.0),
|
||||
custom_size,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A custom component built from raw field expressions.
|
||||
pub fn custom(type_path: &str, fields: &[(&str, &str)]) -> ComponentData {
|
||||
ComponentData::Custom(CustomComponent {
|
||||
type_path: type_path.to_string(),
|
||||
fields: fields
|
||||
.iter()
|
||||
.map(|(name, expr)| CustomField {
|
||||
name: name.to_string(),
|
||||
expr: expr.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A primitive mesh component with the default material.
|
||||
pub fn primitive_mesh(shape: PrimitiveShape) -> ComponentData {
|
||||
ComponentData::Mesh3d(bevy_forge_core::Mesh3dData {
|
||||
mesh: bevy_forge_core::MeshSource::Primitive(shape),
|
||||
material: bevy_forge_core::MaterialData::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn approx_vec2(actual: Vec2, expected: Vec2) -> bool {
|
||||
(actual - expected).length() < 1e-4
|
||||
}
|
||||
|
||||
pub fn approx_vec3(actual: Vec3, expected: Vec3) -> bool {
|
||||
(actual - expected).length() < 1e-4
|
||||
}
|
||||
|
||||
pub fn approx_f32(actual: f32, expected: f32) -> bool {
|
||||
(actual - expected).abs() < 1e-4
|
||||
}
|
||||
|
||||
pub fn assert_line(code: &str, expected: &str) {
|
||||
assert!(
|
||||
code.lines().any(|line| line == expected),
|
||||
"missing exact line {expected:?}\n--- generated code ---\n{code}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Assert a generated file contains the prelude import and ends with exactly one
|
||||
/// newline (never a blank line). The attribution header, when enabled, comes first.
|
||||
pub fn assert_generated_shape(code: &str, format: bevy_forge_core::ExportFormat) {
|
||||
assert!(
|
||||
code.starts_with("use bevy::prelude::*;")
|
||||
|| code.starts_with("// Generated by Bevy Forge."),
|
||||
"{} output must start with the Bevy prelude import or its header:\n{code}",
|
||||
format.label()
|
||||
);
|
||||
assert!(
|
||||
code.contains("\nuse bevy::prelude::*;\n") || code.starts_with("use bevy::prelude::*;"),
|
||||
"{} output must import the Bevy prelude",
|
||||
format.label()
|
||||
);
|
||||
assert!(
|
||||
code.ends_with('\n') && !code.ends_with("\n\n"),
|
||||
"{} output must end with exactly one newline",
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a scene file with an arbitrary body and return a matching error.
|
||||
pub fn expect_project_error(result: Result<Project, ProjectError>) -> ProjectError {
|
||||
result.expect_err("expected a project error")
|
||||
}
|
||||
|
||||
/// Create `relative` under `root`, including parent directories.
|
||||
pub fn touch(root: &Path, relative: &str, contents: &[u8]) -> PathBuf {
|
||||
let path = root.join(relative);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create parent dirs");
|
||||
}
|
||||
std::fs::write(&path, contents).expect("write file");
|
||||
path
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Public-API integration tests for the scene model (`bevy_forge_core::scene`).
|
||||
//!
|
||||
//! Every structural mutation is followed by a [`SceneDoc::check_integrity`] check
|
||||
//! either directly or through the `assert_sound` helper.
|
||||
|
||||
mod common;
|
||||
|
||||
use bevy_forge_core::{
|
||||
components::{ComponentData, PrimitiveShape, VisibilityMode},
|
||||
math::{Bounds2, Color, Transform, Vec2, Vec3},
|
||||
EntityId, EntityNode, SceneDoc, SceneError, FORMAT_VERSION,
|
||||
};
|
||||
|
||||
fn assert_sound(doc: &SceneDoc) {
|
||||
let problems = doc.check_integrity();
|
||||
assert!(
|
||||
problems.is_empty(),
|
||||
"scene {:?} has structural problems: {problems:?}",
|
||||
doc.name
|
||||
);
|
||||
}
|
||||
|
||||
fn subtree_ids(doc: &SceneDoc, root: EntityId) -> Vec<EntityId> {
|
||||
let mut ids = Vec::new();
|
||||
fn walk(doc: &SceneDoc, id: EntityId, ids: &mut Vec<EntityId>) {
|
||||
if let Some(node) = doc.entity(id) {
|
||||
ids.push(id);
|
||||
for child in &node.children {
|
||||
walk(doc, *child, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc, root, &mut ids);
|
||||
ids
|
||||
}
|
||||
|
||||
fn text_component(text: &str, font_size: f32) -> ComponentData {
|
||||
ComponentData::Text2d(bevy_forge_core::Text2dData {
|
||||
text: text.to_string(),
|
||||
font_size,
|
||||
color: Color::WHITE,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_entity_preserves_stable_ids_and_display_order() {
|
||||
let mut doc = SceneDoc::new("Order");
|
||||
assert_eq!(doc.format_version, FORMAT_VERSION);
|
||||
assert_eq!(doc.next_id, 1);
|
||||
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", None);
|
||||
let c = doc.add_entity("C", Some(a));
|
||||
let d = doc.add_entity("D", Some(a));
|
||||
|
||||
assert_eq!((a, b, c, d), (1, 2, 3, 4));
|
||||
assert_eq!(doc.next_id, 5);
|
||||
assert_eq!(doc.roots, vec![a, b]);
|
||||
assert_eq!(doc.entity(a).unwrap().children, vec![c, d]);
|
||||
assert_eq!(doc.parent(c), Some(a));
|
||||
assert_eq!(doc.siblings(Some(a)), &[c, d]);
|
||||
assert_eq!(doc.siblings(None), &[a, b]);
|
||||
assert_eq!(doc.depth_first_ids(), vec![a, c, d, b]);
|
||||
assert_eq!(doc.entity(b).unwrap().parent, None);
|
||||
|
||||
let stats = doc.stats();
|
||||
assert_eq!(stats.entities, 4);
|
||||
assert_eq!(stats.roots, 2);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_entity_honours_parent_and_index_and_advances_next_id() {
|
||||
let mut doc = SceneDoc::new("Insert");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", None);
|
||||
|
||||
let prepared = EntityNode {
|
||||
id: 50,
|
||||
name: "Inserted Root".into(),
|
||||
parent: Some(b), // overwritten by the parent argument
|
||||
children: Vec::new(),
|
||||
transform: Transform::from_xyz(7.0, 8.0, 9.0),
|
||||
components: vec![common::sprite(None, Some(Vec2::new(2.0, 2.0)))],
|
||||
locked: true,
|
||||
};
|
||||
let inserted = doc.insert_entity(prepared, None, Some(0));
|
||||
assert_eq!(inserted, 50);
|
||||
assert_eq!(doc.roots, vec![inserted, a, b]);
|
||||
assert_eq!(doc.entity(inserted).unwrap().parent, None);
|
||||
assert_eq!(doc.entity(inserted).unwrap().name, "Inserted Root");
|
||||
assert!(doc.entity(inserted).unwrap().locked);
|
||||
assert_eq!(
|
||||
doc.entity(inserted).unwrap().transform,
|
||||
Transform::from_xyz(7.0, 8.0, 9.0)
|
||||
);
|
||||
assert_eq!(doc.next_id, 51);
|
||||
assert_sound(&doc);
|
||||
|
||||
// Appending under a parent, with an out-of-range index clamped to the end.
|
||||
let child = EntityNode {
|
||||
id: 60,
|
||||
name: "Inserted Child".into(),
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
transform: Transform::IDENTITY,
|
||||
components: Vec::new(),
|
||||
locked: false,
|
||||
};
|
||||
let child = doc.insert_entity(child, Some(a), Some(usize::MAX));
|
||||
assert_eq!(child, 60);
|
||||
assert_eq!(doc.entity(a).unwrap().children, vec![child]);
|
||||
assert_eq!(doc.next_id, 61);
|
||||
assert_eq!(doc.add_entity("Next", None), 61);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_entity_removes_the_subtree_and_never_reuses_ids() {
|
||||
let mut doc = SceneDoc::new("Remove");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", Some(a));
|
||||
let c = doc.add_entity("C", Some(a));
|
||||
let d = doc.add_entity("D", Some(b));
|
||||
doc.add_component(b, common::sprite(None, None));
|
||||
|
||||
let removed = doc.remove_entity(a);
|
||||
assert_eq!(removed, vec![a, b, d, c]);
|
||||
assert!(removed.contains(&c));
|
||||
assert!(doc.entities.is_empty());
|
||||
assert!(doc.roots.is_empty());
|
||||
assert_eq!(doc.next_id, 5, "removal must not rewind id allocation");
|
||||
assert_sound(&doc);
|
||||
|
||||
let next = doc.add_entity("New Root", None);
|
||||
assert_eq!(next, 5, "stable ids are never reused");
|
||||
assert!(doc.remove_entity(999).is_empty());
|
||||
assert_sound(&doc);
|
||||
|
||||
// Removing a single child detaches it from its parent only.
|
||||
let child = doc.add_entity("Child", Some(next));
|
||||
assert_eq!(doc.remove_entity(child), vec![child]);
|
||||
assert!(doc.entity(next).unwrap().children.is_empty());
|
||||
assert!(doc.entity(next).is_some());
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_entity_deep_copies_the_subtree_with_new_ids_and_copy_name() {
|
||||
let mut doc = SceneDoc::new("Duplicate");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", Some(a));
|
||||
let c = doc.add_entity("C", Some(a));
|
||||
let d = doc.add_entity("D", Some(b));
|
||||
assert_eq!(doc.entity(c).unwrap().parent, Some(a));
|
||||
doc.entity_mut(a).unwrap().transform = Transform::from_xyz(1.0, 2.0, 3.0);
|
||||
doc.entity_mut(a).unwrap().locked = true;
|
||||
doc.add_component(
|
||||
b,
|
||||
common::sprite(Some("textures/hero.png"), Some(Vec2::new(8.0, 4.0))),
|
||||
);
|
||||
doc.add_component(d, common::custom("game::Health", &[("current", "10.0")]));
|
||||
|
||||
let copy = doc.duplicate_entity(a).expect("existing entity duplicates");
|
||||
assert_eq!(
|
||||
doc.roots,
|
||||
vec![a, copy],
|
||||
"copy goes right after the original"
|
||||
);
|
||||
|
||||
let copy_node = doc.entity(copy).unwrap();
|
||||
assert_eq!(copy_node.name, "A Copy");
|
||||
assert_eq!(copy_node.parent, None);
|
||||
assert!(copy_node.locked, "editor flags are copied");
|
||||
assert_eq!(copy_node.transform, Transform::from_xyz(1.0, 2.0, 3.0));
|
||||
assert_eq!(copy_node.components, doc.entity(a).unwrap().components);
|
||||
|
||||
let original_ids = subtree_ids(&doc, a);
|
||||
let copied_ids = subtree_ids(&doc, copy);
|
||||
assert_eq!(original_ids.len(), 4);
|
||||
assert_eq!(copied_ids.len(), 4);
|
||||
for left in &original_ids {
|
||||
assert!(
|
||||
!copied_ids.contains(left),
|
||||
"copied subtree reused id {left}"
|
||||
);
|
||||
}
|
||||
assert_ne!(copy, a);
|
||||
assert!(copied_ids.iter().all(|id| *id > d), "copy ids are fresh");
|
||||
|
||||
// Hierarchy is preserved; only the copied root gets the " Copy" suffix.
|
||||
let copied_children = doc.entity(copy).unwrap().children.clone();
|
||||
assert_eq!(
|
||||
copied_children
|
||||
.iter()
|
||||
.map(|id| doc.entity(*id).unwrap().name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["B", "C"]
|
||||
);
|
||||
let copied_b = copied_children[0];
|
||||
assert_eq!(doc.entity(copied_b).unwrap().parent, Some(copy));
|
||||
assert_eq!(
|
||||
doc.entity(copied_b).unwrap().components,
|
||||
doc.entity(b).unwrap().components
|
||||
);
|
||||
let copied_d = doc.entity(copied_b).unwrap().children[0];
|
||||
assert_eq!(doc.entity(copied_d).unwrap().name, "D");
|
||||
assert_eq!(
|
||||
doc.entity(copied_d).unwrap().components,
|
||||
doc.entity(d).unwrap().components
|
||||
);
|
||||
|
||||
assert!(doc.duplicate_entity(999).is_none());
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_child_is_inserted_after_the_original_among_siblings() {
|
||||
let mut doc = SceneDoc::new("Duplicate Sibling");
|
||||
let parent = doc.add_entity("Parent", None);
|
||||
let a = doc.add_entity("A", Some(parent));
|
||||
let b = doc.add_entity("B", Some(parent));
|
||||
let c = doc.add_entity("C", Some(parent));
|
||||
|
||||
let copy = doc.duplicate_entity(b).unwrap();
|
||||
assert_eq!(doc.entity(parent).unwrap().children, vec![a, b, copy, c]);
|
||||
assert_eq!(doc.entity(copy).unwrap().parent, Some(parent));
|
||||
assert_eq!(doc.entity(copy).unwrap().name, "B Copy");
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_moves_between_roots_and_children() {
|
||||
let mut doc = SceneDoc::new("Reparent");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", None);
|
||||
let c = doc.add_entity("C", Some(a));
|
||||
|
||||
// Child -> root.
|
||||
doc.reparent(c, None, None).unwrap();
|
||||
assert_eq!(doc.roots, vec![a, b, c]);
|
||||
assert_eq!(doc.parent(c), None);
|
||||
assert!(doc.entity(a).unwrap().children.is_empty());
|
||||
assert_sound(&doc);
|
||||
|
||||
// Root -> child, at the front.
|
||||
doc.reparent(b, Some(a), Some(0)).unwrap();
|
||||
assert_eq!(doc.roots, vec![a, c]);
|
||||
assert_eq!(doc.entity(a).unwrap().children, vec![b]);
|
||||
assert_eq!(doc.parent(b), Some(a));
|
||||
assert_sound(&doc);
|
||||
|
||||
// Move the child back to the roots at a specific position.
|
||||
doc.reparent(b, None, Some(1)).unwrap();
|
||||
assert_eq!(doc.roots, vec![a, b, c]);
|
||||
assert!(doc.entity(a).unwrap().children.is_empty());
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_reorders_siblings_and_clamps_the_index() {
|
||||
let mut doc = SceneDoc::new("Reorder");
|
||||
let parent = doc.add_entity("Parent", None);
|
||||
let a = doc.add_entity("A", Some(parent));
|
||||
let b = doc.add_entity("B", Some(parent));
|
||||
let c = doc.add_entity("C", Some(parent));
|
||||
|
||||
doc.reparent(c, Some(parent), Some(0)).unwrap();
|
||||
assert_eq!(doc.entity(parent).unwrap().children, vec![c, a, b]);
|
||||
|
||||
doc.reparent(c, Some(parent), None).unwrap();
|
||||
assert_eq!(doc.entity(parent).unwrap().children, vec![a, b, c]);
|
||||
|
||||
doc.reparent(a, Some(parent), Some(99)).unwrap();
|
||||
assert_eq!(doc.entity(parent).unwrap().children, vec![b, c, a]);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_rejects_self_cycles_and_missing_entities() {
|
||||
let mut doc = SceneDoc::new("Reject");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", Some(a));
|
||||
let c = doc.add_entity("C", Some(b));
|
||||
|
||||
let before = doc.clone();
|
||||
assert!(matches!(
|
||||
doc.reparent(a, Some(a), None),
|
||||
Err(SceneError::SelfParent(id)) if id == a
|
||||
));
|
||||
assert!(matches!(
|
||||
doc.reparent(a, Some(c), None),
|
||||
Err(SceneError::Cycle { entity, parent }) if entity == a && parent == c
|
||||
));
|
||||
assert!(matches!(
|
||||
doc.reparent(a, Some(999), None),
|
||||
Err(SceneError::ParentNotFound(999))
|
||||
));
|
||||
assert!(matches!(
|
||||
doc.reparent(999, None, None),
|
||||
Err(SceneError::EntityNotFound(999))
|
||||
));
|
||||
assert_eq!(
|
||||
doc, before,
|
||||
"rejected reparents must not mutate the document"
|
||||
);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_first_ids_follow_roots_and_children_order() {
|
||||
let mut doc = SceneDoc::new("Depth First");
|
||||
let r1 = doc.add_entity("R1", None);
|
||||
let r2 = doc.add_entity("R2", None);
|
||||
let a = doc.add_entity("A", Some(r1));
|
||||
let b = doc.add_entity("B", Some(r1));
|
||||
let a1 = doc.add_entity("A1", Some(a));
|
||||
|
||||
assert_eq!(doc.depth_first_ids(), vec![r1, a, a1, b, r2]);
|
||||
|
||||
doc.reparent(b, None, Some(0)).unwrap();
|
||||
assert_eq!(doc.depth_first_ids(), vec![b, r1, a, a1, r2]);
|
||||
|
||||
doc.reparent(a1, Some(b), None).unwrap();
|
||||
assert_eq!(doc.depth_first_ids(), vec![b, a1, r1, a, r2]);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_transform_composes_multi_level_translation_rotation_and_scale() {
|
||||
let mut doc = SceneDoc::new("World");
|
||||
let root = doc.add_entity("Root", None);
|
||||
let child = doc.add_entity("Child", Some(root));
|
||||
let grandchild = doc.add_entity("Grandchild", Some(child));
|
||||
|
||||
doc.entity_mut(root).unwrap().transform = Transform {
|
||||
translation: Vec3::new(10.0, 0.0, 0.0),
|
||||
rotation: Vec3::new(0.0, 0.0, 90.0),
|
||||
scale: Vec3::splat(2.0),
|
||||
};
|
||||
doc.entity_mut(child).unwrap().transform = Transform::from_xyz(0.0, 2.0, 0.0);
|
||||
doc.entity_mut(grandchild).unwrap().transform = Transform::from_xyz(0.0, 1.0, 0.0);
|
||||
|
||||
let world_child = doc.world_transform(child).unwrap();
|
||||
assert!(
|
||||
common::approx_vec3(world_child.translation, Vec3::new(6.0, 0.0, 0.0)),
|
||||
"unexpected child translation {:?}",
|
||||
world_child.translation
|
||||
);
|
||||
assert!(common::approx_vec3(world_child.scale, Vec3::splat(2.0)));
|
||||
assert!(
|
||||
common::approx_f32(world_child.rotation.z, 90.0),
|
||||
"unexpected child rotation {:?}",
|
||||
world_child.rotation
|
||||
);
|
||||
|
||||
let parent_world = doc.parent_world_transform(child);
|
||||
assert!(common::approx_vec3(
|
||||
parent_world.translation,
|
||||
Vec3::new(10.0, 0.0, 0.0)
|
||||
));
|
||||
assert!(common::approx_vec3(parent_world.scale, Vec3::splat(2.0)));
|
||||
assert!(common::approx_f32(parent_world.rotation.z, 90.0));
|
||||
|
||||
let world_grandchild = doc.world_transform(grandchild).unwrap();
|
||||
assert!(
|
||||
common::approx_vec3(world_grandchild.translation, Vec3::new(4.0, 0.0, 0.0)),
|
||||
"unexpected grandchild translation {:?}",
|
||||
world_grandchild.translation
|
||||
);
|
||||
assert!(common::approx_vec3(
|
||||
world_grandchild.scale,
|
||||
Vec3::splat(2.0)
|
||||
));
|
||||
|
||||
assert_eq!(doc.parent_world_transform(root), Transform::IDENTITY);
|
||||
assert!(doc.world_transform(999).is_none());
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_are_unique_per_key_and_set_replaces_in_place() {
|
||||
let mut doc = SceneDoc::new("Components");
|
||||
let id = doc.add_entity("Entity", None);
|
||||
|
||||
assert!(doc.add_component(id, common::sprite(None, None)));
|
||||
assert!(
|
||||
!doc.add_component(id, common::sprite(Some("a.png"), None)),
|
||||
"duplicate sprite key must be rejected"
|
||||
);
|
||||
assert!(doc.add_component(id, text_component("Hi", 12.0)));
|
||||
assert!(doc.add_component(id, common::custom("game::Health", &[("current", "1.0")])));
|
||||
assert!(
|
||||
!doc.add_component(id, common::custom("game::Health", &[("current", "2.0")])),
|
||||
"custom components are keyed by type path"
|
||||
);
|
||||
assert!(doc.add_component(id, common::custom("game::Mana", &[])));
|
||||
|
||||
let node = doc.entity(id).unwrap();
|
||||
assert_eq!(
|
||||
node.components.iter().map(|c| c.key()).collect::<Vec<_>>(),
|
||||
vec![
|
||||
"sprite",
|
||||
"text2d",
|
||||
"custom:game::Health",
|
||||
"custom:game::Mana"
|
||||
]
|
||||
);
|
||||
|
||||
let replacement = common::sprite(Some("textures/hero.png"), Some(Vec2::new(3.0, 4.0)));
|
||||
assert!(doc.set_component(id, replacement.clone()));
|
||||
assert_eq!(doc.entity(id).unwrap().components.len(), 4);
|
||||
assert_eq!(
|
||||
doc.entity(id).unwrap().component("sprite"),
|
||||
Some(&replacement)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.entity(id).unwrap().component("text2d"),
|
||||
Some(&text_component("Hi", 12.0)),
|
||||
"setting a component must not reorder or drop others"
|
||||
);
|
||||
|
||||
let removed = doc.remove_component(id, 1);
|
||||
assert!(matches!(removed, Some(ComponentData::Text2d(_))));
|
||||
assert!(!doc.entity(id).unwrap().has_component("text2d"));
|
||||
assert!(doc.remove_component(id, 99).is_none());
|
||||
assert!(doc.remove_component(999, 0).is_none());
|
||||
assert!(!doc.set_component(999, ComponentData::Camera2d));
|
||||
assert!(!doc.add_component(999, ComponentData::Camera2d));
|
||||
assert!(doc.entity(id).unwrap().component("missing").is_none());
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_hidden_cycles_visibility_and_preserves_other_components() {
|
||||
let mut doc = SceneDoc::new("Visibility");
|
||||
let id = doc.add_entity("Entity", None);
|
||||
doc.add_component(id, common::sprite(None, None));
|
||||
assert_eq!(
|
||||
doc.entity(id).unwrap().visibility(),
|
||||
VisibilityMode::Inherited
|
||||
);
|
||||
assert!(!doc.entity(id).unwrap().is_hidden());
|
||||
|
||||
assert!(doc.toggle_hidden(id));
|
||||
assert!(doc.entity(id).unwrap().is_hidden());
|
||||
assert_eq!(
|
||||
doc.entity(id).unwrap().component("sprite"),
|
||||
Some(&common::sprite(None, None))
|
||||
);
|
||||
assert_eq!(
|
||||
doc.entity(id)
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.filter(|c| c.key() == "visibility")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
assert!(doc.toggle_hidden(id));
|
||||
assert!(!doc.entity(id).unwrap().is_hidden());
|
||||
assert_eq!(
|
||||
doc.entity(id).unwrap().visibility(),
|
||||
VisibilityMode::Inherited
|
||||
);
|
||||
assert!(!doc.entity(id).unwrap().has_component("visibility"));
|
||||
assert!(doc.entity(id).unwrap().has_component("sprite"));
|
||||
|
||||
// A visible marker is replaced by a hidden one, never duplicated.
|
||||
doc.set_component(id, ComponentData::Visibility(VisibilityMode::Visible));
|
||||
assert!(doc.toggle_hidden(id));
|
||||
assert!(doc.entity(id).unwrap().is_hidden());
|
||||
assert_eq!(
|
||||
doc.entity(id)
|
||||
.unwrap()
|
||||
.components
|
||||
.iter()
|
||||
.filter(|c| c.key() == "visibility")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(!doc.toggle_hidden(999));
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_2d_covers_world_space_sprites_and_text() {
|
||||
let mut doc = SceneDoc::new("Bounds");
|
||||
let root = doc.add_entity("Root", None);
|
||||
doc.entity_mut(root).unwrap().transform = Transform::from_xyz(10.0, 20.0, 0.0);
|
||||
doc.add_component(root, common::sprite(None, Some(Vec2::new(4.0, 2.0))));
|
||||
|
||||
let child = doc.add_entity("Child", Some(root));
|
||||
doc.entity_mut(child).unwrap().transform =
|
||||
Transform::from_xyz(0.0, 10.0, 0.0).with_scale(Vec3::splat(2.0));
|
||||
doc.add_component(child, common::sprite(None, Some(Vec2::new(2.0, 2.0))));
|
||||
|
||||
let bounds = doc.bounds_2d();
|
||||
assert!(
|
||||
common::approx_vec2(bounds.min, Vec2::new(8.0, 19.0)),
|
||||
"{bounds:?}"
|
||||
);
|
||||
assert!(
|
||||
common::approx_vec2(bounds.max, Vec2::new(12.0, 32.0)),
|
||||
"{bounds:?}"
|
||||
);
|
||||
|
||||
let mut text_doc = SceneDoc::new("Text Bounds");
|
||||
let text = text_doc.add_entity("Text", None);
|
||||
text_doc.add_component(text, text_component("abc", 10.0));
|
||||
let text_bounds = text_doc.bounds_2d();
|
||||
// 3 chars * 10px * 0.55 = 16.5 wide (text uses estimated glyph extents).
|
||||
assert!(common::approx_vec2(text_bounds.min, Vec2::new(-8.25, -7.0)));
|
||||
assert!(common::approx_vec2(text_bounds.max, Vec2::new(8.25, 7.0)));
|
||||
|
||||
let empty = SceneDoc::new("Empty").bounds_2d();
|
||||
assert_eq!(
|
||||
empty,
|
||||
Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0))
|
||||
);
|
||||
let mut camera_only = SceneDoc::new("Camera Only");
|
||||
let camera = camera_only.add_entity("Camera", None);
|
||||
camera_only.add_component(camera, ComponentData::Camera2d);
|
||||
assert_eq!(
|
||||
camera_only.bounds_2d(),
|
||||
Bounds2::from_center_size(Vec2::ZERO, Vec2::splat(10.0))
|
||||
);
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_find_by_name_and_rename_follow_the_document() {
|
||||
let mut doc = SceneDoc::new("Stats");
|
||||
let camera = doc.add_entity("Camera", None);
|
||||
doc.add_component(camera, ComponentData::Camera2d);
|
||||
let sprite = doc.add_entity("Sprite", None);
|
||||
doc.add_component(sprite, common::sprite(Some("a.png"), None));
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(
|
||||
mesh,
|
||||
common::primitive_mesh(PrimitiveShape::Cuboid { size: Vec3::ONE }),
|
||||
);
|
||||
let light = doc.add_entity("Sun", None);
|
||||
doc.add_component(
|
||||
light,
|
||||
ComponentData::DirectionalLight(bevy_forge_core::DirectionalLightData::default()),
|
||||
);
|
||||
let custom = doc.add_entity("Marker", None);
|
||||
doc.add_component(custom, common::custom("game::Marker", &[]));
|
||||
|
||||
let stats = doc.stats();
|
||||
assert_eq!(stats.entities, 5);
|
||||
assert_eq!(stats.roots, 5);
|
||||
assert_eq!(stats.cameras, 1);
|
||||
assert_eq!(stats.sprites, 1);
|
||||
assert_eq!(stats.meshes, 1);
|
||||
assert_eq!(stats.lights, 1);
|
||||
assert_eq!(stats.custom_components, 1);
|
||||
|
||||
assert_eq!(doc.find_by_name("Mesh"), Some(mesh));
|
||||
assert_eq!(doc.find_by_name("missing"), None);
|
||||
assert!(doc.rename(mesh, "Mesh Renamed"));
|
||||
assert_eq!(doc.find_by_name("Mesh"), None);
|
||||
assert_eq!(doc.find_by_name("Mesh Renamed"), Some(mesh));
|
||||
assert!(!doc.rename(999, "Nope"));
|
||||
assert_sound(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_integrity_detects_inconsistent_forests() {
|
||||
let mut doc = SceneDoc::new("Corrupt");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", Some(a));
|
||||
let c = doc.add_entity("C", Some(b));
|
||||
|
||||
assert!(doc.check_integrity().is_empty());
|
||||
doc.roots.push(999);
|
||||
doc.entity_mut(a).unwrap().children.push(998);
|
||||
doc.entity_mut(b).unwrap().parent = Some(404);
|
||||
doc.entity_mut(c).unwrap().id = 77;
|
||||
|
||||
let problems = doc.check_integrity();
|
||||
assert!(!problems.is_empty());
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("node id mismatch")),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("does not exist")),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(
|
||||
problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("wrong parent")),
|
||||
"{problems:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
//! Public-API integration tests for project persistence and asset discovery.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_forge_core::{
|
||||
components::{ComponentData, Mesh3dData, MeshSource, PrimitiveShape, VisibilityMode},
|
||||
math::{Color, Transform, Vec2, Vec3},
|
||||
project::{find_project_root, SCENE_EXTENSION},
|
||||
samples, validate_scene, AssetIndex, AssetKind, Project, ProjectError, SceneDoc,
|
||||
FORMAT_VERSION,
|
||||
};
|
||||
|
||||
fn sample_document() -> SceneDoc {
|
||||
let mut doc = SceneDoc::new("Round Trip");
|
||||
let root = doc.add_entity("Root", None);
|
||||
doc.entity_mut(root).unwrap().transform =
|
||||
Transform::from_xyz(1.5, -2.25, 3.75).with_rotation(Vec3::new(10.0, 20.0, 30.0));
|
||||
doc.entity_mut(root).unwrap().locked = true;
|
||||
|
||||
let sprite = doc.add_entity("Sprite", Some(root));
|
||||
doc.entity_mut(sprite).unwrap().transform = Transform::from_xyz(0.0, 4.0, 1.0);
|
||||
doc.add_component(
|
||||
sprite,
|
||||
ComponentData::Sprite(bevy_forge_core::SpriteData {
|
||||
image: Some("textures/player.png".into()),
|
||||
color: Color::srgba(0.25, 0.5, 0.75, 1.0),
|
||||
custom_size: Some(Vec2::new(64.0, 96.0)),
|
||||
flip_x: true,
|
||||
flip_y: false,
|
||||
}),
|
||||
);
|
||||
doc.add_component(sprite, ComponentData::Visibility(VisibilityMode::Hidden));
|
||||
doc.add_component(
|
||||
sprite,
|
||||
common::custom("game::Health", &[("current", "10.0"), ("max", "Vec3::X")]),
|
||||
);
|
||||
|
||||
let camera = doc.add_entity("Camera", None);
|
||||
doc.add_component(camera, ComponentData::Camera2d);
|
||||
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(
|
||||
mesh,
|
||||
ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Cylinder {
|
||||
radius: 0.5,
|
||||
height: 2.0,
|
||||
}),
|
||||
material: bevy_forge_core::MaterialData {
|
||||
base_color: Color::srgb(0.2, 0.3, 0.4),
|
||||
metallic: 0.6,
|
||||
perceptual_roughness: 0.35,
|
||||
unlit: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let light = doc.add_entity("Light", None);
|
||||
doc.add_component(
|
||||
light,
|
||||
ComponentData::PointLight(bevy_forge_core::PointLightData {
|
||||
color: Color::srgb(1.0, 0.9, 0.8),
|
||||
intensity: 450.0,
|
||||
range: 12.0,
|
||||
shadows: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let text = doc.add_entity("Text", None);
|
||||
doc.add_component(
|
||||
text,
|
||||
ComponentData::Text2d(bevy_forge_core::Text2dData {
|
||||
text: "Line \"one\"\nLine two".into(),
|
||||
font_size: 18.0,
|
||||
color: Color::WHITE,
|
||||
}),
|
||||
);
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_minimal_bevy_project_and_reports_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let project = common::create_project(dir.path(), "demo-project");
|
||||
let canonical = dir.path().canonicalize().unwrap();
|
||||
|
||||
assert_eq!(project.root, canonical);
|
||||
assert_eq!(project.name, "demo-project");
|
||||
assert_eq!(project.bevy_version.as_deref(), Some("0.20"));
|
||||
// No assets directory: the project root doubles as the Bevy assets dir.
|
||||
assert_eq!(project.assets_dir, canonical);
|
||||
assert_eq!(project.scenes_dir(), canonical.join("scenes"));
|
||||
assert_eq!(project.forge_dir(), canonical.join(".forge"));
|
||||
|
||||
std::fs::create_dir_all(canonical.join("assets")).unwrap();
|
||||
let with_assets = Project::open(dir.path()).unwrap();
|
||||
assert_eq!(with_assets.assets_dir, canonical.join("assets"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_bevy_dependencies_in_tables_workspaces_and_dev_dependencies() {
|
||||
let cases: [(&str, &str, Option<&str>); 4] = [
|
||||
(
|
||||
"string",
|
||||
"[package]\nname = \"string-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
|
||||
Some("string-dep"),
|
||||
),
|
||||
(
|
||||
"table",
|
||||
"[package]\nname = \"table-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies.bevy]\nversion = \"0.20\"\nfeatures = [\"2d\"]\n",
|
||||
Some("table-dep"),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
"[workspace]\nmembers = []\n\n[workspace.dependencies]\nbevy = \"0.20\"\n",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"dev",
|
||||
"[package]\nname = \"dev-dep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dev-dependencies]\nbevy = \"0.20\"\n",
|
||||
Some("dev-dep"),
|
||||
),
|
||||
];
|
||||
|
||||
for (label, manifest, expected_name) in cases {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
common::write_manifest(dir.path(), manifest);
|
||||
let project = Project::open(dir.path())
|
||||
.unwrap_or_else(|error| panic!("{label} manifest should open: {error}"));
|
||||
assert_eq!(
|
||||
project.bevy_version.as_deref(),
|
||||
Some("0.20"),
|
||||
"{label} dependency"
|
||||
);
|
||||
if let Some(name) = expected_name {
|
||||
assert_eq!(project.name, name, "{label} package name");
|
||||
} else {
|
||||
assert_eq!(
|
||||
project.name,
|
||||
project.root.file_name().unwrap().to_string_lossy(),
|
||||
"workspace-only manifest falls back to the directory name"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_projects_malformed_manifests_and_missing_roots() {
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
assert!(matches!(
|
||||
Project::open(empty.path()),
|
||||
Err(ProjectError::NotAProject(_))
|
||||
));
|
||||
|
||||
let no_bevy = tempfile::tempdir().unwrap();
|
||||
common::write_manifest(no_bevy.path(), "[package]\nname = \"plain\"\n");
|
||||
assert!(matches!(
|
||||
Project::open(no_bevy.path()),
|
||||
Err(ProjectError::NotAProject(_))
|
||||
));
|
||||
|
||||
let malformed = tempfile::tempdir().unwrap();
|
||||
common::write_manifest(malformed.path(), "[dependencies\nbevy = \"0.20\"\n");
|
||||
assert!(matches!(
|
||||
Project::open(malformed.path()),
|
||||
Err(ProjectError::Manifest(_))
|
||||
));
|
||||
|
||||
let missing = tempfile::tempdir().unwrap().path().join("does-not-exist");
|
||||
assert!(matches!(
|
||||
Project::open(&missing),
|
||||
Err(ProjectError::Io { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_project_root_walks_up_from_directories_and_files() {
|
||||
let (_dir, project) = common::temp_project("find-root");
|
||||
let nested = project.root.join("src/game/deep");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(find_project_root(&nested), Some(project.root.clone()));
|
||||
|
||||
let scene = common::touch(
|
||||
&project.root,
|
||||
"scenes/level.forge.ron",
|
||||
b"(format_version: 1)",
|
||||
);
|
||||
assert_eq!(find_project_root(&scene), Some(project.root.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip_preserves_ids_order_components_and_custom_data() {
|
||||
let (_dir, project) = common::temp_project("round-trip");
|
||||
let doc = sample_document();
|
||||
let expected_path = project.scenes_dir().join("round-trip.forge.ron");
|
||||
|
||||
let saved = project.save_scene(&doc, None).unwrap();
|
||||
assert_eq!(saved, expected_path);
|
||||
assert!(saved.is_file());
|
||||
|
||||
let loaded = project.load_scene(&saved).unwrap();
|
||||
assert_eq!(loaded, doc);
|
||||
assert_eq!(loaded.name, "Round Trip");
|
||||
assert_eq!(
|
||||
loaded.entities.keys().copied().collect::<Vec<_>>(),
|
||||
doc.entities.keys().copied().collect::<Vec<_>>(),
|
||||
"entity insertion order must survive persistence"
|
||||
);
|
||||
assert_eq!(loaded.roots, doc.roots);
|
||||
assert_eq!(loaded.next_id, doc.next_id);
|
||||
assert!(loaded.entity(1).unwrap().locked);
|
||||
|
||||
let custom = loaded
|
||||
.entities
|
||||
.values()
|
||||
.find_map(|node| match node.component("custom:game::Health") {
|
||||
Some(ComponentData::Custom(custom)) => Some(custom),
|
||||
_ => None,
|
||||
})
|
||||
.expect("custom component survives the round trip");
|
||||
assert_eq!(custom.type_path, "game::Health");
|
||||
assert_eq!(custom.fields.len(), 2);
|
||||
assert_eq!(custom.fields[1].name, "max");
|
||||
assert_eq!(custom.fields[1].expr, "Vec3::X");
|
||||
|
||||
// An explicit in-project path also round-trips, and the file is real RON.
|
||||
let explicit = project.scenes_dir().join("explicit.forge.ron");
|
||||
project.save_scene(&doc, Some(&explicit)).unwrap();
|
||||
assert_eq!(project.load_scene(&explicit).unwrap(), doc);
|
||||
let text = std::fs::read_to_string(&explicit).unwrap();
|
||||
assert!(text.contains("format_version: 1"), "{text}");
|
||||
assert!(text.contains("Round Trip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_scene_rejects_paths_outside_the_project() {
|
||||
let (_dir, project) = common::temp_project("containment");
|
||||
let mut doc = SceneDoc::new("Contained");
|
||||
doc.add_entity("Root", None);
|
||||
|
||||
let outside_dir = tempfile::tempdir().unwrap();
|
||||
let outside = outside_dir.path().join("escape.forge.ron");
|
||||
match project.save_scene(&doc, Some(&outside)) {
|
||||
Err(ProjectError::SceneOutsideProject(path)) => assert_eq!(path, outside),
|
||||
other => panic!("expected SceneOutsideProject, got {other:?}"),
|
||||
}
|
||||
assert!(!outside.exists(), "a rejected save must write nothing");
|
||||
|
||||
// The core contract only requires containment: a path inside the project is
|
||||
// accepted even when it is not under scenes/ (the editor uses this for autosave).
|
||||
let inside_root = project.root.join("root-level.forge.ron");
|
||||
project.save_scene(&doc, Some(&inside_root)).unwrap();
|
||||
assert_eq!(project.load_scene(&inside_root).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_paths_use_slugs_and_the_forge_ron_extension() {
|
||||
let (_dir, project) = common::temp_project("scene-paths");
|
||||
assert_eq!(SCENE_EXTENSION, "forge.ron");
|
||||
|
||||
let scene = project.scene_path("My Level 01");
|
||||
assert_eq!(scene, project.scenes_dir().join("my-level-01.forge.ron"));
|
||||
assert!(scene
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.ends_with(".forge.ron"));
|
||||
|
||||
assert_eq!(
|
||||
project.scene_path("***"),
|
||||
project.scenes_dir().join("scene.forge.ron")
|
||||
);
|
||||
|
||||
let mut doc = SceneDoc::new("Boss Room!");
|
||||
doc.add_entity("Root", None);
|
||||
let saved = project.save_scene(&doc, None).unwrap();
|
||||
assert_eq!(saved, project.scenes_dir().join("boss-room.forge.ron"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_is_atomic_and_leaves_no_temporary_files() {
|
||||
let (_dir, project) = common::temp_project("atomic");
|
||||
let mut doc = SceneDoc::new("Atomic");
|
||||
doc.add_entity("Root", None);
|
||||
let path = project.scenes_dir().join("atomic.forge.ron");
|
||||
project.save_scene(&doc, Some(&path)).unwrap();
|
||||
|
||||
common::touch(&project.scenes_dir(), "keep.txt", b"unrelated");
|
||||
doc.rename(1, "Root Renamed");
|
||||
project.save_scene(&doc, Some(&path)).unwrap();
|
||||
|
||||
let loaded = project.load_scene(&path).unwrap();
|
||||
assert_eq!(loaded, doc, "second save atomically replaces the first");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(project.scenes_dir().join("keep.txt")).unwrap(),
|
||||
"unrelated",
|
||||
"saving must not disturb unrelated files"
|
||||
);
|
||||
|
||||
let names: Vec<String> = std::fs::read_dir(project.scenes_dir())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(names.iter().any(|name| name == "atomic.forge.ron"));
|
||||
assert!(
|
||||
names.iter().all(|name| !name.contains(".tmp-")),
|
||||
"temporary files leaked: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.all(|name| name != "." && name != ".." && !name.starts_with('.')),
|
||||
"hidden temporary files leaked: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_scenes_is_sorted_and_lists_only_direct_forge_scenes() {
|
||||
let (_dir, project) = common::temp_project("list-scenes");
|
||||
let beta = SceneDoc::new("Beta");
|
||||
let mut alpha = SceneDoc::new("Alpha");
|
||||
alpha.add_entity("Root", None);
|
||||
project
|
||||
.save_scene(&beta, Some(&project.scenes_dir().join("beta.forge.ron")))
|
||||
.unwrap();
|
||||
project
|
||||
.save_scene(&alpha, Some(&project.scenes_dir().join("alpha.forge.ron")))
|
||||
.unwrap();
|
||||
common::touch(&project.scenes_dir(), "notes.txt", b"not a scene");
|
||||
common::touch(&project.scenes_dir(), "other.ron", b"not a forge scene");
|
||||
common::touch(&project.scenes_dir(), "sub/nested.forge.ron", b"nested");
|
||||
|
||||
assert_eq!(
|
||||
project.list_scenes(),
|
||||
vec![
|
||||
project.scenes_dir().join("alpha.forge.ron"),
|
||||
project.scenes_dir().join("beta.forge.ron")
|
||||
]
|
||||
);
|
||||
|
||||
let (_empty_dir, empty_project) = common::temp_project("no-scenes");
|
||||
assert!(empty_project.list_scenes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_path_is_nested_under_forge_and_round_trips() {
|
||||
let (_dir, project) = common::temp_project("autosave");
|
||||
let autosave = project.autosave_path();
|
||||
assert_eq!(autosave, project.forge_dir().join("autosave.forge.ron"));
|
||||
assert_eq!(autosave, project.root.join(".forge/autosave.forge.ron"));
|
||||
assert!(!autosave.exists());
|
||||
|
||||
let mut doc = SceneDoc::new("Autosaved");
|
||||
doc.add_entity("Root", None);
|
||||
project.save_scene(&doc, Some(&autosave)).unwrap();
|
||||
assert!(project.forge_dir().is_dir());
|
||||
assert_eq!(project.load_scene(&autosave).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_scene_reports_errors_and_normalises_legacy_files() {
|
||||
let (_dir, project) = common::temp_project("load-errors");
|
||||
let missing = project.scenes_dir().join("missing.forge.ron");
|
||||
assert!(matches!(
|
||||
project.load_scene(&missing),
|
||||
Err(ProjectError::Io { .. })
|
||||
));
|
||||
|
||||
let broken = common::touch(
|
||||
&project.scenes_dir(),
|
||||
"broken.forge.ron",
|
||||
b"this is not ron",
|
||||
);
|
||||
match project.load_scene(&broken) {
|
||||
Err(ProjectError::SceneParse { path, .. }) => assert_eq!(path, broken),
|
||||
other => panic!("expected SceneParse, got {other:?}"),
|
||||
}
|
||||
|
||||
let mut doc = SceneDoc::new("Legacy");
|
||||
doc.add_entity("Root", None);
|
||||
let path = project.save_scene(&doc, None).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let legacy = text
|
||||
.replacen("format_version: 1", "format_version: 0", 1)
|
||||
.lines()
|
||||
.filter(|line| !line.contains("locked:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
+ "\n";
|
||||
assert_ne!(legacy, text, "fixture must actually change");
|
||||
std::fs::write(&path, legacy).unwrap();
|
||||
|
||||
let loaded = project.load_scene(&path).unwrap();
|
||||
assert_eq!(loaded.format_version, FORMAT_VERSION);
|
||||
assert_eq!(loaded.name, "Legacy");
|
||||
assert_eq!(loaded.entities.len(), 1);
|
||||
assert!(!loaded.entity(1).unwrap().locked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_project_loads_validates_cleanly_and_all_assets_resolve() {
|
||||
let demo_dir = common::demo_project_dir();
|
||||
let project = Project::open(&demo_dir).expect("demo project opens");
|
||||
assert_eq!(project.name, "forge-demo");
|
||||
assert_eq!(project.bevy_version.as_deref(), Some("0.20"));
|
||||
assert_eq!(project.assets_dir, demo_dir.join("assets"));
|
||||
|
||||
let scenes = project.list_scenes();
|
||||
assert_eq!(
|
||||
scenes,
|
||||
vec![
|
||||
demo_dir.join("scenes/garden.forge.ron"),
|
||||
demo_dir.join("scenes/platformer.forge.ron")
|
||||
]
|
||||
);
|
||||
|
||||
let index = AssetIndex::scan(&project.assets_dir);
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(index.images().len(), 6);
|
||||
|
||||
for (path, expected_doc, expected_asset_count) in [
|
||||
(
|
||||
demo_dir.join("scenes/garden.forge.ron"),
|
||||
samples::garden_demo(),
|
||||
0usize,
|
||||
),
|
||||
(
|
||||
demo_dir.join("scenes/platformer.forge.ron"),
|
||||
samples::platformer_demo(),
|
||||
6usize,
|
||||
),
|
||||
] {
|
||||
let doc = project.load_scene(&path).expect("demo scene loads");
|
||||
assert_eq!(doc, expected_doc, "committed scene must match its builder");
|
||||
assert!(
|
||||
doc.check_integrity().is_empty(),
|
||||
"{} integrity: {:?}",
|
||||
doc.name,
|
||||
doc.check_integrity()
|
||||
);
|
||||
|
||||
let report = validate_scene(&doc, Some(&index));
|
||||
assert_eq!(report.errors(), 0, "{}: {:?}", doc.name, report.issues);
|
||||
assert_eq!(report.warnings(), 0, "{}: {:?}", doc.name, report.issues);
|
||||
assert!(report.is_ok());
|
||||
|
||||
let mut referenced = BTreeSet::new();
|
||||
for entity in doc.entities.values() {
|
||||
for component in entity.components.iter() {
|
||||
for (field, asset_path) in component.asset_references() {
|
||||
assert!(!field.is_empty());
|
||||
assert!(!asset_path.contains('\\'));
|
||||
referenced.insert(asset_path.to_string());
|
||||
let indexed = index
|
||||
.find(asset_path)
|
||||
.unwrap_or_else(|| panic!("{} not indexed", asset_path));
|
||||
assert!(indexed.abs_path.is_file(), "{asset_path} missing on disk");
|
||||
assert_eq!(indexed.kind, AssetKind::Image);
|
||||
let resolved = project.resolve_asset(asset_path);
|
||||
assert!(resolved.is_file(), "{} does not resolve", asset_path);
|
||||
assert_eq!(resolved, project.assets_dir.join(asset_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
referenced.len(),
|
||||
expected_asset_count,
|
||||
"{} asset references: {referenced:?}",
|
||||
doc.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_demo_assets_indexes_sorted_forward_slash_image_paths() {
|
||||
let index = AssetIndex::scan(&common::demo_assets_dir());
|
||||
assert_eq!(index.error, None);
|
||||
assert_eq!(index.len(), 6);
|
||||
|
||||
let paths: Vec<&str> = index.entries.iter().map(|e| e.path.as_str()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
"textures/background.png",
|
||||
"textures/coin.png",
|
||||
"textures/crate.png",
|
||||
"textures/hat.png",
|
||||
"textures/player.png",
|
||||
"textures/tile.png",
|
||||
]
|
||||
);
|
||||
let mut sorted = paths.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(paths, sorted);
|
||||
|
||||
for entry in &index.entries {
|
||||
assert_eq!(entry.kind, AssetKind::Image);
|
||||
assert!(entry.size_bytes > 0, "{} has no contents", entry.path);
|
||||
assert!(!entry.path.starts_with('/'));
|
||||
assert!(
|
||||
!entry.path.contains('\\'),
|
||||
"{} uses backslashes",
|
||||
entry.path
|
||||
);
|
||||
assert!(entry.abs_path.is_file());
|
||||
assert_eq!(entry.abs_path, common::demo_assets_dir().join(&entry.path));
|
||||
assert!(entry.file_name().ends_with(".png"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_search_kind_filters_and_metadata_behave_as_documented() {
|
||||
let index = AssetIndex::scan(&common::demo_assets_dir());
|
||||
|
||||
assert_eq!(index.search("").len(), 6);
|
||||
assert_eq!(index.search("textures").len(), 6);
|
||||
let player = index.search("player");
|
||||
assert_eq!(player.len(), 1);
|
||||
assert_eq!(player[0].path, "textures/player.png");
|
||||
assert_eq!(
|
||||
index.search(" PLAYER ").len(),
|
||||
1,
|
||||
"search trims and folds case"
|
||||
);
|
||||
assert!(index.search("missing-texture").is_empty());
|
||||
|
||||
assert_eq!(index.by_kind(AssetKind::Image).len(), 6);
|
||||
assert!(index.by_kind(AssetKind::Mesh).is_empty());
|
||||
assert_eq!(index.images().len(), 6);
|
||||
assert!(index.find("textures/hero.png").is_none());
|
||||
assert_eq!(
|
||||
index.find("textures/player.png").unwrap().file_name(),
|
||||
"player.png"
|
||||
);
|
||||
assert!(!index.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_assets_directory_is_an_empty_index_with_an_error() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let missing = temp.path().join("assets-does-not-exist");
|
||||
|
||||
let index = AssetIndex::scan(&missing);
|
||||
assert!(index.is_empty());
|
||||
assert_eq!(index.len(), 0);
|
||||
assert_eq!(index.scanned_dir, missing);
|
||||
let error = index.error.as_deref().expect("missing dir must set error");
|
||||
assert!(error.contains("cannot read assets directory"), "{error}");
|
||||
assert!(error.contains("assets-does-not-exist"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_paths_are_project_relative_and_skip_hidden_and_build_dirs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let assets = dir.path().join("assets");
|
||||
common::touch(&assets, "textures/hero.png", b"png");
|
||||
common::touch(&assets, "audio/loop.ogg", b"ogg");
|
||||
common::touch(&assets, ".hidden.png", b"hidden");
|
||||
common::touch(&assets, "target/debug/cache.bin", b"cache");
|
||||
let project = common::create_project(dir.path(), "asset-paths");
|
||||
assert_eq!(project.assets_dir, project.root.join("assets"));
|
||||
|
||||
let index = AssetIndex::scan(&project.assets_dir);
|
||||
assert_eq!(index.error, None);
|
||||
let paths: Vec<&str> = index.entries.iter().map(|e| e.path.as_str()).collect();
|
||||
assert_eq!(paths, vec!["audio/loop.ogg", "textures/hero.png"]);
|
||||
assert_eq!(index.find("audio/loop.ogg").unwrap().kind, AssetKind::Audio);
|
||||
|
||||
let resolved = project.resolve_asset("textures/hero.png");
|
||||
assert_eq!(resolved, project.assets_dir.join("textures/hero.png"));
|
||||
assert!(resolved.is_file());
|
||||
assert_eq!(
|
||||
project.asset_path_for(&resolved).as_deref(),
|
||||
Some("textures/hero.png")
|
||||
);
|
||||
assert_eq!(
|
||||
project.asset_path_for(&project.root.join("Cargo.toml")),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Public-API integration tests for scene validation (contracts §6).
|
||||
|
||||
mod common;
|
||||
|
||||
use bevy_forge_core::{
|
||||
components::{
|
||||
ComponentData, CustomComponent, CustomField, DirectionalLightData, MaterialData,
|
||||
Mesh3dData, MeshSource, PointLightData, PrimitiveShape, Text2dData, VisibilityMode,
|
||||
},
|
||||
math::{Color, Transform, Vec2, Vec3},
|
||||
validate_scene, AssetIndex, EntityId, Issue, SceneDoc, Severity, ValidationReport,
|
||||
};
|
||||
|
||||
fn doc_with(name: &str, component: ComponentData) -> (SceneDoc, EntityId) {
|
||||
let mut doc = SceneDoc::new("Validation");
|
||||
let id = doc.add_entity(name, None);
|
||||
assert!(doc.add_component(id, component), "component was rejected");
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn mesh_asset(path: &str) -> ComponentData {
|
||||
ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Asset(path.to_string()),
|
||||
material: MaterialData::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn has_issue(report: &ValidationReport, entity: EntityId, field: &str, severity: Severity) -> bool {
|
||||
report.issues.iter().any(|issue| {
|
||||
issue.entity == Some(entity)
|
||||
&& issue.field.as_deref() == Some(field)
|
||||
&& issue.severity == severity
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_issue(report: &ValidationReport, entity: EntityId, field: &str, severity: Severity) {
|
||||
assert!(
|
||||
has_issue(report, entity, field, severity),
|
||||
"missing {severity:?} issue for entity {entity} field {field}; issues: {:#?}",
|
||||
report.issues
|
||||
);
|
||||
}
|
||||
|
||||
fn has_message(report: &ValidationReport, fragment: &str) -> bool {
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.message.contains(fragment))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_asset_references_are_errors_with_entity_and_field_context() {
|
||||
let cases = [
|
||||
("/textures/hero.png", "absolute"),
|
||||
("textures/../hero.png", "`..`"),
|
||||
("textures\\hero.png", "backslash"),
|
||||
("", "empty"),
|
||||
];
|
||||
for (path, message_fragment) in cases {
|
||||
let (doc, id) = doc_with("Sprite", common::sprite(Some(path), Some(Vec2::splat(8.0))));
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(!report.is_ok(), "{path:?} should be invalid");
|
||||
assert_eq!(report.errors(), 1, "{path:?}: {:?}", report.issues);
|
||||
let issue = &report.issues[0];
|
||||
assert_eq!(issue.severity, Severity::Error);
|
||||
assert_eq!(issue.entity, Some(id));
|
||||
assert_eq!(issue.field.as_deref(), Some("sprite.image"));
|
||||
assert!(
|
||||
issue.message.contains(message_fragment),
|
||||
"{path:?}: {}",
|
||||
issue.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_asset_checks_require_a_readable_asset_index() {
|
||||
let path = "textures/ghost.png";
|
||||
let (doc, id) = doc_with("Sprite", common::sprite(Some(path), Some(Vec2::splat(8.0))));
|
||||
|
||||
let without_index = validate_scene(&doc, None);
|
||||
assert!(without_index.is_ok());
|
||||
assert!(!has_issue(
|
||||
&without_index,
|
||||
id,
|
||||
"sprite.image",
|
||||
Severity::Error
|
||||
));
|
||||
|
||||
let empty_assets = tempfile::tempdir().unwrap();
|
||||
let empty_index = AssetIndex::scan(empty_assets.path());
|
||||
assert_eq!(empty_index.error, None);
|
||||
let with_empty_index = validate_scene(&doc, Some(&empty_index));
|
||||
assert_issue(&with_empty_index, id, "sprite.image", Severity::Error);
|
||||
assert!(has_message(
|
||||
&with_empty_index,
|
||||
"not found in the project assets"
|
||||
));
|
||||
|
||||
// An index whose scan failed cannot prove an asset is missing, so the
|
||||
// missing-file error is suppressed (kind checks still apply).
|
||||
let missing_assets = tempfile::tempdir().unwrap().path().join("not-there");
|
||||
let broken_index = AssetIndex::scan(&missing_assets);
|
||||
assert!(broken_index.error.is_some());
|
||||
let with_broken_index = validate_scene(&doc, Some(&broken_index));
|
||||
assert!(with_broken_index.is_ok(), "{:#?}", with_broken_index.issues);
|
||||
assert!(!has_issue(
|
||||
&with_broken_index,
|
||||
id,
|
||||
"sprite.image",
|
||||
Severity::Error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_kind_mismatches_are_errors_even_without_an_index() {
|
||||
let mut doc = SceneDoc::new("Kind Mismatch");
|
||||
let sprite = doc.add_entity("Sprite", None);
|
||||
doc.add_component(
|
||||
sprite,
|
||||
common::sprite(Some("audio/loop.ogg"), Some(Vec2::splat(8.0))),
|
||||
);
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(mesh, mesh_asset("textures/hero.png"));
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_eq!(report.errors(), 2, "{:#?}", report.issues);
|
||||
assert_issue(&report, sprite, "sprite.image", Severity::Error);
|
||||
assert_issue(&report, mesh, "mesh3d.mesh", Severity::Error);
|
||||
assert!(has_message(&report, "is not an image"));
|
||||
assert!(has_message(&report, "is not a mesh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_component_and_transform_errors_carry_context() {
|
||||
let mut doc = SceneDoc::new("Numeric Errors");
|
||||
|
||||
let sprite = doc.add_entity("Sprite", None);
|
||||
doc.add_component(sprite, common::sprite(None, Some(Vec2::new(0.0, 2.0))));
|
||||
|
||||
let text = doc.add_entity("Text", None);
|
||||
doc.add_component(
|
||||
text,
|
||||
ComponentData::Text2d(Text2dData {
|
||||
text: "Hello".into(),
|
||||
font_size: 0.0,
|
||||
color: Color::WHITE,
|
||||
}),
|
||||
);
|
||||
|
||||
let light = doc.add_entity("Light", None);
|
||||
doc.add_component(
|
||||
light,
|
||||
ComponentData::PointLight(PointLightData {
|
||||
color: Color::WHITE,
|
||||
intensity: -1.0,
|
||||
range: 0.0,
|
||||
shadows: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let sun = doc.add_entity("Sun", None);
|
||||
doc.add_component(
|
||||
sun,
|
||||
ComponentData::DirectionalLight(DirectionalLightData {
|
||||
color: Color::WHITE,
|
||||
illuminance: -5.0,
|
||||
shadows: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(
|
||||
mesh,
|
||||
ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Cuboid { size: Vec3::ONE }),
|
||||
material: MaterialData {
|
||||
base_color: Color::WHITE,
|
||||
metallic: f32::NAN,
|
||||
perceptual_roughness: 0.5,
|
||||
unlit: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let broken = doc.add_entity("Broken Transform", None);
|
||||
doc.entity_mut(broken).unwrap().transform = Transform {
|
||||
translation: Vec3::new(f32::NAN, 0.0, 0.0),
|
||||
rotation: Vec3::new(0.0, f32::INFINITY, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, f32::NAN),
|
||||
};
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_eq!(report.errors(), 9, "{:#?}", report.issues);
|
||||
assert_issue(&report, sprite, "sprite.custom_size", Severity::Error);
|
||||
assert_issue(&report, text, "text2d.font_size", Severity::Error);
|
||||
assert_issue(&report, light, "point_light.intensity", Severity::Error);
|
||||
assert_issue(&report, light, "point_light.range", Severity::Error);
|
||||
assert_issue(
|
||||
&report,
|
||||
sun,
|
||||
"directional_light.illuminance",
|
||||
Severity::Error,
|
||||
);
|
||||
assert_issue(&report, mesh, "mesh3d.material.metallic", Severity::Error);
|
||||
assert_issue(&report, broken, "transform.translation", Severity::Error);
|
||||
assert_issue(&report, broken, "transform.rotation", Severity::Error);
|
||||
assert_issue(&report, broken, "transform.scale", Severity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_component_errors_have_documented_field_paths() {
|
||||
let (mut doc, id) = doc_with("Custom", common::custom("not a path", &[]));
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_eq!(report.errors(), 1, "{:#?}", report.issues);
|
||||
assert_issue(&report, id, "custom.type_path", Severity::Error);
|
||||
assert!(has_message(&report, "not a valid Rust type path"));
|
||||
|
||||
doc.set_component(id, common::custom("game::Health", &[("2bad", "1.0")]));
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_issue(&report, id, "custom.Health.2bad", Severity::Error);
|
||||
assert!(has_message(&report, "not a valid Rust identifier"));
|
||||
|
||||
doc.set_component(id, common::custom("game::Health", &[("current", " ")]));
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_issue(&report, id, "custom.Health.current", Severity::Error);
|
||||
assert!(has_message(&report, "empty expression"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_cover_names_scale_materials_sprites_and_text() {
|
||||
let mut doc = SceneDoc::new("Warnings");
|
||||
|
||||
let empty_name = doc.add_entity("", None);
|
||||
doc.add_component(empty_name, ComponentData::Camera2d);
|
||||
|
||||
let duplicate_one = doc.add_entity("Same", None);
|
||||
doc.add_component(duplicate_one, ComponentData::Camera2d);
|
||||
let duplicate_two = doc.add_entity("Same", None);
|
||||
doc.add_component(duplicate_two, ComponentData::Camera2d);
|
||||
|
||||
let blank_name = doc.add_entity(" ", None);
|
||||
doc.add_component(blank_name, ComponentData::Camera2d);
|
||||
|
||||
let zero_scale = doc.add_entity("Zero Scale", None);
|
||||
doc.add_component(zero_scale, ComponentData::Camera2d);
|
||||
doc.entity_mut(zero_scale).unwrap().transform.scale = Vec3::new(1.0, 0.0, 1.0);
|
||||
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(
|
||||
mesh,
|
||||
ComponentData::Mesh3d(Mesh3dData {
|
||||
mesh: MeshSource::Primitive(PrimitiveShape::Sphere { radius: 1.0 }),
|
||||
material: MaterialData {
|
||||
base_color: Color::WHITE,
|
||||
metallic: 1.5,
|
||||
perceptual_roughness: -0.1,
|
||||
unlit: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let sprite = doc.add_entity("Sprite", None);
|
||||
doc.add_component(sprite, common::sprite(None, None));
|
||||
|
||||
let text = doc.add_entity("Text", None);
|
||||
doc.add_component(
|
||||
text,
|
||||
ComponentData::Text2d(Text2dData {
|
||||
text: " ".into(),
|
||||
font_size: 12.0,
|
||||
color: Color::WHITE,
|
||||
}),
|
||||
);
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.is_ok(), "{:#?}", report.issues);
|
||||
assert_eq!(report.errors(), 0);
|
||||
assert_eq!(report.warnings(), 9, "{:#?}", report.issues);
|
||||
|
||||
assert_issue(&report, empty_name, "name", Severity::Warning);
|
||||
assert_issue(&report, blank_name, "name", Severity::Warning);
|
||||
assert_issue(&report, duplicate_one, "name", Severity::Warning);
|
||||
assert_issue(&report, duplicate_two, "name", Severity::Warning);
|
||||
assert_issue(&report, zero_scale, "transform.scale", Severity::Warning);
|
||||
assert_issue(&report, mesh, "mesh3d.material.metallic", Severity::Warning);
|
||||
assert_issue(
|
||||
&report,
|
||||
mesh,
|
||||
"mesh3d.material.perceptual_roughness",
|
||||
Severity::Warning,
|
||||
);
|
||||
assert_issue(&report, sprite, "sprite", Severity::Warning);
|
||||
assert_issue(&report, text, "text2d.text", Severity::Warning);
|
||||
|
||||
assert!(report.by_entity(duplicate_one).len() == 1);
|
||||
assert!(has_message(&report, "not unique"));
|
||||
assert!(has_message(&report, "no image and no custom_size"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_and_light_rules_produce_infos_and_a_mix_warning() {
|
||||
let mut doc = SceneDoc::new("Cameras");
|
||||
let mesh = doc.add_entity("Mesh", None);
|
||||
doc.add_component(
|
||||
mesh,
|
||||
common::primitive_mesh(PrimitiveShape::Cuboid { size: Vec3::ONE }),
|
||||
);
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.is_ok());
|
||||
assert!(has_message(&report, "scene has no camera"));
|
||||
assert!(has_message(&report, "meshes but no light"));
|
||||
assert_eq!(report.count(Severity::Info), 2, "{:#?}", report.issues);
|
||||
|
||||
let camera = doc.add_entity("Camera 3D", None);
|
||||
doc.add_component(camera, ComponentData::Camera3d);
|
||||
let light = doc.add_entity("Light", None);
|
||||
doc.add_component(light, ComponentData::PointLight(PointLightData::default()));
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.is_ok());
|
||||
assert!(!has_message(&report, "scene has no camera"));
|
||||
assert!(!has_message(&report, "meshes but no light"));
|
||||
assert_eq!(report.warnings(), 0);
|
||||
assert_eq!(report.count(Severity::Info), 0, "{:#?}", report.issues);
|
||||
|
||||
let second_camera = doc.add_entity("Camera 2D", None);
|
||||
doc.add_component(second_camera, ComponentData::Camera2d);
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.is_ok());
|
||||
assert_eq!(report.warnings(), 1);
|
||||
assert!(has_message(&report, "mixes 2D and 3D cameras"));
|
||||
assert_eq!(report.count(Severity::Info), 1);
|
||||
assert!(has_message(&report, "scene has 2 cameras"));
|
||||
|
||||
let empty = SceneDoc::new("Empty");
|
||||
let empty_report = validate_scene(&empty, None);
|
||||
assert!(empty_report.is_ok());
|
||||
assert_eq!(empty_report.errors(), 0);
|
||||
assert_eq!(empty_report.warnings(), 0);
|
||||
assert_eq!(empty_report.count(Severity::Info), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_with_only_a_transform_is_reported_as_info() {
|
||||
let mut doc = SceneDoc::new("Bare");
|
||||
let id = doc.add_entity("Bare Entity", None);
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.is_ok());
|
||||
let issue = report
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.entity == Some(id))
|
||||
.expect("entity-specific issue");
|
||||
assert_eq!(issue.severity, Severity::Info);
|
||||
assert!(issue.message.contains("no components"));
|
||||
assert_eq!(issue.field, None);
|
||||
assert_eq!(doc.entity(id).unwrap().components.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_forests_do_not_panic_and_report_errors() {
|
||||
let mut doc = SceneDoc::new("Corrupt");
|
||||
let a = doc.add_entity("A", None);
|
||||
let b = doc.add_entity("B", Some(a));
|
||||
let c = doc.add_entity("C", Some(b));
|
||||
|
||||
// Self-parent, a parent cycle, a missing child, a missing root, a duplicate
|
||||
// root reference, and a node id that disagrees with its map key.
|
||||
doc.entity_mut(a).unwrap().parent = Some(a);
|
||||
doc.entity_mut(b).unwrap().parent = Some(c);
|
||||
doc.entity_mut(c).unwrap().parent = Some(b);
|
||||
doc.entity_mut(a).unwrap().children.push(999);
|
||||
doc.roots.push(999);
|
||||
doc.roots.push(a);
|
||||
doc.entity_mut(c).unwrap().id = 77;
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(!report.is_ok());
|
||||
assert!(report.errors() > 0);
|
||||
assert!(
|
||||
report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.field.as_deref() == Some("parent")),
|
||||
"{:#?}",
|
||||
report.issues
|
||||
);
|
||||
assert!(
|
||||
has_message(&report, "cycle") || has_message(&report, "referenced"),
|
||||
"{:#?}",
|
||||
report.issues
|
||||
);
|
||||
|
||||
let again = validate_scene(&doc, None);
|
||||
assert_eq!(report, again, "validation must be deterministic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_parent_chains_are_capped_without_looping_or_panicking() {
|
||||
let mut doc = SceneDoc::new("Deep");
|
||||
let mut parent = None;
|
||||
for index in 0..300 {
|
||||
parent = Some(doc.add_entity(format!("Node {index}"), parent));
|
||||
}
|
||||
assert!(doc.check_integrity().is_empty());
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert_eq!(report.errors(), 0, "{:#?}", report.issues);
|
||||
assert_eq!(report.warnings(), 0);
|
||||
assert!(report.count(Severity::Info) >= 300);
|
||||
assert!(
|
||||
!report
|
||||
.issues
|
||||
.iter()
|
||||
.any(|issue| issue.message.contains("cycle")),
|
||||
"the depth cap must not fabricate cycles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issues_are_sorted_by_severity_entity_and_message() {
|
||||
let mut doc = SceneDoc::new("Sorting");
|
||||
let empty_name = doc.add_entity("", None);
|
||||
doc.add_component(empty_name, ComponentData::Camera2d);
|
||||
let point = doc.add_entity("Point", None);
|
||||
doc.add_component(
|
||||
point,
|
||||
ComponentData::PointLight(PointLightData {
|
||||
color: Color::WHITE,
|
||||
intensity: -1.0,
|
||||
range: 20.0,
|
||||
shadows: false,
|
||||
}),
|
||||
);
|
||||
let bad_custom = doc.add_entity("Custom", None);
|
||||
doc.add_component(bad_custom, common::custom("not a path", &[]));
|
||||
let bad_transform = doc.add_entity("Transform", None);
|
||||
doc.add_component(bad_transform, ComponentData::Camera2d);
|
||||
doc.entity_mut(bad_transform)
|
||||
.unwrap()
|
||||
.transform
|
||||
.translation
|
||||
.x = f32::NAN;
|
||||
let sprite = doc.add_entity("Sprite", None);
|
||||
doc.add_component(sprite, common::sprite(None, None));
|
||||
|
||||
let report = validate_scene(&doc, None);
|
||||
assert!(report.errors() >= 3);
|
||||
assert!(report.warnings() >= 2);
|
||||
|
||||
for pair in report.issues.windows(2) {
|
||||
let (left, right) = (&pair[0], &pair[1]);
|
||||
assert!(
|
||||
left.severity >= right.severity,
|
||||
"severity order: {left:?} before {right:?}"
|
||||
);
|
||||
if left.severity == right.severity {
|
||||
assert!(
|
||||
left.entity <= right.entity,
|
||||
"entity order: {left:?} before {right:?}"
|
||||
);
|
||||
if left.entity == right.entity {
|
||||
assert!(
|
||||
left.message <= right.message,
|
||||
"message order: {left:?} before {right:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let again = validate_scene(&doc, None);
|
||||
assert_eq!(report, again);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_helpers_and_severity_ordering_are_public_contract() {
|
||||
assert!(Severity::Info < Severity::Warning);
|
||||
assert!(Severity::Warning < Severity::Error);
|
||||
assert_eq!(Severity::Info.label(), "info");
|
||||
assert_eq!(Severity::Warning.label(), "warning");
|
||||
assert_eq!(Severity::Error.label(), "error");
|
||||
|
||||
let report = ValidationReport {
|
||||
issues: vec![
|
||||
Issue::info("info without entity"),
|
||||
Issue::warning("warning for two").with_entity(2),
|
||||
Issue::error("error for one")
|
||||
.with_entity(1)
|
||||
.with_field("name"),
|
||||
Issue::error("scene-wide error"),
|
||||
],
|
||||
};
|
||||
assert!(!report.is_ok());
|
||||
assert_eq!(report.errors(), 2);
|
||||
assert_eq!(report.warnings(), 1);
|
||||
assert_eq!(report.count(Severity::Info), 1);
|
||||
assert_eq!(report.by_entity(1).len(), 1);
|
||||
assert_eq!(report.by_entity(1)[0].field.as_deref(), Some("name"));
|
||||
assert_eq!(report.by_entity(2).len(), 1);
|
||||
assert!(report.by_entity(3).is_empty());
|
||||
|
||||
let sorted = report.sorted();
|
||||
assert_eq!(sorted.len(), report.issues.len());
|
||||
assert_eq!(sorted[0].severity, Severity::Error);
|
||||
assert_eq!(sorted[1].severity, Severity::Error);
|
||||
assert_eq!(sorted[2].severity, Severity::Warning);
|
||||
assert_eq!(sorted[3].severity, Severity::Info);
|
||||
|
||||
let clean = ValidationReport::default();
|
||||
assert!(clean.is_ok());
|
||||
assert_eq!(clean.errors(), 0);
|
||||
assert_eq!(clean.warnings(), 0);
|
||||
assert!(clean.sorted().is_empty());
|
||||
}
|
||||
|
||||
// Keep these public constructors referenced so the test module also documents
|
||||
// that custom data is authored through the public component types.
|
||||
#[test]
|
||||
fn custom_component_public_constructors_are_inspectable() {
|
||||
let custom = CustomComponent {
|
||||
type_path: "game::Health".into(),
|
||||
fields: vec![CustomField {
|
||||
name: "current".into(),
|
||||
expr: "10.0".into(),
|
||||
}],
|
||||
};
|
||||
assert!(custom.is_valid());
|
||||
assert_eq!(custom.short_name(), "Health");
|
||||
let component = ComponentData::Custom(custom);
|
||||
assert_eq!(component.key(), "custom:game::Health");
|
||||
assert!(component.is_builtin() == false);
|
||||
assert_eq!(component.asset_references().len(), 0);
|
||||
assert_eq!(
|
||||
ComponentData::Visibility(VisibilityMode::Hidden).key(),
|
||||
"visibility"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "bevy_forge_editor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Bevy Forge — a visual scene editor for the Bevy engine (Bevy 0.20 BSN)"
|
||||
|
||||
[[bin]]
|
||||
name = "bevy-forge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bevy_forge_core = { path = "../bevy_forge_core" }
|
||||
eframe.workspace = true
|
||||
egui.workspace = true
|
||||
image.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Deferred editor actions emitted by panels and handled in one place by the app.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, ProjectTemplate};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::state::ViewMode;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
NewScene(ProjectTemplate),
|
||||
OpenProjectDialog,
|
||||
OpenProject(PathBuf),
|
||||
OpenSceneDialog,
|
||||
OpenScene(PathBuf),
|
||||
Save,
|
||||
OpenSaveAsDialog,
|
||||
SaveSceneTo(PathBuf),
|
||||
OpenExportDialog(ExportFormat),
|
||||
ExportTo { format: ExportFormat, path: PathBuf },
|
||||
CopyExport(ExportFormat),
|
||||
Undo,
|
||||
Redo,
|
||||
DeleteSelection,
|
||||
DuplicateSelection,
|
||||
FocusSelection,
|
||||
SetViewMode(ViewMode),
|
||||
ToggleViewMode,
|
||||
FitView,
|
||||
ToggleGrid,
|
||||
ToggleSnap,
|
||||
ToggleAssets,
|
||||
ToggleLeftPanel,
|
||||
ToggleRightPanel,
|
||||
OpenCommandPalette,
|
||||
ShowShortcuts,
|
||||
ShowAbout,
|
||||
RequestQuit,
|
||||
QuitNow,
|
||||
ReloadAssets,
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
//! Application shell: owns state, orders panels, dispatches deferred actions,
|
||||
//! handles saving/exporting and the unsaved-changes close guard.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bevy_forge_core::{
|
||||
samples, AssetIndex, EditorConfig, ExportFormat, Project, ProjectTemplate, SceneDoc, Session,
|
||||
};
|
||||
use eframe::egui;
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::assets_panel::AssetsPanelState;
|
||||
use crate::dialogs::DialogsState;
|
||||
use crate::hierarchy::HierarchyPanelState;
|
||||
use crate::inspector::InspectorPanelState;
|
||||
use crate::palette::PaletteState;
|
||||
use crate::state::{EditorContext, ExportState, Screen, UiState, ViewMode};
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
use crate::viewport2d::Viewport2dState;
|
||||
use crate::viewport3d::Viewport3dState;
|
||||
use crate::welcome::WelcomeState;
|
||||
use crate::{
|
||||
assets_panel, dialogs, hierarchy, inspector, menu, palette, shortcuts, status_bar, theme,
|
||||
viewport2d, viewport3d, welcome, widgets,
|
||||
};
|
||||
|
||||
pub struct ForgeApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub project: Option<Project>,
|
||||
pub assets: AssetIndex,
|
||||
pub textures: TextureCache,
|
||||
pub toasts: Toasts,
|
||||
pub config: EditorConfig,
|
||||
pub export: ExportState,
|
||||
pub screen: Screen,
|
||||
pub hierarchy: HierarchyPanelState,
|
||||
pub inspector: InspectorPanelState,
|
||||
pub assets_panel: AssetsPanelState,
|
||||
pub viewport2d: Viewport2dState,
|
||||
pub viewport3d: Viewport3dState,
|
||||
pub palette: PaletteState,
|
||||
pub dialogs: DialogsState,
|
||||
pub welcome: WelcomeState,
|
||||
pub current_scene_path: Option<PathBuf>,
|
||||
pub last_title: String,
|
||||
pub allow_close: bool,
|
||||
pub quit_now: bool,
|
||||
last_autosave: Option<Instant>,
|
||||
/// Navigation interrupted by the unsaved-changes prompt.
|
||||
pending_navigation: Option<PendingNavigation>,
|
||||
/// If set, capture a PNG after the UI settles and quit (CI/screenshot mode).
|
||||
pub screenshot_path: Option<PathBuf>,
|
||||
screenshot_frames: u32,
|
||||
screenshot_requested: bool,
|
||||
}
|
||||
|
||||
impl ForgeApp {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, initial: Option<PathBuf>) -> Self {
|
||||
theme::apply(&cc.egui_ctx);
|
||||
let config = EditorConfig::load_lossy();
|
||||
let mut app = Self::from_parts(SceneDoc::new("Untitled"), config);
|
||||
if let Some(initial) = initial {
|
||||
if initial.is_dir() {
|
||||
if !app.open_project(&initial) {
|
||||
app.screen = Screen::Welcome;
|
||||
}
|
||||
} else {
|
||||
match bevy_forge_core::project::find_project_root(&initial) {
|
||||
Some(root) => {
|
||||
if app.open_project(&root) {
|
||||
app.open_scene(&initial);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
app.toasts.error(format!(
|
||||
"Could not find a Bevy project containing {}",
|
||||
initial.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(last) = app.config.last_project.clone() {
|
||||
app.welcome.project_path = last.display().to_string();
|
||||
}
|
||||
app.screenshot_path = std::env::var_os("BEVY_FORGE_SCREENSHOT")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
if app.screenshot_path.is_some() {
|
||||
// Screenshot mode: never paint a pointer tooltip over the clean UI.
|
||||
cc.egui_ctx.style_mut(|style| {
|
||||
style.interaction.tooltip_delay = f32::INFINITY;
|
||||
});
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// Construct an editor with a preloaded scene, for headless UI tests.
|
||||
pub fn new_for_test(ctx: &egui::Context, doc: SceneDoc) -> Self {
|
||||
theme::apply(ctx);
|
||||
let mut app = Self::from_parts(doc, EditorConfig::default());
|
||||
app.screen = Screen::Editor;
|
||||
app
|
||||
}
|
||||
|
||||
fn from_parts(doc: SceneDoc, config: EditorConfig) -> Self {
|
||||
let mut ui = UiState::default();
|
||||
ui.show_grid = config.show_grid;
|
||||
ui.snap_to_grid = config.snap_to_grid;
|
||||
ui.show_assets = config.show_assets;
|
||||
ui.grid_size = config.grid_size;
|
||||
Self {
|
||||
session: Session::new(doc),
|
||||
ui,
|
||||
project: None,
|
||||
assets: AssetIndex::default(),
|
||||
textures: TextureCache::new(),
|
||||
toasts: Toasts::new(),
|
||||
config,
|
||||
export: ExportState::default(),
|
||||
screen: Screen::Welcome,
|
||||
hierarchy: HierarchyPanelState::default(),
|
||||
inspector: InspectorPanelState::default(),
|
||||
assets_panel: AssetsPanelState::default(),
|
||||
viewport2d: Viewport2dState::default(),
|
||||
viewport3d: Viewport3dState::default(),
|
||||
palette: PaletteState::default(),
|
||||
dialogs: DialogsState::default(),
|
||||
welcome: WelcomeState::default(),
|
||||
current_scene_path: None,
|
||||
last_title: String::new(),
|
||||
allow_close: false,
|
||||
quit_now: false,
|
||||
last_autosave: Some(Instant::now()),
|
||||
pending_navigation: None,
|
||||
screenshot_path: None,
|
||||
screenshot_frames: 0,
|
||||
screenshot_requested: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Project & scene lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
pub fn open_project(&mut self, path: &Path) -> bool {
|
||||
match Project::open(path) {
|
||||
Ok(project) => {
|
||||
self.assets = AssetIndex::scan(&project.assets_dir);
|
||||
self.textures.clear();
|
||||
self.config.push_recent(&project.root);
|
||||
let _ = self.config.save();
|
||||
self.project = Some(project);
|
||||
self.current_scene_path = None;
|
||||
self.screen = Screen::Editor;
|
||||
self.ui.selection.clear();
|
||||
self.ui.error_banner = None;
|
||||
let first_scene = self
|
||||
.project
|
||||
.as_ref()
|
||||
.and_then(|project| project.list_scenes().into_iter().next());
|
||||
if let Some(scene) = first_scene {
|
||||
if !self.open_scene(&scene) {
|
||||
// Never leave the previous project's document paired
|
||||
// with the newly opened project after a failed load.
|
||||
self.session.replace(samples::empty_scene());
|
||||
self.current_scene_path = None;
|
||||
self.ui.selection.clear();
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.status = "Could not open the project's first scene".into();
|
||||
}
|
||||
} else {
|
||||
self.session.replace(samples::empty_scene());
|
||||
self.current_scene_path = None;
|
||||
self.ui.selection.clear();
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.status = "New empty scene".into();
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.toasts.error(error.to_string());
|
||||
self.welcome.error = Some(error.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_scene(&mut self, path: &Path) -> bool {
|
||||
let Some(project) = &self.project else {
|
||||
self.toasts
|
||||
.error("Open a Bevy project before opening a scene");
|
||||
return false;
|
||||
};
|
||||
match project.load_scene(path) {
|
||||
Ok(doc) => {
|
||||
self.session.replace(doc);
|
||||
self.current_scene_path = Some(path.to_path_buf());
|
||||
self.ui.selection.clear();
|
||||
if let Some(id) = choose_initial_selection(self.session.doc()) {
|
||||
self.ui.selection.select_only(id);
|
||||
self.hierarchy.scroll_to = Some(id);
|
||||
}
|
||||
self.screen = Screen::Editor;
|
||||
self.apply_auto_view_mode();
|
||||
// 2D auto-frames new scenes. 3D keeps its pleasant default orbit
|
||||
// camera; F/Fit frames the whole scene on demand.
|
||||
self.ui.fit_view_requested = false;
|
||||
self.ui.status = format!("Opened {}", path.display());
|
||||
self.toasts.success(format!("Opened {}", file_name(path)));
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.ui.error_banner = Some(error.to_string());
|
||||
self.toasts.error(error.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_scene(&mut self, template: ProjectTemplate) {
|
||||
let doc = samples::for_template(template);
|
||||
self.session.replace(doc);
|
||||
self.current_scene_path = None;
|
||||
self.screen = Screen::Editor;
|
||||
self.ui.selection.clear();
|
||||
if let Some(id) = choose_initial_selection(self.session.doc()) {
|
||||
self.ui.selection.select_only(id);
|
||||
self.hierarchy.scroll_to = Some(id);
|
||||
}
|
||||
self.apply_auto_view_mode();
|
||||
self.ui.fit_view_requested = false;
|
||||
self.ui.status = "New scene — remember to save".into();
|
||||
}
|
||||
|
||||
pub fn save_scene(&mut self) {
|
||||
let Some(project) = &self.project else {
|
||||
self.open_save_as_dialog();
|
||||
return;
|
||||
};
|
||||
let path = self
|
||||
.current_scene_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.scene_path(&self.session.doc().name));
|
||||
self.save_scene_to(&path);
|
||||
}
|
||||
|
||||
pub fn save_scene_to(&mut self, path: &Path) {
|
||||
let Some(project) = &self.project else {
|
||||
self.toasts
|
||||
.error("Open a Bevy project before saving scenes");
|
||||
return;
|
||||
};
|
||||
let mut path = path.to_path_buf();
|
||||
if path.is_relative() {
|
||||
path = project.root.join(&path);
|
||||
}
|
||||
if !path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.ends_with(".forge.ron"))
|
||||
{
|
||||
self.toasts.error("Scene files must end with .forge.ron");
|
||||
self.ui.error_banner = Some("Scene files must end with .forge.ron".into());
|
||||
return;
|
||||
}
|
||||
match project.save_scene(self.session.doc(), Some(&path)) {
|
||||
Ok(written) => {
|
||||
self.session.mark_saved();
|
||||
self.current_scene_path = Some(written.clone());
|
||||
self.ui.status = format!("Saved {}", written.display());
|
||||
self.toasts
|
||||
.success(format!("Saved {}", file_name(&written)));
|
||||
}
|
||||
Err(error) => self.toasts.error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_save_as_dialog(&mut self) {
|
||||
let default = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.scene_path(&self.session.doc().name))
|
||||
.unwrap_or_else(|| {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(format!("{}.forge.ron", slug(&self.session.doc().name)))
|
||||
});
|
||||
self.dialogs.save_as_path = default.display().to_string();
|
||||
self.dialogs.save_as_open = true;
|
||||
}
|
||||
|
||||
pub fn export(&mut self, format: ExportFormat, path: &Path) {
|
||||
self.export.format = format;
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: self.export.function_name.clone(),
|
||||
include_header: true,
|
||||
};
|
||||
match format.generate(self.session.doc(), &options) {
|
||||
Ok(code) => {
|
||||
let path = if path.is_relative() {
|
||||
self.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.join(path))
|
||||
.unwrap_or_else(|| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(error) = std::fs::create_dir_all(parent) {
|
||||
self.toasts
|
||||
.error(format!("Could not create export folder: {error}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
match std::fs::write(&path, code) {
|
||||
Ok(()) => {
|
||||
self.toasts
|
||||
.success(format!("Exported {}", file_name(&path)));
|
||||
self.ui.status = format!("Exported {}", path.display());
|
||||
self.export.dialog_open = false;
|
||||
}
|
||||
Err(error) => {
|
||||
self.export.error = Some(error.to_string());
|
||||
self.toasts.error(format!("Export failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.export.error = Some(error.to_string());
|
||||
self.toasts.error(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_export_dialog(&mut self, format: ExportFormat) {
|
||||
self.export.format = format;
|
||||
self.export.function_name = function_name_for(&self.session.doc().name);
|
||||
let path = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.join("export"))
|
||||
.unwrap_or_else(|| PathBuf::from("export"));
|
||||
self.export.path = path
|
||||
.join(format!("{}.rs", self.export.function_name))
|
||||
.display()
|
||||
.to_string();
|
||||
self.export.regenerate(self.session.doc());
|
||||
self.export.dialog_open = true;
|
||||
}
|
||||
|
||||
pub fn copy_export(&mut self, ctx: &egui::Context, format: ExportFormat) {
|
||||
let name = if self.export.function_name.trim().is_empty() {
|
||||
function_name_for(&self.session.doc().name)
|
||||
} else {
|
||||
self.export.function_name.clone()
|
||||
};
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: name,
|
||||
include_header: true,
|
||||
};
|
||||
match format.generate(self.session.doc(), &options) {
|
||||
Ok(code) => {
|
||||
ctx.copy_text(code);
|
||||
self.toasts.success("Copied generated code");
|
||||
}
|
||||
Err(error) => self.toasts.error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_auto_view_mode(&mut self) {
|
||||
let doc = self.session.doc();
|
||||
let is_3d = doc.entities.values().any(|node| {
|
||||
node.components.iter().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
bevy_forge_core::ComponentData::Mesh3d(_)
|
||||
| bevy_forge_core::ComponentData::Camera3d
|
||||
| bevy_forge_core::ComponentData::DirectionalLight(_)
|
||||
)
|
||||
})
|
||||
});
|
||||
self.ui.view_mode = if is_3d {
|
||||
ViewMode::ThreeD
|
||||
} else {
|
||||
ViewMode::TwoD
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Actions
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn delete_selection(&mut self) {
|
||||
let ids: Vec<_> = self.ui.selection.ids().collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.session.edit("Delete entities", |doc| {
|
||||
for id in ids {
|
||||
doc.remove_entity(id);
|
||||
}
|
||||
});
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = "Deleted entities".into();
|
||||
}
|
||||
|
||||
fn duplicate_selection(&mut self) {
|
||||
let ids: Vec<_> = self.ui.selection.ids().collect();
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut new_ids = Vec::new();
|
||||
self.session.edit("Duplicate entities", |doc| {
|
||||
for id in ids {
|
||||
if let Some(new_id) = doc.duplicate_entity(id) {
|
||||
new_ids.push(new_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(primary) = new_ids.first().copied() {
|
||||
self.ui.selection.select_many(new_ids, Some(primary));
|
||||
}
|
||||
self.ui.status = "Duplicated entities".into();
|
||||
}
|
||||
|
||||
fn request_navigation(&mut self, navigation: PendingNavigation) {
|
||||
if self.screen == Screen::Editor && self.session.is_dirty() {
|
||||
self.pending_navigation = Some(navigation);
|
||||
self.dialogs.confirm_quit_open = false;
|
||||
} else {
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_navigation(&mut self, navigation: PendingNavigation) {
|
||||
match navigation {
|
||||
PendingNavigation::NewScene(template) => self.new_scene(template),
|
||||
PendingNavigation::OpenProject(path) => {
|
||||
self.open_project(&path);
|
||||
}
|
||||
PendingNavigation::OpenScene(path) => {
|
||||
self.open_scene(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_navigation_ui(&mut self, ctx: &egui::Context) {
|
||||
let Some(navigation) = self.pending_navigation.clone() else {
|
||||
return;
|
||||
};
|
||||
let action = match &navigation {
|
||||
PendingNavigation::NewScene(_) => "Create a new scene",
|
||||
PendingNavigation::OpenProject(_) => "Open another project",
|
||||
PendingNavigation::OpenScene(_) => "Open another scene",
|
||||
};
|
||||
let can_save = self.project.is_some() && self.current_scene_path.is_some();
|
||||
let mut discard = false;
|
||||
let mut save_and_continue = false;
|
||||
let mut cancel = false;
|
||||
let response = egui::Modal::new(egui::Id::new("unsaved_navigation")).show(ctx, |ui| {
|
||||
ui.set_width(440.0);
|
||||
ui.heading("Unsaved changes");
|
||||
ui.add_space(4.0);
|
||||
ui.label(format!(
|
||||
"The current scene has unsaved changes and its undo history will be lost. {action} anyway?"
|
||||
));
|
||||
if !can_save {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
egui::RichText::new("This scene has no saved file yet; use Save As first if you want to keep it.")
|
||||
.color(theme::MUTED)
|
||||
.size(11.5),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if can_save
|
||||
&& ui
|
||||
.button("Save and continue")
|
||||
.on_hover_text("Save the current scene, then continue")
|
||||
.clicked()
|
||||
{
|
||||
save_and_continue = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.add(widgets::danger_button("Discard and continue")).clicked() {
|
||||
discard = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancel = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if response.should_close() && !discard && !save_and_continue {
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
self.pending_navigation = None;
|
||||
self.ui.status = "Navigation cancelled".into();
|
||||
}
|
||||
if save_and_continue {
|
||||
self.save_scene();
|
||||
if !self.session.is_dirty() {
|
||||
self.pending_navigation = None;
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if discard {
|
||||
self.pending_navigation = None;
|
||||
self.perform_navigation(navigation);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_actions(&mut self, ctx: &egui::Context, actions: Vec<Action>) {
|
||||
// Keep only the last view-mode action if a menu produced several.
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::NewScene(template) => {
|
||||
self.request_navigation(PendingNavigation::NewScene(template))
|
||||
}
|
||||
Action::OpenProjectDialog => {
|
||||
self.dialogs.open_project_open = true;
|
||||
if self.welcome.project_path.is_empty() {
|
||||
self.welcome.project_path = self
|
||||
.project
|
||||
.as_ref()
|
||||
.map(|project| project.root.display().to_string())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
self.dialogs.open_project_path = self.welcome.project_path.clone();
|
||||
}
|
||||
Action::OpenProject(path) => {
|
||||
self.request_navigation(PendingNavigation::OpenProject(path))
|
||||
}
|
||||
Action::OpenSceneDialog => {
|
||||
self.dialogs.open_scene_path = self
|
||||
.current_scene_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_default();
|
||||
self.dialogs.open_scene_open = true;
|
||||
}
|
||||
Action::OpenScene(path) => {
|
||||
self.request_navigation(PendingNavigation::OpenScene(path))
|
||||
}
|
||||
Action::Save => self.save_scene(),
|
||||
Action::OpenSaveAsDialog => self.open_save_as_dialog(),
|
||||
Action::SaveSceneTo(path) => {
|
||||
self.dialogs.save_as_open = false;
|
||||
self.save_scene_to(&path);
|
||||
}
|
||||
Action::OpenExportDialog(format) => self.open_export_dialog(format),
|
||||
Action::ExportTo { format, path } => {
|
||||
self.export(format, &path);
|
||||
}
|
||||
Action::CopyExport(format) => self.copy_export(ctx, format),
|
||||
Action::Undo => {
|
||||
if let Some(label) = self.session.undo() {
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = format!("Undid {label}");
|
||||
}
|
||||
}
|
||||
Action::Redo => {
|
||||
if let Some(label) = self.session.redo() {
|
||||
self.ui.selection.retain_existing(self.session.doc());
|
||||
self.ui.status = format!("Redid {label}");
|
||||
}
|
||||
}
|
||||
Action::DeleteSelection => self.delete_selection(),
|
||||
Action::DuplicateSelection => self.duplicate_selection(),
|
||||
Action::FocusSelection => {
|
||||
self.ui.fit_view_requested = true;
|
||||
}
|
||||
Action::SetViewMode(mode) => self.ui.view_mode = mode,
|
||||
Action::ToggleViewMode => self.ui.view_mode = self.ui.view_mode.toggled(),
|
||||
Action::FitView => self.ui.fit_view_requested = true,
|
||||
Action::ToggleGrid => {
|
||||
self.ui.show_grid = !self.ui.show_grid;
|
||||
self.config.show_grid = self.ui.show_grid;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleSnap => {
|
||||
self.ui.snap_to_grid = !self.ui.snap_to_grid;
|
||||
self.config.snap_to_grid = self.ui.snap_to_grid;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleAssets => {
|
||||
self.ui.show_assets = !self.ui.show_assets;
|
||||
self.config.show_assets = self.ui.show_assets;
|
||||
let _ = self.config.save();
|
||||
}
|
||||
Action::ToggleLeftPanel => self.ui.show_left_panel = !self.ui.show_left_panel,
|
||||
Action::ToggleRightPanel => self.ui.show_right_panel = !self.ui.show_right_panel,
|
||||
Action::OpenCommandPalette => {
|
||||
self.palette.open = true;
|
||||
self.palette.query.clear();
|
||||
self.palette.selected = 0;
|
||||
}
|
||||
Action::ShowShortcuts => self.dialogs.shortcuts_open = true,
|
||||
Action::ShowAbout => self.dialogs.about_open = true,
|
||||
Action::RequestQuit => {
|
||||
if self.session.is_dirty() {
|
||||
self.dialogs.confirm_quit_open = true;
|
||||
} else {
|
||||
self.quit_now = true;
|
||||
}
|
||||
}
|
||||
Action::QuitNow => {
|
||||
self.allow_close = true;
|
||||
self.quit_now = true;
|
||||
}
|
||||
Action::ReloadAssets => {
|
||||
if let Some(project) = &self.project {
|
||||
self.assets = AssetIndex::scan(&project.assets_dir);
|
||||
self.toasts
|
||||
.success(format!("Indexed {} assets", self.assets.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn editor_ui(&mut self, ctx: &egui::Context) {
|
||||
let mut actions = Vec::new();
|
||||
let modal_open = self.palette.open
|
||||
|| self.dialogs.save_as_open
|
||||
|| self.dialogs.open_project_open
|
||||
|| self.dialogs.open_scene_open
|
||||
|| self.dialogs.confirm_quit_open
|
||||
|| self.dialogs.shortcuts_open
|
||||
|| self.dialogs.about_open
|
||||
|| self.export.dialog_open
|
||||
|| self.pending_navigation.is_some();
|
||||
let mut cx = EditorContext {
|
||||
session: &mut self.session,
|
||||
project: self.project.as_ref(),
|
||||
assets: &self.assets,
|
||||
textures: &mut self.textures,
|
||||
ui: &mut self.ui,
|
||||
toasts: &mut self.toasts,
|
||||
export: &mut self.export,
|
||||
};
|
||||
|
||||
actions.extend(menu::show(ctx, &cx));
|
||||
if let Some(error) = cx.ui.error_banner.clone() {
|
||||
let mut dismiss = false;
|
||||
egui::TopBottomPanel::top("error_banner")
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgb(0x3a, 0x1f, 0x24))
|
||||
.inner_margin(egui::Margin::symmetric(10, 5)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("⚠").color(theme::ERROR).strong());
|
||||
ui.label(egui::RichText::new(error).color(theme::TEXT).size(12.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Dismiss").clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if dismiss {
|
||||
cx.ui.error_banner = None;
|
||||
}
|
||||
}
|
||||
if !modal_open {
|
||||
actions.extend(shortcuts::handle(ctx, cx.session));
|
||||
}
|
||||
status_bar::show(ctx, &cx);
|
||||
|
||||
// Side panels come before the asset drawer so they keep the full editor
|
||||
// height; the drawer then occupies only the central column. This keeps
|
||||
// the selected hierarchy row and the inspector reachable at 1440x900
|
||||
// and at the 1000x640 minimum size.
|
||||
if cx.ui.show_left_panel {
|
||||
actions.extend(hierarchy::show(ctx, &mut cx, &mut self.hierarchy));
|
||||
}
|
||||
if cx.ui.show_right_panel {
|
||||
actions.extend(inspector::show(ctx, &mut cx, &mut self.inspector));
|
||||
}
|
||||
actions.extend(assets_panel::show(ctx, &mut cx, &mut self.assets_panel));
|
||||
|
||||
let mode = cx.ui.view_mode;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::new().fill(theme::BG))
|
||||
.show(ctx, |ui| match mode {
|
||||
ViewMode::TwoD => {
|
||||
actions.extend(viewport2d::show(ui, &mut cx, &mut self.viewport2d));
|
||||
}
|
||||
ViewMode::ThreeD => {
|
||||
actions.extend(viewport3d::show(ui, &mut cx, &mut self.viewport3d));
|
||||
}
|
||||
});
|
||||
|
||||
actions.extend(palette::show(ctx, &mut cx, &mut self.palette));
|
||||
actions.extend(dialogs::show(ctx, &mut cx, &mut self.dialogs));
|
||||
drop(cx);
|
||||
|
||||
self.handle_actions(ctx, actions);
|
||||
self.autosave();
|
||||
}
|
||||
|
||||
fn welcome_ui(&mut self, ctx: &egui::Context) {
|
||||
let actions = welcome::show(ctx, &mut self.welcome, &self.config);
|
||||
self.handle_actions(ctx, actions);
|
||||
}
|
||||
|
||||
fn autosave(&mut self) {
|
||||
if self.config.autosave_secs == 0 || !self.session.is_dirty() {
|
||||
return;
|
||||
}
|
||||
let Some(project) = &self.project else {
|
||||
return;
|
||||
};
|
||||
let due = self
|
||||
.last_autosave
|
||||
.map(|last| last.elapsed().as_secs() >= self.config.autosave_secs)
|
||||
.unwrap_or(true);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
let path = project.autosave_path();
|
||||
if project.save_scene(self.session.doc(), Some(&path)).is_ok() {
|
||||
self.last_autosave = Some(Instant::now());
|
||||
self.ui.status = format!("Autosaved to {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn update_title(&mut self, ctx: &egui::Context) {
|
||||
let title = match (&self.project, self.screen) {
|
||||
(Some(project), Screen::Editor) => format!(
|
||||
"Bevy Forge — {} — {}{}",
|
||||
project.name,
|
||||
self.session.doc().name,
|
||||
if self.session.is_dirty() { " *" } else { "" }
|
||||
),
|
||||
_ => "Bevy Forge — Bevy scene editor".to_string(),
|
||||
};
|
||||
if title != self.last_title {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.last_title = title;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_screenshot(&mut self, ctx: &egui::Context) {
|
||||
let Some(path) = self.screenshot_path.clone() else {
|
||||
return;
|
||||
};
|
||||
if ctx.input(|input| {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| matches!(event, egui::Event::Screenshot { .. }))
|
||||
}) {
|
||||
let image = ctx.input(|input| {
|
||||
input.events.iter().find_map(|event| match event {
|
||||
egui::Event::Screenshot { image, .. } => Some(image.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(image) = image {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let result = image::save_buffer_with_format(
|
||||
&path,
|
||||
image.as_raw(),
|
||||
image.width() as u32,
|
||||
image.height() as u32,
|
||||
image::ColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
);
|
||||
match result {
|
||||
Ok(()) => eprintln!("bevy-forge: screenshot written to {}", path.display()),
|
||||
Err(error) => eprintln!("bevy-forge: screenshot failed: {error}"),
|
||||
}
|
||||
}
|
||||
self.screenshot_path = None;
|
||||
self.allow_close = true;
|
||||
self.quit_now = true;
|
||||
return;
|
||||
}
|
||||
self.screenshot_frames = self.screenshot_frames.saturating_add(1);
|
||||
if self.screenshot_frames == 30 && !self.screenshot_requested {
|
||||
self.screenshot_requested = true;
|
||||
// Screenshot mode is for docs/CI: no transient toasts in the frame.
|
||||
self.toasts.clear();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||
}
|
||||
if self.screenshot_frames < 32 {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_close_request(&mut self, ctx: &egui::Context) {
|
||||
if ctx.input(|input| input.viewport().close_requested())
|
||||
&& self.session.is_dirty()
|
||||
&& !self.allow_close
|
||||
&& self.screen == Screen::Editor
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.dialogs.confirm_quit_open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ForgeApp {
|
||||
/// Render one frame. Exposed so headless tests can drive the full UI without
|
||||
/// an eframe window or GPU.
|
||||
pub fn ui(&mut self, ctx: &egui::Context) {
|
||||
match self.screen {
|
||||
Screen::Welcome => self.welcome_ui(ctx),
|
||||
Screen::Editor => self.editor_ui(ctx),
|
||||
}
|
||||
self.pending_navigation_ui(ctx);
|
||||
self.toasts.show(ctx);
|
||||
// Autosave is time-based; without a periodic repaint an idle window
|
||||
// would never run the timer.
|
||||
if self.session.is_dirty() && self.project.is_some() && self.config.autosave_secs > 0 {
|
||||
ctx.request_repaint_after(Duration::from_secs(1));
|
||||
}
|
||||
self.update_title(ctx);
|
||||
}
|
||||
|
||||
/// Dispatch a single deferred action (used by the palette, tests and menus).
|
||||
pub fn dispatch_action(&mut self, ctx: &egui::Context, action: Action) {
|
||||
self.handle_actions(ctx, vec![action]);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for ForgeApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.handle_close_request(ctx);
|
||||
self.ui(ctx);
|
||||
self.handle_screenshot(ctx);
|
||||
if self.quit_now {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PendingNavigation {
|
||||
NewScene(ProjectTemplate),
|
||||
OpenProject(PathBuf),
|
||||
OpenScene(PathBuf),
|
||||
}
|
||||
|
||||
fn file_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
bevy_forge_core::project::slugify(name)
|
||||
}
|
||||
|
||||
/// Pick the entity that makes the first frame most informative: a named content
|
||||
/// entity (mesh/sprite) rather than a camera, a light or a giant backdrop.
|
||||
fn choose_initial_selection(doc: &bevy_forge_core::SceneDoc) -> Option<bevy_forge_core::EntityId> {
|
||||
use bevy_forge_core::ComponentData;
|
||||
|
||||
let mut best: Option<(i32, bevy_forge_core::EntityId)> = None;
|
||||
for node in doc.entities.values() {
|
||||
if node.is_hidden() {
|
||||
continue;
|
||||
}
|
||||
let mut score = 0;
|
||||
let mut area = 0.0;
|
||||
for component in &node.components {
|
||||
match component {
|
||||
ComponentData::Camera2d | ComponentData::Camera3d => score -= 120,
|
||||
ComponentData::DirectionalLight(_) | ComponentData::PointLight(_) => score -= 30,
|
||||
ComponentData::Mesh3d(_) => score += 50,
|
||||
ComponentData::Sprite(sprite) => {
|
||||
score += 40;
|
||||
if let Some(size) = sprite.custom_size {
|
||||
area = (size.x * size.y).abs();
|
||||
}
|
||||
}
|
||||
ComponentData::Text2d(_) => score += 15,
|
||||
ComponentData::Custom(_) => score += 20,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !node.children.is_empty() {
|
||||
score += 30;
|
||||
}
|
||||
if node.name.eq_ignore_ascii_case("player")
|
||||
|| node.name.to_ascii_lowercase().contains("crystal")
|
||||
{
|
||||
score += 100;
|
||||
}
|
||||
if area > 50_000.0 {
|
||||
score -= 60;
|
||||
}
|
||||
if score <= 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = (score, node.id);
|
||||
if best
|
||||
.map(|(best_score, _)| score > best_score)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn function_name_for(scene_name: &str) -> String {
|
||||
let mut name = slug(scene_name).replace('-', "_");
|
||||
if name
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
name = format!("scene_{name}");
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_project() -> (tempfile::TempDir, Project) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"autosave-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("assets")).unwrap();
|
||||
let project = Project::open(dir.path()).unwrap();
|
||||
(dir, project)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosave_writes_when_due_and_dirty() {
|
||||
let ctx = egui::Context::default();
|
||||
let (_dir, project) = temp_project();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Autosave"));
|
||||
app.assets = AssetIndex::scan(&project.assets_dir);
|
||||
app.project = Some(project);
|
||||
app.session.edit("add", |doc| {
|
||||
doc.add_entity("Root", None);
|
||||
});
|
||||
app.config.autosave_secs = 1;
|
||||
app.last_autosave = Some(Instant::now() - Duration::from_secs(5));
|
||||
app.autosave();
|
||||
let path = app.project.as_ref().unwrap().autosave_path();
|
||||
assert!(path.is_file(), "autosave file should exist at {path:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_navigation_is_prompted_before_replacing_the_scene() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, SceneDoc::new("Dirty"));
|
||||
app.session.edit("add", |doc| {
|
||||
doc.add_entity("Keep me", None);
|
||||
});
|
||||
app.request_navigation(PendingNavigation::NewScene(ProjectTemplate::Empty));
|
||||
assert!(app.pending_navigation.is_some());
|
||||
assert_eq!(app.session.doc().entities.len(), 1);
|
||||
assert_eq!(app.session.doc().name, "Dirty");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
//! Bevy Forge editor library. Exposed for headless panel tests.
|
||||
|
||||
pub mod action;
|
||||
pub mod app;
|
||||
pub mod assets_panel;
|
||||
pub mod dialogs;
|
||||
pub mod hierarchy;
|
||||
pub mod inspector;
|
||||
pub mod menu;
|
||||
pub mod palette;
|
||||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
pub mod status_bar;
|
||||
pub mod texture_cache;
|
||||
pub mod theme;
|
||||
pub mod toast;
|
||||
pub mod viewport2d;
|
||||
pub mod viewport3d;
|
||||
pub mod welcome;
|
||||
pub mod widgets;
|
||||
|
||||
pub use action::Action;
|
||||
pub use app::ForgeApp;
|
||||
pub use state::{Screen, Selection, UiState, ViewMode};
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let initial = std::env::args_os().nth(1).map(PathBuf::from);
|
||||
let size = std::env::var("BEVY_FORGE_WINDOW_SIZE")
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
Some([
|
||||
width.trim().parse::<f32>().ok()?,
|
||||
height.trim().parse::<f32>().ok()?,
|
||||
])
|
||||
})
|
||||
.unwrap_or([1440.0, 900.0]);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size(size)
|
||||
.with_min_inner_size([1000.0_f32, 640.0_f32])
|
||||
.with_title("Bevy Forge")
|
||||
.with_app_id("bevy-forge"),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Bevy Forge",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
bevy_forge_editor::theme::apply(&cc.egui_ctx);
|
||||
Ok(Box::new(bevy_forge_editor::ForgeApp::new(cc, initial)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Top menu bar: File, Edit, View and Help menus plus the project/scene badge.
|
||||
//!
|
||||
//! Menu entries only emit deferred [`Action`]s; the app owns all state changes.
|
||||
//! The context is borrowed immutably, so toggles edit local copies and emit the
|
||||
//! matching toggle action. Every action item closes its menu with `ui.close()`.
|
||||
//!
|
||||
//! Shortcut hints use [`egui::Modifiers::COMMAND`] conventions (`Ctrl` on
|
||||
//! Linux/Windows, `Cmd` on macOS) and match [`crate::shortcuts`].
|
||||
|
||||
use bevy_forge_core::{ExportFormat, ProjectTemplate};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::{EditorContext, ViewMode};
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
/// Label of the platform's primary command modifier.
|
||||
const COMMAND: &str = if cfg!(target_os = "macos") {
|
||||
"Cmd"
|
||||
} else {
|
||||
"Ctrl"
|
||||
};
|
||||
|
||||
pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
egui::TopBottomPanel::top("menu_bar")
|
||||
.exact_height(32.0)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(10, 4)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("◊ Bevy Forge")
|
||||
.color(theme::ACCENT)
|
||||
.strong()
|
||||
.size(14.0),
|
||||
)
|
||||
.on_hover_text("Bevy Forge — visual scene editor for Bevy 0.20 BSN");
|
||||
ui.separator();
|
||||
|
||||
file_menu(ui, cx, &mut actions);
|
||||
edit_menu(ui, cx, &mut actions);
|
||||
view_menu(ui, cx, &mut actions);
|
||||
help_menu(ui, &mut actions);
|
||||
|
||||
project_badge(ui, cx);
|
||||
});
|
||||
});
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn file_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
let project_open = cx.project.is_some();
|
||||
|
||||
ui.menu_button("File", |ui| {
|
||||
// A click inside the "New scene" submenu closes the submenu but not the
|
||||
// File menu (egui delegates close handling to the submenu), so remember
|
||||
// to close the parent as well.
|
||||
let mut close_file_menu = false;
|
||||
ui.menu_button("New scene", |ui| {
|
||||
if item(ui, "Empty scene", "")
|
||||
.on_hover_text("A blank scene with nothing in it")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::Empty));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
if item(ui, "2D starter", "")
|
||||
.on_hover_text("Camera, light and a sprite to build on")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::TwoD));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
if item(ui, "3D starter", "")
|
||||
.on_hover_text("Camera, sun, ground and primitive shapes")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::ThreeD));
|
||||
ui.close();
|
||||
close_file_menu = true;
|
||||
}
|
||||
});
|
||||
if close_file_menu {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Open project…", &shortcut(false, "O")).clicked() {
|
||||
actions.push(Action::OpenProjectDialog);
|
||||
ui.close();
|
||||
}
|
||||
if item_enabled(ui, project_open, "Open scene…", "")
|
||||
.on_disabled_hover_text("Open a project first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenSceneDialog);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let save = item(ui, "Save", &shortcut(false, "S"));
|
||||
let save = if project_open {
|
||||
save.on_hover_text("Save the current scene")
|
||||
} else {
|
||||
save.on_hover_text("No project open — this opens the Save as dialog")
|
||||
};
|
||||
if save.clicked() {
|
||||
actions.push(Action::Save);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Save as…", &shortcut(true, "S")).clicked() {
|
||||
actions.push(Action::OpenSaveAsDialog);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item_enabled(ui, project_open, "Reload assets", "")
|
||||
.on_disabled_hover_text("Open a project first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::ReloadAssets);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Export BSN…", &shortcut(false, "E"))
|
||||
.on_hover_text("Bevy 0.20 scene notation, ready to paste into a project")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenExportDialog(ExportFormat::Bsn));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Export Rust…", &shortcut(true, "E"))
|
||||
.on_hover_text("Imperative Commands::spawn code")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenExportDialog(ExportFormat::Rust));
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Copy BSN", "")
|
||||
.on_hover_text("Generate BSN code and copy it to the clipboard")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::CopyExport(ExportFormat::Bsn));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Quit", "").clicked() {
|
||||
actions.push(Action::RequestQuit);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn edit_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("Edit", |ui| {
|
||||
let undo_label = match cx.session.undo_label() {
|
||||
Some(label) => format!("Undo {label}"),
|
||||
None => "Undo".to_owned(),
|
||||
};
|
||||
if item_enabled(
|
||||
ui,
|
||||
cx.session.can_undo(),
|
||||
&undo_label,
|
||||
&shortcut(false, "Z"),
|
||||
)
|
||||
.on_disabled_hover_text("Nothing to undo yet")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Undo);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
let redo_label = match cx.session.redo_label() {
|
||||
Some(label) => format!("Redo {label}"),
|
||||
None => "Redo".to_owned(),
|
||||
};
|
||||
if item_enabled(
|
||||
ui,
|
||||
cx.session.can_redo(),
|
||||
&redo_label,
|
||||
&shortcut(false, "Y"),
|
||||
)
|
||||
.on_disabled_hover_text("Nothing to redo")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::Redo);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let has_selection = !cx.ui.selection.is_empty();
|
||||
if item_enabled(ui, has_selection, "Duplicate", &shortcut(false, "D"))
|
||||
.on_disabled_hover_text("Select an entity first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::DuplicateSelection);
|
||||
ui.close();
|
||||
}
|
||||
if item_enabled(ui, has_selection, "Delete", "Delete")
|
||||
.on_disabled_hover_text("Select an entity first")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::DeleteSelection);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn view_menu(ui: &mut egui::Ui, cx: &EditorContext<'_>, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("View", |ui| {
|
||||
let mut mode = cx.ui.view_mode;
|
||||
let two_d = ui
|
||||
.radio_value(&mut mode, ViewMode::TwoD, "2D view")
|
||||
.on_hover_text("Switch to the 2D viewport (2)");
|
||||
let three_d = ui
|
||||
.radio_value(&mut mode, ViewMode::ThreeD, "3D view")
|
||||
.on_hover_text("Switch to the 3D viewport (3)");
|
||||
if (two_d.clicked() || three_d.clicked()) && mode != cx.ui.view_mode {
|
||||
actions.push(Action::SetViewMode(mode));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let mut grid = cx.ui.show_grid;
|
||||
if ui
|
||||
.checkbox(&mut grid, "Grid")
|
||||
.on_hover_text("Show the grid (G)")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleGrid);
|
||||
ui.close();
|
||||
}
|
||||
let mut snap = cx.ui.snap_to_grid;
|
||||
if ui
|
||||
.checkbox(&mut snap, "Snap to grid")
|
||||
.on_hover_text("Snap moves to whole grid steps")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleSnap);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let mut assets = cx.ui.show_assets;
|
||||
if ui
|
||||
.checkbox(&mut assets, "Asset browser")
|
||||
.on_hover_text(format!(
|
||||
"Toggle the asset browser ({})",
|
||||
shortcut(false, "B")
|
||||
))
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleAssets);
|
||||
ui.close();
|
||||
}
|
||||
let mut left = cx.ui.show_left_panel;
|
||||
if ui
|
||||
.checkbox(&mut left, "Hierarchy panel")
|
||||
.on_hover_text(format!(
|
||||
"Toggle the hierarchy panel ({})",
|
||||
shortcut(true, "L")
|
||||
))
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleLeftPanel);
|
||||
ui.close();
|
||||
}
|
||||
let mut right = cx.ui.show_right_panel;
|
||||
if ui
|
||||
.checkbox(&mut right, "Inspector panel")
|
||||
.on_hover_text("Toggle the inspector panel")
|
||||
.changed()
|
||||
{
|
||||
actions.push(Action::ToggleRightPanel);
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if item(ui, "Fit view", "F").clicked() {
|
||||
actions.push(Action::FitView);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "Command palette…", &shortcut(false, "K")).clicked() {
|
||||
actions.push(Action::OpenCommandPalette);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn help_menu(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
|
||||
ui.menu_button("Help", |ui| {
|
||||
if item(ui, "Keyboard shortcuts", "F1").clicked() {
|
||||
actions.push(Action::ShowShortcuts);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "About Bevy Forge", "").clicked() {
|
||||
actions.push(Action::ShowAbout);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.hyperlink_to("Bevy 0.20 documentation", "https://bevy.org")
|
||||
.on_hover_text("Open bevy.org in your browser");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project / scene badge on the right
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project_badge(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::help_marker(
|
||||
ui,
|
||||
"Ctrl+K opens the command palette · F1 lists every shortcut",
|
||||
);
|
||||
|
||||
if cx.session.is_dirty() {
|
||||
ui.label(
|
||||
egui::RichText::new("• unsaved")
|
||||
.color(theme::WARNING)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Unsaved changes — Ctrl+S saves the scene");
|
||||
}
|
||||
|
||||
let scene = &cx.session.doc().name;
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(scene, 36))
|
||||
.color(theme::TEXT)
|
||||
.size(12.0),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(format!("Scene: {scene}"));
|
||||
|
||||
ui.separator();
|
||||
|
||||
match cx.project {
|
||||
Some(project) => {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(&project.name, 24))
|
||||
.color(theme::MUTED)
|
||||
.size(11.5),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(format!("Project: {}", project.root.display()));
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new("No project")
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Open a Bevy project to save scenes, browse assets and export");
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Standard menu item with a right-aligned shortcut hint.
|
||||
fn item(ui: &mut egui::Ui, label: &str, shortcut_text: &str) -> egui::Response {
|
||||
let button = egui::Button::new(label);
|
||||
let button = if shortcut_text.is_empty() {
|
||||
button
|
||||
} else {
|
||||
button.shortcut_text(shortcut_text)
|
||||
};
|
||||
ui.add(button)
|
||||
}
|
||||
|
||||
/// Menu item that can be greyed out, with an explanation on hover.
|
||||
fn item_enabled(
|
||||
ui: &mut egui::Ui,
|
||||
enabled: bool,
|
||||
label: &str,
|
||||
shortcut_text: &str,
|
||||
) -> egui::Response {
|
||||
let button = egui::Button::new(label);
|
||||
let button = if shortcut_text.is_empty() {
|
||||
button
|
||||
} else {
|
||||
button.shortcut_text(shortcut_text)
|
||||
};
|
||||
ui.add_enabled(enabled, button)
|
||||
}
|
||||
|
||||
/// `Ctrl+Z`-style hint for the menu (with `Shift` when requested).
|
||||
fn shortcut(shift: bool, key: &str) -> String {
|
||||
if shift {
|
||||
format!("{COMMAND}+Shift+{key}")
|
||||
} else {
|
||||
format!("{COMMAND}+{key}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Elide the middle of a long scene/project name so both ends stay readable.
|
||||
fn shorten(text: &str, max_chars: usize) -> String {
|
||||
let count = text.chars().count();
|
||||
if count <= max_chars || max_chars < 4 {
|
||||
return text.to_owned();
|
||||
}
|
||||
let keep_left = (max_chars - 1) / 2;
|
||||
let keep_right = max_chars - 1 - keep_left;
|
||||
let left: String = text.chars().take(keep_left).collect();
|
||||
let right: String = text.chars().skip(count - keep_right).collect();
|
||||
format!("{left}…{right}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::{ExportState, UiState};
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
use bevy_forge_core::{AssetIndex, SceneDoc, Session};
|
||||
|
||||
#[test]
|
||||
fn shortcut_hints_use_the_platform_command_key() {
|
||||
assert_eq!(shortcut(false, "S"), format!("{COMMAND}+S"));
|
||||
assert_eq!(shortcut(true, "S"), format!("{COMMAND}+Shift+S"));
|
||||
assert!(shortcut(false, "Z").ends_with('Z'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_names_are_shortened_in_the_middle() {
|
||||
assert_eq!(shorten("garden", 20), "garden");
|
||||
assert_eq!(shorten("abcdefghij", 6), "ab…hij");
|
||||
assert_eq!(
|
||||
shorten("Édition — jardin.forge.ron", 14).chars().count(),
|
||||
14
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_draws_headlessly_without_a_project() {
|
||||
let ctx = egui::Context::default();
|
||||
theme::apply(&ctx);
|
||||
|
||||
let mut session = Session::new(SceneDoc::new("Untitled"));
|
||||
let assets = AssetIndex::default();
|
||||
let mut textures = TextureCache::new();
|
||||
let mut ui_state = UiState::default();
|
||||
let mut toasts = Toasts::new();
|
||||
let mut export = ExportState::default();
|
||||
let mut actions = Vec::new();
|
||||
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1440.0, 900.0),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| {
|
||||
let cx = EditorContext {
|
||||
session: &mut session,
|
||||
project: None,
|
||||
assets: &assets,
|
||||
textures: &mut textures,
|
||||
ui: &mut ui_state,
|
||||
toasts: &mut toasts,
|
||||
export: &mut export,
|
||||
};
|
||||
actions = show(ctx, &cx);
|
||||
});
|
||||
|
||||
assert!(actions.is_empty(), "opening the menu bar emits no action");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
//! Command palette (Ctrl+K): fuzzy command search with keyboard navigation.
|
||||
//!
|
||||
//! The palette only emits [`Action`]s; `app.rs` owns the actual behaviour. Commands
|
||||
//! that cannot run in the current state (undo with no history, delete with no
|
||||
//! selection, …) stay visible but are rendered as disabled.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, Project, ProjectTemplate, Session};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::{EditorContext, Selection, ViewMode};
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
/// Maximum number of command rows visible before the list scrolls.
|
||||
const MAX_VISIBLE_ROWS: usize = 10;
|
||||
/// Height of a single command row, in points.
|
||||
const ROW_HEIGHT: f32 = 32.0;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PaletteState {
|
||||
pub open: bool,
|
||||
pub query: String,
|
||||
pub selected: usize,
|
||||
/// Set once focus has been requested for the current opening.
|
||||
pub focus_requested: bool,
|
||||
/// Scroll the selected row into view on the next list pass.
|
||||
pub scroll_to_selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Category {
|
||||
File,
|
||||
Edit,
|
||||
View,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::File => "FILE",
|
||||
Self::Edit => "EDIT",
|
||||
Self::View => "VIEW",
|
||||
Self::Help => "HELP",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum CommandId {
|
||||
NewEmptyScene,
|
||||
New2dScene,
|
||||
New3dScene,
|
||||
OpenProject,
|
||||
OpenScene,
|
||||
Save,
|
||||
SaveAs,
|
||||
ExportBsn,
|
||||
ExportRust,
|
||||
CopyBsn,
|
||||
ToggleBothPanels,
|
||||
ToggleHierarchy,
|
||||
ToggleInspector,
|
||||
Undo,
|
||||
Redo,
|
||||
Duplicate,
|
||||
Delete,
|
||||
FitView,
|
||||
ToggleGrid,
|
||||
ToggleSnap,
|
||||
ToggleAssets,
|
||||
View2d,
|
||||
View3d,
|
||||
Shortcuts,
|
||||
About,
|
||||
Quit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CommandSpec {
|
||||
id: CommandId,
|
||||
label: &'static str,
|
||||
category: Category,
|
||||
shortcut: Option<&'static str>,
|
||||
hint: &'static str,
|
||||
}
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec {
|
||||
id: CommandId::NewEmptyScene,
|
||||
label: "New empty scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Start a blank scene with no entities.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::New2dScene,
|
||||
label: "New 2D scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Create a 2D starter with a camera and sprites.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::New3dScene,
|
||||
label: "New 3D scene",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Create a 3D starter with a camera, light and primitives.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::OpenProject,
|
||||
label: "Open project…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+O"),
|
||||
hint: "Open a Bevy project folder (Cargo.toml with a bevy dependency).",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::OpenScene,
|
||||
label: "Open scene…",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Pick one of the .forge.ron scenes in the current project.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Save,
|
||||
label: "Save scene",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+S"),
|
||||
hint: "Save the current scene into the project's scenes/ folder.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::SaveAs,
|
||||
label: "Save scene as…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+Shift+S"),
|
||||
hint: "Choose a path for the scene file.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ExportBsn,
|
||||
label: "Export BSN…",
|
||||
category: Category::File,
|
||||
shortcut: Some("Ctrl+E"),
|
||||
hint: "Generate Bevy 0.20 BSN Rust code and copy or save it.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ExportRust,
|
||||
label: "Export Rust…",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Generate imperative Commands::spawn code.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::CopyBsn,
|
||||
label: "Copy BSN to clipboard",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Copy the generated BSN snippet without opening the export dialog.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Quit,
|
||||
label: "Quit Bevy Forge",
|
||||
category: Category::File,
|
||||
shortcut: None,
|
||||
hint: "Close the editor. Unsaved changes ask first.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Undo,
|
||||
label: "Undo",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+Z"),
|
||||
hint: "Revert the last scene edit.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Redo,
|
||||
label: "Redo",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+Shift+Z"),
|
||||
hint: "Reapply the last undone edit.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Duplicate,
|
||||
label: "Duplicate selection",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Ctrl+D"),
|
||||
hint: "Deep-copy the selected entities.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Delete,
|
||||
label: "Delete selection",
|
||||
category: Category::Edit,
|
||||
shortcut: Some("Del"),
|
||||
hint: "Remove the selected entities and their children.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleBothPanels,
|
||||
label: "Toggle both side panels",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the hierarchy and inspector together.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleHierarchy,
|
||||
label: "Toggle hierarchy panel",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the scene hierarchy on the left.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleInspector,
|
||||
label: "Toggle inspector panel",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the inspector on the right.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::FitView,
|
||||
label: "Fit view",
|
||||
category: Category::View,
|
||||
shortcut: Some("F"),
|
||||
hint: "Frame the scene content in the active viewport.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleGrid,
|
||||
label: "Toggle grid",
|
||||
category: Category::View,
|
||||
shortcut: Some("G"),
|
||||
hint: "Show or hide the viewport grid.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleSnap,
|
||||
label: "Toggle snap to grid",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Snap transforms to the grid while dragging.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::ToggleAssets,
|
||||
label: "Toggle asset browser",
|
||||
category: Category::View,
|
||||
shortcut: None,
|
||||
hint: "Show or hide the asset browser panel.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::View2d,
|
||||
label: "Switch to 2D view",
|
||||
category: Category::View,
|
||||
shortcut: Some("2"),
|
||||
hint: "Use the orthographic 2D viewport.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::View3d,
|
||||
label: "Switch to 3D view",
|
||||
category: Category::View,
|
||||
shortcut: Some("3"),
|
||||
hint: "Use the 3D viewport with orbit controls.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::Shortcuts,
|
||||
label: "Keyboard shortcuts",
|
||||
category: Category::Help,
|
||||
shortcut: Some("F1"),
|
||||
hint: "Show every keyboard and mouse shortcut.",
|
||||
},
|
||||
CommandSpec {
|
||||
id: CommandId::About,
|
||||
label: "About Bevy Forge",
|
||||
category: Category::Help,
|
||||
shortcut: None,
|
||||
hint: "Version and project information.",
|
||||
},
|
||||
];
|
||||
|
||||
/// Render the palette when open and return the commands chosen this frame.
|
||||
pub fn show(
|
||||
ctx: &egui::Context,
|
||||
cx: &mut EditorContext<'_>,
|
||||
state: &mut PaletteState,
|
||||
) -> Vec<Action> {
|
||||
if !state.open {
|
||||
// Never hold keyboard focus while closed.
|
||||
state.focus_requested = false;
|
||||
state.scroll_to_selected = false;
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
let matches = filter_commands(&state.query);
|
||||
if matches.is_empty() {
|
||||
state.selected = 0;
|
||||
} else if state.selected >= matches.len() {
|
||||
state.selected = matches.len() - 1;
|
||||
}
|
||||
|
||||
let id = egui::Id::new("forge_command_palette");
|
||||
let area =
|
||||
egui::Modal::default_area(id).anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 96.0));
|
||||
let mut executed = false;
|
||||
|
||||
let modal = egui::Modal::new(id)
|
||||
.area(area)
|
||||
.frame(dialog_frame(ctx))
|
||||
.backdrop_color(egui::Color32::from_black_alpha(150))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(520.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Command palette")
|
||||
.size(14.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy Forge")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::kbd(ui, "Esc");
|
||||
ui.label(egui::RichText::new("close").size(10.5).color(theme::FAINT));
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
|
||||
let search = ui.add(
|
||||
egui::TextEdit::singleline(&mut state.query)
|
||||
.id_salt("forge_palette_query")
|
||||
.hint_text("Search commands…")
|
||||
.desired_width(f32::INFINITY)
|
||||
.font(egui::TextStyle::Body)
|
||||
.margin(egui::Margin::symmetric(8, 6)),
|
||||
);
|
||||
if !state.focus_requested {
|
||||
search.request_focus();
|
||||
state.focus_requested = true;
|
||||
}
|
||||
// Arrow keys and Enter are only handled while the palette owns the
|
||||
// keyboard, so the palette can never steal keys from another field.
|
||||
let owns_keyboard =
|
||||
search.has_focus() || search.lost_focus() || !ctx.wants_keyboard_input();
|
||||
if owns_keyboard {
|
||||
let down = ctx.input_mut(|input| {
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)
|
||||
});
|
||||
let up = ctx.input_mut(|input| {
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)
|
||||
});
|
||||
if down || up {
|
||||
state.selected = step_selection(state.selected, matches.len(), down);
|
||||
state.scroll_to_selected = true;
|
||||
}
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Enter)) {
|
||||
if let Some(&command_index) = matches.get(state.selected) {
|
||||
let command = &COMMANDS[command_index];
|
||||
if command_enabled(command.id, cx.session, cx.project, &cx.ui.selection) {
|
||||
actions.extend(command_actions(command.id));
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
||||
if matches.is_empty() {
|
||||
widgets::empty_state(
|
||||
ui,
|
||||
"No commands match",
|
||||
"Try “save”, “export”, “2D”, “undo” or “about”.",
|
||||
);
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(ROW_HEIGHT * MAX_VISIBLE_ROWS as f32)
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
// Rows carry their own height; avoid gaps so exactly
|
||||
// MAX_VISIBLE_ROWS rows fit in the scroll viewport.
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for (visible_index, &command_index) in matches.iter().enumerate() {
|
||||
let command = &COMMANDS[command_index];
|
||||
let enabled = command_enabled(
|
||||
command.id,
|
||||
cx.session,
|
||||
cx.project,
|
||||
&cx.ui.selection,
|
||||
);
|
||||
let selected = visible_index == state.selected;
|
||||
let response = command_row(ui, command, selected, enabled);
|
||||
if selected && state.scroll_to_selected {
|
||||
response.scroll_to_me(Some(egui::Align::Center));
|
||||
state.scroll_to_selected = false;
|
||||
}
|
||||
if response.clicked() && enabled {
|
||||
state.selected = visible_index;
|
||||
actions.extend(command_actions(command.id));
|
||||
executed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ui.add_space(2.0);
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
let hint = matches
|
||||
.get(state.selected)
|
||||
.map(|&index| COMMANDS[index].hint)
|
||||
.unwrap_or("Type to filter commands.");
|
||||
ui.label(egui::RichText::new(hint).size(10.5).color(theme::FAINT));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
widgets::kbd(ui, "Enter");
|
||||
ui.label(egui::RichText::new("run").size(10.5).color(theme::FAINT));
|
||||
widgets::kbd(ui, "Up/Down");
|
||||
ui.label(
|
||||
egui::RichText::new("navigate")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if executed || modal.should_close() {
|
||||
state.open = false;
|
||||
state.focus_requested = false;
|
||||
state.scroll_to_selected = false;
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn dialog_frame(ctx: &egui::Context) -> egui::Frame {
|
||||
egui::Frame::popup(&ctx.style())
|
||||
.fill(theme::PANEL)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(10))
|
||||
.inner_margin(egui::Margin::symmetric(16, 14))
|
||||
}
|
||||
|
||||
/// Move the selection by one row, wrapping around the ends.
|
||||
fn step_selection(selected: usize, len: usize, down: bool) -> usize {
|
||||
if len == 0 {
|
||||
return 0;
|
||||
}
|
||||
if down {
|
||||
if selected + 1 >= len {
|
||||
0
|
||||
} else {
|
||||
selected + 1
|
||||
}
|
||||
} else if selected == 0 {
|
||||
len - 1
|
||||
} else {
|
||||
selected - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a command can run in the current editor state.
|
||||
fn command_enabled(
|
||||
id: CommandId,
|
||||
session: &Session,
|
||||
project: Option<&Project>,
|
||||
selection: &Selection,
|
||||
) -> bool {
|
||||
match id {
|
||||
CommandId::Undo => session.can_undo(),
|
||||
CommandId::Redo => session.can_redo(),
|
||||
CommandId::Duplicate | CommandId::Delete => !selection.is_empty(),
|
||||
CommandId::OpenScene => project.is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Actions produced by executing a command. Most commands map to one action;
|
||||
/// "Toggle both side panels" is the exception.
|
||||
fn command_actions(id: CommandId) -> Vec<Action> {
|
||||
match id {
|
||||
CommandId::NewEmptyScene => vec![Action::NewScene(ProjectTemplate::Empty)],
|
||||
CommandId::New2dScene => vec![Action::NewScene(ProjectTemplate::TwoD)],
|
||||
CommandId::New3dScene => vec![Action::NewScene(ProjectTemplate::ThreeD)],
|
||||
CommandId::OpenProject => vec![Action::OpenProjectDialog],
|
||||
CommandId::OpenScene => vec![Action::OpenSceneDialog],
|
||||
CommandId::Save => vec![Action::Save],
|
||||
CommandId::SaveAs => vec![Action::OpenSaveAsDialog],
|
||||
CommandId::ExportBsn => vec![Action::OpenExportDialog(ExportFormat::Bsn)],
|
||||
CommandId::ExportRust => vec![Action::OpenExportDialog(ExportFormat::Rust)],
|
||||
CommandId::CopyBsn => vec![Action::CopyExport(ExportFormat::Bsn)],
|
||||
CommandId::ToggleBothPanels => {
|
||||
vec![Action::ToggleLeftPanel, Action::ToggleRightPanel]
|
||||
}
|
||||
CommandId::ToggleHierarchy => vec![Action::ToggleLeftPanel],
|
||||
CommandId::ToggleInspector => vec![Action::ToggleRightPanel],
|
||||
CommandId::Undo => vec![Action::Undo],
|
||||
CommandId::Redo => vec![Action::Redo],
|
||||
CommandId::Duplicate => vec![Action::DuplicateSelection],
|
||||
CommandId::Delete => vec![Action::DeleteSelection],
|
||||
CommandId::FitView => vec![Action::FitView],
|
||||
CommandId::ToggleGrid => vec![Action::ToggleGrid],
|
||||
CommandId::ToggleSnap => vec![Action::ToggleSnap],
|
||||
CommandId::ToggleAssets => vec![Action::ToggleAssets],
|
||||
CommandId::View2d => vec![Action::SetViewMode(ViewMode::TwoD)],
|
||||
CommandId::View3d => vec![Action::SetViewMode(ViewMode::ThreeD)],
|
||||
CommandId::Shortcuts => vec![Action::ShowShortcuts],
|
||||
CommandId::About => vec![Action::ShowAbout],
|
||||
CommandId::Quit => vec![Action::RequestQuit],
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of commands matching `query`, best match first. An empty query keeps
|
||||
/// the curated command order.
|
||||
fn filter_commands(query: &str) -> Vec<usize> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return (0..COMMANDS.len()).collect();
|
||||
}
|
||||
let mut scored: Vec<(usize, i32)> = COMMANDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, command)| {
|
||||
command_score(command.label, query).map(|score| (index, score))
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
scored.into_iter().map(|(index, _)| index).collect()
|
||||
}
|
||||
|
||||
/// Score a label against a query. Substring matches always beat subsequence
|
||||
/// matches; prefix and word-boundary matches get additional weight.
|
||||
fn command_score(label: &str, query: &str) -> Option<i32> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let haystack = label.to_lowercase();
|
||||
let needle = query.to_lowercase();
|
||||
|
||||
if let Some(position) = haystack.find(&needle) {
|
||||
let before = haystack[..position].chars().next_back();
|
||||
let at_start = position == 0;
|
||||
let at_boundary = at_start || before.is_some_and(|c| !c.is_alphanumeric());
|
||||
let mut score = 600 - position.min(240) as i32;
|
||||
if at_boundary {
|
||||
score += 250;
|
||||
}
|
||||
if at_start {
|
||||
score += 150;
|
||||
}
|
||||
return Some(score);
|
||||
}
|
||||
|
||||
// Subsequence ("fuzzy") match.
|
||||
let mut haystack_chars = haystack.char_indices();
|
||||
let mut previous_end: Option<usize> = None;
|
||||
let mut score = 200_i32;
|
||||
for needle_char in needle.chars() {
|
||||
let mut found = None;
|
||||
for (index, haystack_char) in haystack_chars.by_ref() {
|
||||
if haystack_char == needle_char {
|
||||
found = Some(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let index = found?;
|
||||
if let Some(previous) = previous_end {
|
||||
let gap = index.saturating_sub(previous + 1);
|
||||
score -= (gap.min(20) as i32) * 3;
|
||||
score += 2; // Reward contiguous runs.
|
||||
}
|
||||
previous_end = Some(index);
|
||||
}
|
||||
Some(score.max(1))
|
||||
}
|
||||
|
||||
fn command_row(
|
||||
ui: &mut egui::Ui,
|
||||
command: &CommandSpec,
|
||||
selected: bool,
|
||||
enabled: bool,
|
||||
) -> egui::Response {
|
||||
let width = ui.available_width();
|
||||
let (rect, response) = ui.allocate_exact_size(
|
||||
egui::vec2(width, ROW_HEIGHT),
|
||||
if enabled {
|
||||
egui::Sense::click()
|
||||
} else {
|
||||
egui::Sense::hover()
|
||||
},
|
||||
);
|
||||
let hovered = enabled && response.hovered();
|
||||
let background = if selected && enabled {
|
||||
theme::ACCENT_DIM
|
||||
} else if hovered {
|
||||
theme::ELEVATED
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
};
|
||||
let painter = ui.painter();
|
||||
let row_rect = rect.shrink2(egui::vec2(0.0, 1.0));
|
||||
if background != egui::Color32::TRANSPARENT {
|
||||
painter.rect_filled(row_rect, 5.0, background);
|
||||
}
|
||||
if selected && enabled {
|
||||
painter.rect_stroke(
|
||||
row_rect,
|
||||
5.0,
|
||||
egui::Stroke::new(1.0_f32, theme::ACCENT),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
}
|
||||
|
||||
let text_color = if enabled { theme::TEXT } else { theme::FAINT };
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 10.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
command.category.label(),
|
||||
egui::FontId::proportional(9.0),
|
||||
theme::FAINT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 66.0, rect.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
command.label,
|
||||
egui::FontId::proportional(13.5),
|
||||
text_color,
|
||||
);
|
||||
if let Some(shortcut) = command.shortcut {
|
||||
draw_keycap(
|
||||
painter,
|
||||
rect.right_center() - egui::vec2(10.0, 0.0),
|
||||
shortcut,
|
||||
enabled,
|
||||
);
|
||||
}
|
||||
|
||||
response.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, command.label)
|
||||
});
|
||||
response.on_hover_text(command.hint)
|
||||
}
|
||||
|
||||
fn draw_keycap(painter: &egui::Painter, right_center: egui::Pos2, text: &str, enabled: bool) {
|
||||
let color = if enabled { theme::MUTED } else { theme::FAINT };
|
||||
let galley = painter.layout_no_wrap(text.to_owned(), egui::FontId::monospace(10.0), color);
|
||||
let padding = egui::vec2(6.0, 2.0);
|
||||
let size = galley.size() + padding * 2.0;
|
||||
let rect = egui::Rect::from_min_size(
|
||||
egui::pos2(right_center.x - size.x, right_center.y - size.y * 0.5),
|
||||
size,
|
||||
);
|
||||
painter.rect_filled(rect, 3.0, theme::BG);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
3.0,
|
||||
egui::Stroke::new(1.0_f32, theme::BORDER),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.galley(rect.min + padding, galley, color);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::SceneDoc;
|
||||
|
||||
fn label_of(index: usize) -> &'static str {
|
||||
COMMANDS[index].label
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_query_keeps_all_commands_in_order() {
|
||||
let all = filter_commands("");
|
||||
assert_eq!(all.len(), COMMANDS.len());
|
||||
assert_eq!(all.first().copied(), Some(0));
|
||||
assert!(all.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
assert_eq!(filter_commands(" ").len(), COMMANDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_ranks_first() {
|
||||
let matches = filter_commands("save");
|
||||
assert!(!matches.is_empty());
|
||||
assert_eq!(label_of(matches[0]), "Save scene");
|
||||
// "Save scene as…" is also a substring match and stays in the list.
|
||||
assert!(matches
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "Save scene as…"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substring_beats_fuzzy_match() {
|
||||
// "undo" is a prefix of "Undo"; "U…n…d…o" also fuzzy-matches several labels.
|
||||
let matches = filter_commands("undo");
|
||||
assert_eq!(label_of(matches[0]), "Undo");
|
||||
let scene_search = filter_commands("ne3");
|
||||
// "New 3D scene" must fuzzy-match while "New empty scene" must not.
|
||||
assert!(scene_search
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "New 3D scene"));
|
||||
assert!(!scene_search
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "New empty scene"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzzy_subsequence_matches() {
|
||||
let matches = filter_commands("oprj");
|
||||
assert!(
|
||||
matches
|
||||
.iter()
|
||||
.any(|&index| label_of(index) == "Open project…"),
|
||||
"expected a fuzzy match for Open project…, got {:?}",
|
||||
matches.iter().map(|&i| label_of(i)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_query_returns_nothing() {
|
||||
assert!(filter_commands("zzzzzz").is_empty());
|
||||
assert!(command_score("Save scene", "zzzzzz").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_matches_score_above_mid_word_matches() {
|
||||
let boundary = command_score("Open scene", "scene").unwrap();
|
||||
let mid_word = command_score("Obscene thing", "scene").unwrap();
|
||||
assert!(boundary > mid_word);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_step_wraps() {
|
||||
assert_eq!(step_selection(0, 3, true), 1);
|
||||
assert_eq!(step_selection(2, 3, true), 0);
|
||||
assert_eq!(step_selection(0, 3, false), 2);
|
||||
assert_eq!(step_selection(1, 3, false), 0);
|
||||
assert_eq!(step_selection(0, 0, true), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_an_action_and_unique_id() {
|
||||
for (index, command) in COMMANDS.iter().enumerate() {
|
||||
assert!(!command.label.is_empty());
|
||||
assert!(!command_actions(command.id).is_empty());
|
||||
assert!(
|
||||
COMMANDS[..index].iter().all(|other| other.id != command.id),
|
||||
"duplicate command id {:?}",
|
||||
command.id
|
||||
);
|
||||
}
|
||||
assert!(COMMANDS.len() >= 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn availability_tracks_history_and_selection() {
|
||||
let mut session = Session::new(SceneDoc::new("Test"));
|
||||
let mut selection = Selection::default();
|
||||
assert!(!command_enabled(
|
||||
CommandId::Undo,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(!command_enabled(
|
||||
CommandId::Delete,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
selection.select_only(session.doc().roots[0]);
|
||||
assert!(command_enabled(CommandId::Undo, &session, None, &selection));
|
||||
assert!(command_enabled(
|
||||
CommandId::Delete,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(command_enabled(
|
||||
CommandId::New2dScene,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
assert!(!command_enabled(
|
||||
CommandId::OpenScene,
|
||||
&session,
|
||||
None,
|
||||
&selection
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Global keyboard shortcuts.
|
||||
//!
|
||||
//! [`handle`] runs once per frame before the panels are drawn, turns key presses
|
||||
//! into deferred [`Action`]s and consumes the matching events so nothing else
|
||||
//! reacts to the same press. Two rules keep this predictable:
|
||||
//!
|
||||
//! * A focused text field (inline rename, search boxes, the command palette,
|
||||
//! dialogs) owns the keyboard: no shortcut fires while
|
||||
//! [`egui::Context::wants_keyboard_input`] is true. This also leaves `Escape`
|
||||
//! to the widget that has focus.
|
||||
//! * Modifiers must match exactly. egui's `consume_key` deliberately ignores
|
||||
//! extra Shift/Alt modifiers, so `Ctrl+Shift+S` would otherwise count as
|
||||
//! `Ctrl+S`. The mapping below inspects key events and uses
|
||||
//! [`egui::Modifiers::matches_exact`] instead.
|
||||
//!
|
||||
//! Clearing the selection with `Escape` is a focus-local action owned by the
|
||||
//! viewport that has the canvas focused: the frozen [`Action`] enum has no
|
||||
//! clear-selection variant, and a global handler must not consume `Escape` from
|
||||
//! the widgets that do own it.
|
||||
|
||||
use bevy_forge_core::{ExportFormat, Session};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::state::ViewMode;
|
||||
|
||||
/// Human-readable shortcut reference shared with Help → Keyboard shortcuts.
|
||||
///
|
||||
/// `Ctrl` is `Cmd` on macOS: shortcuts are matched with
|
||||
/// [`egui::Modifiers::COMMAND`], which maps to the platform convention.
|
||||
pub const SHORTCUT_REFERENCE: &[(&str, &str)] = &[
|
||||
("Ctrl+S", "Save the current scene"),
|
||||
("Ctrl+Shift+S", "Save the scene as…"),
|
||||
("Ctrl+O", "Open a project…"),
|
||||
("Ctrl+K", "Command palette"),
|
||||
("F1", "Keyboard shortcut reference"),
|
||||
("Ctrl+Z", "Undo"),
|
||||
("Ctrl+Shift+Z / Ctrl+Y", "Redo"),
|
||||
("Ctrl+D", "Duplicate the selection"),
|
||||
("Delete", "Delete the selection"),
|
||||
("Ctrl+E", "Export BSN scene"),
|
||||
("Ctrl+Shift+E", "Export imperative Rust"),
|
||||
("F", "Fit the active viewport"),
|
||||
("G", "Toggle the grid"),
|
||||
("2 / 3", "Switch to the 2D / 3D viewport"),
|
||||
("Ctrl+B", "Toggle the asset browser"),
|
||||
("Ctrl+Shift+L", "Toggle the hierarchy panel"),
|
||||
("Esc", "Clear the selection when the viewport has focus"),
|
||||
];
|
||||
|
||||
/// Map one key press to the action it should emit.
|
||||
///
|
||||
/// Pure: no input state, no session and no UI. `modifiers` must match a binding
|
||||
/// exactly, so `Ctrl+Alt+S` or `Shift+2` do not trigger the plain bindings.
|
||||
/// Whether undo/redo are currently possible is checked by [`handle`], not here.
|
||||
pub fn action_for(key: egui::Key, modifiers: egui::Modifiers) -> Option<Action> {
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
// Keep the more specific binding first: `matches_exact` rejects extra
|
||||
// modifiers, so `Ctrl+Shift+Z` can never fall through to plain `Ctrl+Z`.
|
||||
match key {
|
||||
Key::S if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::OpenSaveAsDialog)
|
||||
}
|
||||
Key::S if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Save),
|
||||
Key::O if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenProjectDialog),
|
||||
Key::K if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::OpenCommandPalette),
|
||||
Key::Z if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::Redo)
|
||||
}
|
||||
Key::Z if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Undo),
|
||||
Key::Y if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::Redo),
|
||||
Key::D if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::DuplicateSelection),
|
||||
Key::E if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::OpenExportDialog(ExportFormat::Rust))
|
||||
}
|
||||
Key::E if modifiers.matches_exact(Modifiers::COMMAND) => {
|
||||
Some(Action::OpenExportDialog(ExportFormat::Bsn))
|
||||
}
|
||||
Key::B if modifiers.matches_exact(Modifiers::COMMAND) => Some(Action::ToggleAssets),
|
||||
Key::L if modifiers.matches_exact(Modifiers::COMMAND | Modifiers::SHIFT) => {
|
||||
Some(Action::ToggleLeftPanel)
|
||||
}
|
||||
|
||||
// Plain keys: no modifier at all (Shift+2 must not switch viewports).
|
||||
Key::F1 if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ShowShortcuts),
|
||||
Key::F if modifiers.matches_exact(Modifiers::NONE) => Some(Action::FitView),
|
||||
Key::G if modifiers.matches_exact(Modifiers::NONE) => Some(Action::ToggleGrid),
|
||||
Key::Delete if modifiers.matches_exact(Modifiers::NONE) => Some(Action::DeleteSelection),
|
||||
Key::Num2 if modifiers.matches_exact(Modifiers::NONE) => {
|
||||
Some(Action::SetViewMode(ViewMode::TwoD))
|
||||
}
|
||||
Key::Num3 if modifiers.matches_exact(Modifiers::NONE) => {
|
||||
Some(Action::SetViewMode(ViewMode::ThreeD))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate keyboard input into at most one action per binding per frame.
|
||||
pub fn handle(ctx: &egui::Context, session: &Session) -> Vec<Action> {
|
||||
// Never steal keys from a focused text field (rename, search, palette,
|
||||
// dialogs): the widget gets its own Ctrl+Z/Delete/Escape handling.
|
||||
if ctx.wants_keyboard_input() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut actions = Vec::new();
|
||||
ctx.input_mut(|input| take_shortcut_events(input, session, &mut actions));
|
||||
actions
|
||||
}
|
||||
|
||||
/// Remove matching key events from `input` and record their actions.
|
||||
///
|
||||
/// Events are consumed even when the action is currently disabled (no undo
|
||||
/// history, no redo history) so a disabled shortcut never leaks into a panel.
|
||||
fn take_shortcut_events(
|
||||
input: &mut egui::InputState,
|
||||
session: &Session,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
input.events.retain(|event| {
|
||||
let egui::Event::Key {
|
||||
key,
|
||||
pressed: true,
|
||||
modifiers,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let Some(action) = action_for(*key, *modifiers) else {
|
||||
return true;
|
||||
};
|
||||
let enabled = match action {
|
||||
Action::Undo => session.can_undo(),
|
||||
Action::Redo => session.can_redo(),
|
||||
_ => true,
|
||||
};
|
||||
// Several repeats in one frame still produce a single action.
|
||||
if enabled && !actions.contains(&action) {
|
||||
actions.push(action);
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::SceneDoc;
|
||||
use egui::{Key, Modifiers};
|
||||
|
||||
fn key_event(key: Key, modifiers: Modifiers) -> egui::Event {
|
||||
egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_shortcuts_map_to_actions() {
|
||||
let cmd = Modifiers::COMMAND;
|
||||
assert_eq!(action_for(Key::S, cmd), Some(Action::Save));
|
||||
assert_eq!(action_for(Key::O, cmd), Some(Action::OpenProjectDialog));
|
||||
assert_eq!(action_for(Key::K, cmd), Some(Action::OpenCommandPalette));
|
||||
assert_eq!(action_for(Key::D, cmd), Some(Action::DuplicateSelection));
|
||||
assert_eq!(action_for(Key::B, cmd), Some(Action::ToggleAssets));
|
||||
assert_eq!(
|
||||
action_for(Key::E, cmd),
|
||||
Some(Action::OpenExportDialog(ExportFormat::Bsn))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::E, cmd | Modifiers::SHIFT),
|
||||
Some(Action::OpenExportDialog(ExportFormat::Rust))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::L, cmd | Modifiers::SHIFT),
|
||||
Some(Action::ToggleLeftPanel)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_require_exact_modifiers() {
|
||||
assert_eq!(action_for(Key::Z, Modifiers::COMMAND), Some(Action::Undo));
|
||||
assert_eq!(
|
||||
action_for(Key::Z, Modifiers::COMMAND | Modifiers::SHIFT),
|
||||
Some(Action::Redo),
|
||||
"Ctrl+Shift+Z is redo, never undo"
|
||||
);
|
||||
assert_eq!(action_for(Key::Y, Modifiers::COMMAND), Some(Action::Redo));
|
||||
// Extra modifiers must not match a binding.
|
||||
assert_eq!(
|
||||
action_for(Key::Z, Modifiers::COMMAND | Modifiers::ALT),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::S, Modifiers::COMMAND | Modifiers::ALT),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_shortcuts_map_to_view_and_selection_actions() {
|
||||
assert_eq!(
|
||||
action_for(Key::F1, Modifiers::NONE),
|
||||
Some(Action::ShowShortcuts)
|
||||
);
|
||||
assert_eq!(action_for(Key::F, Modifiers::NONE), Some(Action::FitView));
|
||||
assert_eq!(
|
||||
action_for(Key::G, Modifiers::NONE),
|
||||
Some(Action::ToggleGrid)
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Delete, Modifiers::NONE),
|
||||
Some(Action::DeleteSelection)
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Num2, Modifiers::NONE),
|
||||
Some(Action::SetViewMode(ViewMode::TwoD))
|
||||
);
|
||||
assert_eq!(
|
||||
action_for(Key::Num3, Modifiers::NONE),
|
||||
Some(Action::SetViewMode(ViewMode::ThreeD))
|
||||
);
|
||||
// Shift+2/Shift+3 and Ctrl+F are not plain bindings.
|
||||
assert_eq!(action_for(Key::Num2, Modifiers::SHIFT), None);
|
||||
assert_eq!(action_for(Key::Num3, Modifiers::SHIFT), None);
|
||||
assert_eq!(action_for(Key::F, Modifiers::COMMAND), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_undo_is_consumed_without_emitting_an_action() {
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
let session = Session::new(SceneDoc::new("Empty"));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
|
||||
assert!(actions.is_empty(), "nothing to undo yet");
|
||||
assert!(
|
||||
input.events.is_empty(),
|
||||
"the key press must still be consumed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_action_is_emitted_once_when_history_exists() {
|
||||
let mut session = Session::new(SceneDoc::new("Empty"));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
assert!(session.can_undo());
|
||||
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
input.events.push(key_event(Key::Z, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
|
||||
assert_eq!(actions, vec![Action::Undo], "repeat events are collapsed");
|
||||
assert!(input.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_action_requires_redo_history() {
|
||||
let mut session = Session::new(SceneDoc::new("Empty"));
|
||||
session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Cube", None);
|
||||
});
|
||||
// Without an undo the redo stack is empty.
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Y, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
|
||||
session.undo();
|
||||
assert!(session.can_redo());
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::Y, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &session, &mut actions);
|
||||
assert_eq!(actions, vec![Action::Redo]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumed_events_do_not_leak_unknown_keys() {
|
||||
let input_session = Session::new(SceneDoc::new("Empty"));
|
||||
let mut input = egui::InputState::default();
|
||||
input.events.push(key_event(Key::A, Modifiers::COMMAND));
|
||||
let mut actions = Vec::new();
|
||||
take_shortcut_events(&mut input, &input_session, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(input.events.len(), 1, "unbound keys stay available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_field_focus_suppresses_every_shortcut() {
|
||||
// Reproduce an inline rename: a text field owns the keyboard and the user
|
||||
// presses Ctrl+S. No action may fire, and the key must not be consumed
|
||||
// from the widget.
|
||||
let ctx = egui::Context::default();
|
||||
let mut rename_buffer = String::from("Cube");
|
||||
let session = Session::new(SceneDoc::new("Typing"));
|
||||
let mut actions = vec![Action::DeleteSelection]; // poison: must be reset
|
||||
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(960.0, 640.0),
|
||||
)),
|
||||
events: vec![key_event(Key::S, Modifiers::COMMAND)],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let response = ui.add(egui::TextEdit::singleline(&mut rename_buffer));
|
||||
response.request_focus();
|
||||
assert!(ctx.wants_keyboard_input(), "the rename field has focus");
|
||||
actions = handle(ctx, &session);
|
||||
});
|
||||
});
|
||||
|
||||
assert!(
|
||||
actions.is_empty(),
|
||||
"Ctrl+S must not fire while renaming, got {actions:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_dispatches_one_shortcut_action_per_press() {
|
||||
// End-to-end: raw key events -> shortcuts::handle -> Action -> ForgeApp.
|
||||
// One Ctrl+D must duplicate exactly once (no double-dispatch), Ctrl+Z must
|
||||
// undo it, and Delete must delete the selected entity.
|
||||
use crate::app::ForgeApp;
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut doc = SceneDoc::new("Shortcut pipeline");
|
||||
let id = doc.add_entity("Cube", None);
|
||||
let mut app = ForgeApp::new_for_test(&ctx, doc);
|
||||
app.ui.selection.select_only(id);
|
||||
|
||||
let frame = |events: Vec<egui::Event>| egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1440.0, 900.0),
|
||||
)),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Warm-up frame: let the panels lay out and load fonts.
|
||||
let _ = ctx.run(frame(Vec::new()), |ctx| app.ui(ctx));
|
||||
assert_eq!(app.session.doc().entities.len(), 1);
|
||||
|
||||
let _ = ctx.run(frame(vec![key_event(Key::D, Modifiers::COMMAND)]), |ctx| {
|
||||
app.ui(ctx)
|
||||
});
|
||||
assert_eq!(
|
||||
app.session.doc().entities.len(),
|
||||
2,
|
||||
"Ctrl+D duplicates exactly once"
|
||||
);
|
||||
|
||||
let _ = ctx.run(frame(vec![key_event(Key::Z, Modifiers::COMMAND)]), |ctx| {
|
||||
app.ui(ctx)
|
||||
});
|
||||
assert_eq!(
|
||||
app.session.doc().entities.len(),
|
||||
1,
|
||||
"Ctrl+Z undoes the duplicate"
|
||||
);
|
||||
|
||||
app.ui.selection.select_only(id);
|
||||
let _ = ctx.run(
|
||||
frame(vec![key_event(Key::Delete, Modifiers::NONE)]),
|
||||
|ctx| app.ui(ctx),
|
||||
);
|
||||
assert!(
|
||||
app.session.doc().entity(id).is_none(),
|
||||
"Delete removes the selected entity"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! UI-wide state shared by panels, and the borrowed context passed to them.
|
||||
|
||||
use bevy_forge_core::{
|
||||
AssetIndex, AssetKind, Color, ExportFormat, Project, SceneDoc, Session, Vec2,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::texture_cache::TextureCache;
|
||||
use crate::toast::Toasts;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Screen {
|
||||
#[default]
|
||||
Welcome,
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ViewMode {
|
||||
#[default]
|
||||
TwoD,
|
||||
ThreeD,
|
||||
}
|
||||
|
||||
impl ViewMode {
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::TwoD => "2D",
|
||||
Self::ThreeD => "3D",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggled(self) -> Self {
|
||||
match self {
|
||||
Self::TwoD => Self::ThreeD,
|
||||
Self::ThreeD => Self::TwoD,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-selection with a primary entity for the inspector.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Selection {
|
||||
ids: BTreeSet<u64>,
|
||||
primary: Option<u64>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.ids.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.ids.len()
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: u64) -> bool {
|
||||
self.ids.contains(&id)
|
||||
}
|
||||
|
||||
pub fn ids(&self) -> impl Iterator<Item = u64> + '_ {
|
||||
self.ids.iter().copied()
|
||||
}
|
||||
|
||||
pub fn as_set(&self) -> &BTreeSet<u64> {
|
||||
&self.ids
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<u64> {
|
||||
self.primary
|
||||
}
|
||||
|
||||
pub fn primary_or_single(&self) -> Option<u64> {
|
||||
self.primary.or_else(|| {
|
||||
if self.ids.len() == 1 {
|
||||
self.ids.iter().next().copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_only(&mut self, id: u64) {
|
||||
self.ids.clear();
|
||||
self.ids.insert(id);
|
||||
self.primary = Some(id);
|
||||
}
|
||||
|
||||
pub fn select_many(&mut self, ids: impl IntoIterator<Item = u64>, primary: Option<u64>) {
|
||||
self.ids = ids.into_iter().collect();
|
||||
self.primary = primary.or_else(|| self.ids.iter().next().copied());
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: u64) {
|
||||
self.ids.insert(id);
|
||||
self.primary = Some(id);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u64) {
|
||||
self.ids.remove(&id);
|
||||
if self.primary == Some(id) {
|
||||
self.primary = self.ids.iter().next().copied();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, id: u64) {
|
||||
if self.ids.contains(&id) {
|
||||
self.remove(id);
|
||||
} else {
|
||||
self.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retain_existing(&mut self, doc: &SceneDoc) {
|
||||
self.ids.retain(|id| doc.entity(*id).is_some());
|
||||
if self.primary.is_some_and(|id| !self.ids.contains(&id)) {
|
||||
self.primary = self.ids.iter().next().copied();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.ids.clear();
|
||||
self.primary = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UiState {
|
||||
pub selection: Selection,
|
||||
pub view_mode: ViewMode,
|
||||
pub show_assets: bool,
|
||||
pub show_grid: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub grid_size: f32,
|
||||
pub hierarchy_search: String,
|
||||
pub inspector_search: String,
|
||||
pub asset_search: String,
|
||||
pub asset_filter: Option<AssetKind>,
|
||||
pub cursor_world: Option<Vec2>,
|
||||
pub pointer_over_viewport: bool,
|
||||
pub viewport_rect: egui::Rect,
|
||||
pub status: String,
|
||||
pub error_banner: Option<String>,
|
||||
pub show_left_panel: bool,
|
||||
pub show_right_panel: bool,
|
||||
/// Consumed by the active viewport to frame scene content.
|
||||
pub fit_view_requested: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
selection: Selection::default(),
|
||||
view_mode: ViewMode::TwoD,
|
||||
show_assets: true,
|
||||
show_grid: true,
|
||||
snap_to_grid: false,
|
||||
grid_size: 1.0,
|
||||
hierarchy_search: String::new(),
|
||||
inspector_search: String::new(),
|
||||
asset_search: String::new(),
|
||||
asset_filter: None,
|
||||
cursor_world: None,
|
||||
pointer_over_viewport: false,
|
||||
viewport_rect: egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(0.0, 0.0)),
|
||||
status: "Ready".into(),
|
||||
error_banner: None,
|
||||
show_left_panel: true,
|
||||
show_right_panel: true,
|
||||
fit_view_requested: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag-and-drop payload for dragging an asset from the browser into a viewport.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AssetDragPayload {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// State of the export dialog and the last generated snippet.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportState {
|
||||
pub dialog_open: bool,
|
||||
pub format: ExportFormat,
|
||||
pub function_name: String,
|
||||
pub path: String,
|
||||
pub code: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
impl Default for ExportState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialog_open: false,
|
||||
format: ExportFormat::Bsn,
|
||||
function_name: "scene".into(),
|
||||
path: String::new(),
|
||||
code: None,
|
||||
error: None,
|
||||
copied: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExportState {
|
||||
pub fn regenerate(&mut self, doc: &SceneDoc) {
|
||||
let options = bevy_forge_core::CodegenOptions {
|
||||
function_name: self.function_name.clone(),
|
||||
include_header: true,
|
||||
};
|
||||
match self.format.generate(doc, &options) {
|
||||
Ok(code) => {
|
||||
self.code = Some(code);
|
||||
self.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
self.code = None;
|
||||
self.error = Some(error.to_string());
|
||||
}
|
||||
}
|
||||
self.copied = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Data every panel needs. Panels never touch `ForgeApp` directly, which keeps
|
||||
/// them independently testable and avoids borrow-checker acrobatics.
|
||||
pub struct EditorContext<'a> {
|
||||
pub session: &'a mut Session,
|
||||
pub project: Option<&'a Project>,
|
||||
pub assets: &'a AssetIndex,
|
||||
pub textures: &'a mut TextureCache,
|
||||
pub ui: &'a mut UiState,
|
||||
pub toasts: &'a mut Toasts,
|
||||
pub export: &'a mut ExportState,
|
||||
}
|
||||
|
||||
impl EditorContext<'_> {
|
||||
pub fn notify_info(&mut self, message: impl Into<String>) {
|
||||
self.toasts.info(message);
|
||||
}
|
||||
|
||||
pub fn notify_success(&mut self, message: impl Into<String>) {
|
||||
self.toasts.success(message);
|
||||
}
|
||||
|
||||
pub fn notify_error(&mut self, message: impl Into<String>) {
|
||||
self.toasts.error(message);
|
||||
}
|
||||
|
||||
/// Asset paths of a given kind, relative to the Bevy `assets/` directory.
|
||||
pub fn assets_of_kind(&self, kind: AssetKind) -> Vec<String> {
|
||||
self.assets
|
||||
.by_kind(kind)
|
||||
.into_iter()
|
||||
.map(|asset| asset.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn image_assets(&self) -> Vec<String> {
|
||||
self.assets_of_kind(AssetKind::Image)
|
||||
}
|
||||
|
||||
pub fn mesh_assets(&self) -> Vec<String> {
|
||||
self.assets_of_kind(AssetKind::Mesh)
|
||||
}
|
||||
|
||||
/// Choose a sensible default component palette color for a new entity.
|
||||
pub fn default_color(&self) -> Color {
|
||||
Color::srgba(0.55, 0.72, 1.0, 1.0)
|
||||
}
|
||||
|
||||
/// Path for saving/exporting, if a project is open.
|
||||
pub fn scenes_dir(&self) -> Option<PathBuf> {
|
||||
self.project.map(|project| project.scenes_dir())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Bottom status bar: scene counts, selection, validation, view/save state and
|
||||
//! the cursor position.
|
||||
//!
|
||||
//! Everything is 11 px muted text on a 25 px strip; long paths are elided in the
|
||||
//! middle and shown in full in a tooltip. The panel itself never mutates state.
|
||||
|
||||
use bevy_forge_core::{validate_scene, SceneDoc, SceneStats, Severity, ValidationReport, Vec2};
|
||||
|
||||
use crate::state::{EditorContext, Selection};
|
||||
use crate::theme;
|
||||
|
||||
/// Height of the status strip in pixels.
|
||||
pub const STATUS_BAR_HEIGHT: f32 = 25.0;
|
||||
|
||||
pub fn show(ctx: &egui::Context, cx: &EditorContext<'_>) {
|
||||
egui::TopBottomPanel::bottom("status_bar")
|
||||
.exact_height(STATUS_BAR_HEIGHT)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(10, 0)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
// `validate_scene` is O(entities) and runs once per frame. Editor
|
||||
// scenes are small (well under 1k entities) and this keeps the badge
|
||||
// honest without any cache invalidation bugs; memoize only if a
|
||||
// profile ever shows it matters.
|
||||
let doc = cx.session.doc();
|
||||
let report = validate_scene(doc, Some(cx.assets));
|
||||
let stats = doc.stats();
|
||||
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(counts_label(&stats))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(stats_tooltip(&stats));
|
||||
|
||||
ui.separator();
|
||||
|
||||
let selection_color = if cx.ui.selection.is_empty() {
|
||||
theme::FAINT
|
||||
} else {
|
||||
theme::SELECTION
|
||||
};
|
||||
let selection = ui.label(
|
||||
egui::RichText::new(selection_label(doc, &cx.ui.selection))
|
||||
.color(selection_color)
|
||||
.size(11.0),
|
||||
);
|
||||
if let Some(tooltip) = selection_tooltip(doc, &cx.ui.selection) {
|
||||
selection.on_hover_text(tooltip);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let (validation, color) = validation_summary(&report);
|
||||
ui.label(egui::RichText::new(validation).color(color).size(11.0))
|
||||
.on_hover_text(validation_tooltip(doc, &report));
|
||||
|
||||
// The right cluster is laid out from the right edge inwards; the
|
||||
// status message is added last and therefore starts the cluster.
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
save_state(ui, cx);
|
||||
ui.separator();
|
||||
cursor_state(ui, cx.ui.cursor_world);
|
||||
ui.separator();
|
||||
snap_state(ui, cx.ui.snap_to_grid);
|
||||
ui.separator();
|
||||
grid_state(ui, cx.ui.show_grid, cx.ui.grid_size);
|
||||
ui.separator();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} view", cx.ui.view_mode.label()))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Viewport mode — press 2 or 3 to switch");
|
||||
ui.separator();
|
||||
project_state(ui, cx);
|
||||
ui.separator();
|
||||
|
||||
let status = status_message(&cx.ui.status);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(shorten(&status, 80))
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(&status);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure formatting helpers (unit-tested below)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn plural(count: usize, singular: &str, plural: &str) -> String {
|
||||
if count == 1 {
|
||||
format!("{count} {singular}")
|
||||
} else {
|
||||
format!("{count} {plural}")
|
||||
}
|
||||
}
|
||||
|
||||
fn counts_label(stats: &SceneStats) -> String {
|
||||
format!(
|
||||
"{} · {} · {} · {} · {}",
|
||||
plural(stats.entities, "entity", "entities"),
|
||||
plural(stats.roots, "root", "roots"),
|
||||
plural(stats.sprites, "sprite", "sprites"),
|
||||
plural(stats.meshes, "mesh", "meshes"),
|
||||
plural(stats.lights, "light", "lights"),
|
||||
)
|
||||
}
|
||||
|
||||
fn stats_tooltip(stats: &SceneStats) -> String {
|
||||
format!(
|
||||
"Scene contents\n{} entities · {} roots\n{} sprites · {} meshes · {} lights\n{} cameras · {} custom components",
|
||||
stats.entities,
|
||||
stats.roots,
|
||||
stats.sprites,
|
||||
stats.meshes,
|
||||
stats.lights,
|
||||
stats.cameras,
|
||||
stats.custom_components,
|
||||
)
|
||||
}
|
||||
|
||||
fn selection_label(doc: &SceneDoc, selection: &Selection) -> String {
|
||||
match selection.len() {
|
||||
0 => "No selection".to_owned(),
|
||||
1 => match selection.ids().next().and_then(|id| doc.entity(id)) {
|
||||
// Keep the strip short even for very long entity names; the tooltip
|
||||
// below always shows the full name.
|
||||
Some(node) => format!("Selected {}", shorten(&node.name, 28)),
|
||||
None => "1 selected".to_owned(),
|
||||
},
|
||||
count => match selection
|
||||
.primary_or_single()
|
||||
.and_then(|id| doc.entity(id))
|
||||
.map(|node| node.name.as_str())
|
||||
{
|
||||
Some(primary) => format!("{count} selected · {}", shorten(primary, 24)),
|
||||
None => format!("{count} selected"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_tooltip(doc: &SceneDoc, selection: &Selection) -> Option<String> {
|
||||
if selection.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<&str> = selection
|
||||
.ids()
|
||||
.filter_map(|id| doc.entity(id))
|
||||
.map(|node| node.name.as_str())
|
||||
.collect();
|
||||
if names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut text = format!("Selection ({} entities):", names.len());
|
||||
for name in names.iter().take(8) {
|
||||
text.push_str("\n• ");
|
||||
text.push_str(name);
|
||||
}
|
||||
if names.len() > 8 {
|
||||
text.push_str(&format!("\n… and {} more", names.len() - 8));
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn validation_summary(report: &ValidationReport) -> (String, egui::Color32) {
|
||||
let errors = report.errors();
|
||||
let warnings = report.warnings();
|
||||
if errors > 0 {
|
||||
// Errors dominate the color, but warnings stay visible in the text.
|
||||
let label = if warnings > 0 {
|
||||
format!(
|
||||
"{} · {}",
|
||||
plural(errors, "error", "errors"),
|
||||
plural(warnings, "warning", "warnings")
|
||||
)
|
||||
} else {
|
||||
plural(errors, "error", "errors")
|
||||
};
|
||||
(label, theme::ERROR)
|
||||
} else if warnings > 0 {
|
||||
(plural(warnings, "warning", "warnings"), theme::WARNING)
|
||||
} else {
|
||||
("Valid".to_owned(), theme::SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_tooltip(doc: &SceneDoc, report: &ValidationReport) -> String {
|
||||
let mut text = format!(
|
||||
"Validation\n{} · {} · {}",
|
||||
plural(report.errors(), "error", "errors"),
|
||||
plural(report.warnings(), "warning", "warnings"),
|
||||
plural(report.count(Severity::Info), "info", "info")
|
||||
);
|
||||
let issues = report.sorted();
|
||||
for issue in issues.iter().take(6) {
|
||||
text.push('\n');
|
||||
if let Some(entity) = issue.entity {
|
||||
let name = doc
|
||||
.entity(entity)
|
||||
.map(|node| node.name.as_str())
|
||||
.unwrap_or("missing entity");
|
||||
text.push_str(&format!(
|
||||
"{}: {name} — {}",
|
||||
issue.severity.label(),
|
||||
issue.message
|
||||
));
|
||||
} else {
|
||||
text.push_str(&format!("{}: {}", issue.severity.label(), issue.message));
|
||||
}
|
||||
}
|
||||
if issues.len() > 6 {
|
||||
text.push_str(&format!("\n… and {} more", issues.len() - 6));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn cursor_label(cursor: Option<Vec2>) -> String {
|
||||
match cursor {
|
||||
Some(pos) => format!("x {:.2} y {:.2}", pos.x, pos.y),
|
||||
None => "x — y —".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_label(show_grid: bool, grid_size: f32) -> String {
|
||||
if show_grid {
|
||||
format!("Grid {}", trim_float(grid_size))
|
||||
} else {
|
||||
"Grid off".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn snap_label(snap_to_grid: bool) -> String {
|
||||
if snap_to_grid {
|
||||
"Snap on".to_owned()
|
||||
} else {
|
||||
"Snap off".to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a small float without trailing zeros (`1`, `0.5`, `0.25`).
|
||||
fn trim_float(value: f32) -> String {
|
||||
let text = format!("{value:.2}");
|
||||
text.trim_end_matches('0').trim_end_matches('.').to_owned()
|
||||
}
|
||||
|
||||
fn status_message(status: &str) -> String {
|
||||
if status.trim().is_empty() {
|
||||
"Ready".to_owned()
|
||||
} else {
|
||||
status.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten `text` to at most `max_chars` characters, eliding the middle so both
|
||||
/// the operation ("Saved …") and the file name stay readable.
|
||||
fn shorten(text: &str, max_chars: usize) -> String {
|
||||
let count = text.chars().count();
|
||||
if count <= max_chars || max_chars < 4 {
|
||||
return text.to_owned();
|
||||
}
|
||||
let keep_left = (max_chars - 1) / 2;
|
||||
let keep_right = max_chars - 1 - keep_left;
|
||||
let left: String = text.chars().take(keep_left).collect();
|
||||
let right: String = text.chars().skip(count - keep_right).collect();
|
||||
format!("{left}…{right}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small drawing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn save_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
if cx.session.is_dirty() {
|
||||
ui.label(
|
||||
egui::RichText::new("• Unsaved changes")
|
||||
.color(theme::WARNING)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("The scene has unsaved changes — Ctrl+S saves it");
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new("Saved")
|
||||
.color(theme::SUCCESS)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("All changes are saved");
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_state(ui: &mut egui::Ui, cursor: Option<Vec2>) {
|
||||
ui.label(
|
||||
egui::RichText::new(cursor_label(cursor))
|
||||
.color(theme::MUTED)
|
||||
.monospace()
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Cursor position in world units (2D) or on the ground plane (3D)");
|
||||
}
|
||||
|
||||
fn snap_state(ui: &mut egui::Ui, snap_to_grid: bool) {
|
||||
let color = if snap_to_grid {
|
||||
theme::ACCENT
|
||||
} else {
|
||||
theme::FAINT
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(snap_label(snap_to_grid))
|
||||
.color(color)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Grid snapping — toggle with the hierarchy toolbar or Edit menu");
|
||||
}
|
||||
|
||||
fn grid_state(ui: &mut egui::Ui, show_grid: bool, grid_size: f32) {
|
||||
let color = if show_grid {
|
||||
theme::MUTED
|
||||
} else {
|
||||
theme::FAINT
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(grid_label(show_grid, grid_size))
|
||||
.color(color)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Grid visibility and spacing — G toggles the grid");
|
||||
}
|
||||
|
||||
fn project_state(ui: &mut egui::Ui, cx: &EditorContext<'_>) {
|
||||
match cx.project {
|
||||
Some(project) => {
|
||||
ui.label(
|
||||
egui::RichText::new(shorten(&project.name, 28))
|
||||
.color(theme::MUTED)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(format!("Project: {}", project.root.display()));
|
||||
}
|
||||
None => {
|
||||
ui.label(
|
||||
egui::RichText::new("No project")
|
||||
.color(theme::FAINT)
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text("Open a Bevy project to save scenes and browse assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy_forge_core::Issue;
|
||||
|
||||
fn doc_with_two_entities() -> (SceneDoc, u64, u64) {
|
||||
let mut doc = SceneDoc::new("Status bar test");
|
||||
let parent = doc.add_entity("Player", None);
|
||||
let child = doc.add_entity("Hat", Some(parent));
|
||||
(doc, parent, child)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_are_pluralized() {
|
||||
let mut stats = SceneStats::default();
|
||||
stats.entities = 1;
|
||||
stats.roots = 2;
|
||||
stats.sprites = 0;
|
||||
stats.meshes = 3;
|
||||
stats.lights = 1;
|
||||
assert_eq!(
|
||||
counts_label(&stats),
|
||||
"1 entity · 2 roots · 0 sprites · 3 meshes · 1 light"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_summary_uses_entity_names() {
|
||||
let (doc, parent, child) = doc_with_two_entities();
|
||||
let mut selection = Selection::default();
|
||||
assert_eq!(selection_label(&doc, &selection), "No selection");
|
||||
assert!(selection_tooltip(&doc, &selection).is_none());
|
||||
|
||||
selection.select_only(parent);
|
||||
assert_eq!(selection_label(&doc, &selection), "Selected Player");
|
||||
|
||||
selection.select_many([parent, child], Some(child));
|
||||
assert_eq!(selection_label(&doc, &selection), "2 selected · Hat");
|
||||
let tooltip = selection_tooltip(&doc, &selection).expect("tooltip for selection");
|
||||
assert!(tooltip.contains("Player"));
|
||||
assert!(tooltip.contains("Hat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_selection_names_are_shortened_in_the_strip() {
|
||||
let mut doc = SceneDoc::new("Long names");
|
||||
let id = doc.add_entity("Extremely Long Player Character Name With Suffix", None);
|
||||
let mut selection = Selection::default();
|
||||
selection.select_only(id);
|
||||
let label = selection_label(&doc, &selection);
|
||||
assert!(
|
||||
label.len() < 60,
|
||||
"status strip label stays compact: {label}"
|
||||
);
|
||||
assert!(label.contains('…'), "the middle is elided: {label}");
|
||||
// The tooltip keeps the complete name.
|
||||
let tooltip = selection_tooltip(&doc, &selection).expect("tooltip");
|
||||
assert!(tooltip.contains("Extremely Long Player Character Name With Suffix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_summary_prefers_the_worst_severity() {
|
||||
let mut report = ValidationReport::default();
|
||||
assert_eq!(validation_summary(&report).0, "Valid");
|
||||
|
||||
report.issues.push(Issue::warning("duplicate name"));
|
||||
let (label, color) = validation_summary(&report);
|
||||
assert_eq!(label, "1 warning");
|
||||
assert_eq!(color, theme::WARNING);
|
||||
|
||||
report.issues.push(Issue::error("sprite missing image"));
|
||||
let (label, color) = validation_summary(&report);
|
||||
assert_eq!(label, "1 error · 1 warning");
|
||||
assert_eq!(color, theme::ERROR);
|
||||
|
||||
let mut only_errors = ValidationReport::default();
|
||||
only_errors
|
||||
.issues
|
||||
.push(Issue::error("sprite missing image"));
|
||||
assert_eq!(validation_summary(&only_errors).0, "1 error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_tooltip_lists_issues_with_entity_names() {
|
||||
let (doc, parent, _) = doc_with_two_entities();
|
||||
let mut report = ValidationReport::default();
|
||||
report
|
||||
.issues
|
||||
.push(Issue::error("scale is zero").with_entity(parent));
|
||||
let tooltip = validation_tooltip(&doc, &report);
|
||||
assert!(tooltip.contains("1 error · 0 warnings · 0 info"));
|
||||
assert!(tooltip.contains("Player"));
|
||||
assert!(tooltip.contains("scale is zero"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorten_elides_the_middle_unicode_safely() {
|
||||
assert_eq!(shorten("short", 20), "short");
|
||||
assert_eq!(shorten("abcdefghij", 6), "ab…hij");
|
||||
assert_eq!(
|
||||
shorten("Sauvegardé — jardin.forge.ron", 14).chars().count(),
|
||||
14
|
||||
);
|
||||
let unicode = shorten("Édition — café.forge.ron", 12);
|
||||
assert!(unicode.starts_with("Édit"));
|
||||
assert!(unicode.contains('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_grid_and_snap_labels_are_explicit() {
|
||||
assert_eq!(cursor_label(None), "x — y —");
|
||||
assert_eq!(
|
||||
cursor_label(Some(Vec2::new(1.234, -5.0))),
|
||||
"x 1.23 y -5.00"
|
||||
);
|
||||
assert_eq!(grid_label(true, 1.0), "Grid 1");
|
||||
assert_eq!(grid_label(true, 0.5), "Grid 0.5");
|
||||
assert_eq!(grid_label(false, 1.0), "Grid off");
|
||||
assert_eq!(snap_label(true), "Snap on");
|
||||
assert_eq!(snap_label(false), "Snap off");
|
||||
assert_eq!(trim_float(0.25), "0.25");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Lazily loaded image textures for the viewport and asset browser.
|
||||
//!
|
||||
//! Images are decoded on first use and cached as egui GPU textures. Failure
|
||||
//! results are cached too, so a broken asset is only read once and never blocks
|
||||
//! the UI thread on repeated attempts. The cache is deliberately small and
|
||||
//! unbounded for now: scene projects reference a handful of assets, and
|
||||
//! [`TextureCache::clear`] drops every handle when the project changes.
|
||||
//!
|
||||
//! Two safety guards keep large files from stalling the editor:
|
||||
//!
|
||||
//! * Images wider or taller than [`MAX_DECODE_DIMENSION`] are rejected by the
|
||||
//! decoder instead of being fully decoded into memory.
|
||||
//! * Decoded images larger than [`MAX_UPLOAD_DIMENSION`] are downscaled with
|
||||
//! [`image::DynamicImage::thumbnail`] before upload. The reported
|
||||
//! [`CachedTexture::size`] always stays the *source* pixel size so viewports
|
||||
//! can still lay out sprites at their native dimensions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Source images wider or taller than this are rejected without decoding them
|
||||
/// into a full pixel buffer.
|
||||
const MAX_DECODE_DIMENSION: u32 = 4096;
|
||||
|
||||
/// GPU uploads are downscaled to at most this many pixels per side. The editor
|
||||
/// only shows textures at a few hundred screen pixels, so full-resolution
|
||||
/// uploads would waste VRAM.
|
||||
const MAX_UPLOAD_DIMENSION: u32 = 512;
|
||||
|
||||
pub struct CachedTexture {
|
||||
pub texture: egui::TextureHandle,
|
||||
/// Pixel size of the source image, before any upload downscale.
|
||||
pub size: [usize; 2],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TextureCache {
|
||||
entries: HashMap<PathBuf, CachedTexture>,
|
||||
failed: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl TextureCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load an image once and cache the GPU texture.
|
||||
///
|
||||
/// Returns `None` for missing, oversized or unsupported files. A failure is
|
||||
/// cached permanently (until [`Self::clear`]) so the file is not re-read
|
||||
/// every frame. The returned reference borrows from the cache and must not
|
||||
/// outlive the next mutable use of it.
|
||||
pub fn get_or_load(&mut self, ctx: &egui::Context, path: &Path) -> Option<&CachedTexture> {
|
||||
// Decode and insert first, then return a borrow of the stored entry. All
|
||||
// mutable borrows must finish before the final lookup so the returned
|
||||
// reference can live as long as the caller needs it.
|
||||
let already_cached = self.entries.contains_key(path);
|
||||
let previously_failed = self.failed.contains_key(path);
|
||||
if !already_cached && !previously_failed {
|
||||
match decode_image(path) {
|
||||
Ok((image, source_size)) => {
|
||||
let texture =
|
||||
ctx.load_texture(texture_name(path), image, egui::TextureOptions::LINEAR);
|
||||
self.entries.insert(
|
||||
path.to_path_buf(),
|
||||
CachedTexture {
|
||||
texture,
|
||||
size: source_size,
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
self.failed.insert(path.to_path_buf(), error);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.entries.get(path)
|
||||
}
|
||||
|
||||
pub fn texture(&self, path: &Path) -> Option<&egui::TextureHandle> {
|
||||
self.entries.get(path).map(|entry| &entry.texture)
|
||||
}
|
||||
|
||||
pub fn dimensions(&self, path: &Path) -> Option<[usize; 2]> {
|
||||
self.entries.get(path).map(|entry| entry.size)
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
// Dropping the handles frees the GPU textures.
|
||||
self.entries.clear();
|
||||
self.failed.clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The cached human-readable failure for `path`, if a load was attempted.
|
||||
pub fn error_for(&self, path: &Path) -> Option<&str> {
|
||||
self.failed.get(path).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, path-derived debug name for the egui texture.
|
||||
fn texture_name(path: &Path) -> String {
|
||||
format!("bevy-forge-asset:{}", path.display())
|
||||
}
|
||||
|
||||
/// Decode `path` into an RGBA color image no larger than
|
||||
/// [`MAX_UPLOAD_DIMENSION`], returning the upload image and the source size.
|
||||
fn decode_image(path: &Path) -> Result<(egui::ColorImage, [usize; 2]), String> {
|
||||
let mut reader = image::ImageReader::open(path)
|
||||
.map_err(|error| format!("could not open `{}`: {error}", path.display()))?
|
||||
.with_guessed_format()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"could not detect the format of `{}`: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_DECODE_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_DECODE_DIMENSION);
|
||||
reader.limits(limits);
|
||||
|
||||
let decoded = reader
|
||||
.decode()
|
||||
.map_err(|error| format!("could not decode `{}`: {error}", path.display()))?;
|
||||
let source_size = [decoded.width() as usize, decoded.height() as usize];
|
||||
|
||||
let upload =
|
||||
if decoded.width() > MAX_UPLOAD_DIMENSION || decoded.height() > MAX_UPLOAD_DIMENSION {
|
||||
decoded.thumbnail(MAX_UPLOAD_DIMENSION, MAX_UPLOAD_DIMENSION)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
let rgba = upload.to_rgba8();
|
||||
let upload_size = [rgba.width() as usize, rgba.height() as usize];
|
||||
// `ColorImage::from_rgba_unmultiplied` asserts the buffer length, which is
|
||||
// guaranteed here because `to_rgba8` allocates exactly width * height * 4.
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied(upload_size, rgba.as_raw());
|
||||
Ok((image, source_size))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::{Rgba, RgbaImage};
|
||||
|
||||
fn write_png(path: &Path, width: u32, height: u32, color: [u8; 4]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create test asset directory");
|
||||
}
|
||||
RgbaImage::from_pixel(width, height, Rgba(color))
|
||||
.save(path)
|
||||
.expect("write test PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_fails_once_and_stays_cached_until_clear() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("textures/missing.png");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache
|
||||
.error_for(&path)
|
||||
.expect("a failed read must store an error");
|
||||
assert!(
|
||||
error.contains("missing.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
assert!(cache.texture(&path).is_none());
|
||||
assert!(cache.dimensions(&path).is_none());
|
||||
|
||||
// Failures are permanent: creating the file must not trigger a re-read.
|
||||
write_png(&path, 2, 2, [0, 0, 0, 255]);
|
||||
assert!(
|
||||
cache.get_or_load(&ctx, &path).is_none(),
|
||||
"failures are not retried until clear()"
|
||||
);
|
||||
assert_eq!(cache.len(), 0);
|
||||
|
||||
// ... and clear() drops the failure so a fixed asset can load.
|
||||
cache.clear();
|
||||
assert!(cache.error_for(&path).is_none());
|
||||
assert!(cache.get_or_load(&ctx, &path).is_some());
|
||||
assert_eq!(cache.dimensions(&path), Some([2, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bytes_are_reported_without_panicking() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("not-an-image.png");
|
||||
std::fs::write(&path, b"this is definitely not a PNG").expect("write garbage");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache.error_for(&path).expect("error is cached");
|
||||
assert!(
|
||||
error.contains("not-an-image.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_png_loads_once_and_same_path_reuses_the_texture() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
// Paths with spaces and unicode must work just like plain ones.
|
||||
let path = temp.path().join("textures with space/héro.png");
|
||||
write_png(&path, 6, 4, [10, 20, 30, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
let (texture_id, size) = {
|
||||
let entry = cache
|
||||
.get_or_load(&ctx, &path)
|
||||
.expect("headless egui contexts support texture handles");
|
||||
(entry.texture.id(), entry.size)
|
||||
};
|
||||
assert_eq!(size, [6, 4], "reports source dimensions");
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.error_for(&path).is_none());
|
||||
|
||||
// Overwrite with a different image: the cached entry must win.
|
||||
write_png(&path, 2, 2, [255, 0, 0, 255]);
|
||||
let entry = cache
|
||||
.get_or_load(&ctx, &path)
|
||||
.expect("second call returns the cached entry");
|
||||
assert_eq!(entry.size, [6, 4]);
|
||||
assert_eq!(entry.texture.id(), texture_id, "same GPU texture handle");
|
||||
assert_eq!(cache.len(), 1, "no duplicate entry for the same path");
|
||||
assert_eq!(cache.dimensions(&path), Some([6, 4]));
|
||||
assert_eq!(cache.texture(&path).map(|t| t.id()), Some(texture_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_are_downscaled_for_upload_but_report_source_size() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("big.png");
|
||||
write_png(&path, 640, 320, [1, 2, 3, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
let entry = cache.get_or_load(&ctx, &path).expect("load big image");
|
||||
assert_eq!(entry.size, [640, 320], "source size is preserved");
|
||||
assert_eq!(
|
||||
entry.texture.size(),
|
||||
[512, 256],
|
||||
"upload is downscaled to the 512 px cap, preserving aspect ratio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_images_fail_instead_of_being_decoded() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let path = temp.path().join("huge.png");
|
||||
// 4097 px wide (height stays tiny) is just past the decode cap.
|
||||
write_png(&path, MAX_DECODE_DIMENSION + 1, 1, [0, 0, 0, 255]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &path).is_none());
|
||||
let error = cache.error_for(&path).expect("oversize error is cached");
|
||||
assert!(
|
||||
error.contains("huge.png"),
|
||||
"error should name the file: {error}"
|
||||
);
|
||||
assert!(cache.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_drops_entries_and_failures() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let good = temp.path().join("good.png");
|
||||
let bad = temp.path().join("bad.png");
|
||||
write_png(&good, 3, 3, [0, 0, 0, 255]);
|
||||
std::fs::write(&bad, b"garbage").expect("write garbage");
|
||||
let ctx = egui::Context::default();
|
||||
let mut cache = TextureCache::new();
|
||||
|
||||
assert!(cache.get_or_load(&ctx, &good).is_some());
|
||||
assert!(cache.get_or_load(&ctx, &bad).is_none());
|
||||
assert_eq!(cache.len(), 1);
|
||||
assert!(cache.error_for(&bad).is_some());
|
||||
|
||||
cache.clear();
|
||||
assert!(cache.is_empty());
|
||||
assert!(cache.error_for(&bad).is_none());
|
||||
assert!(cache.texture(&good).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Bevy Forge's visual identity: a calm, dark, slightly blue editor theme.
|
||||
|
||||
use egui::{Color32, CornerRadius, FontFamily, FontId, Stroke, TextStyle, Visuals};
|
||||
|
||||
pub const BG: Color32 = Color32::from_rgb(0x13, 0x15, 0x1c);
|
||||
pub const PANEL: Color32 = Color32::from_rgb(0x1a, 0x1d, 0x27);
|
||||
pub const PANEL_ALT: Color32 = Color32::from_rgb(0x20, 0x24, 0x30);
|
||||
pub const ELEVATED: Color32 = Color32::from_rgb(0x27, 0x2c, 0x3a);
|
||||
pub const BORDER: Color32 = Color32::from_rgb(0x33, 0x39, 0x49);
|
||||
pub const TEXT: Color32 = Color32::from_rgb(0xe8, 0xea, 0xf2);
|
||||
pub const MUTED: Color32 = Color32::from_rgb(0x94, 0x9c, 0xb2);
|
||||
pub const FAINT: Color32 = Color32::from_rgb(0x7f, 0x87, 0x9b);
|
||||
pub const ACCENT: Color32 = Color32::from_rgb(0x7c, 0x9c, 0xff);
|
||||
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x42, 0x54, 0x8c);
|
||||
pub const SELECTION: Color32 = Color32::from_rgb(0xff, 0xd1, 0x66);
|
||||
pub const ERROR: Color32 = Color32::from_rgb(0xff, 0x6b, 0x6b);
|
||||
pub const WARNING: Color32 = Color32::from_rgb(0xff, 0xc8, 0x57);
|
||||
pub const SUCCESS: Color32 = Color32::from_rgb(0x6e, 0xe7, 0xa0);
|
||||
pub const X_AXIS: Color32 = Color32::from_rgb(0xff, 0x6b, 0x7a);
|
||||
pub const Y_AXIS: Color32 = Color32::from_rgb(0x7e, 0xe7, 0x87);
|
||||
pub const Z_AXIS: Color32 = Color32::from_rgb(0x6c, 0xa8, 0xff);
|
||||
pub const GRID_MINOR: Color32 = Color32::from_rgb(0x22, 0x26, 0x31);
|
||||
pub const GRID_MAJOR: Color32 = Color32::from_rgb(0x2d, 0x33, 0x42);
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
// Force the dark theme even when the OS prefers light; `set_visuals` only
|
||||
// affects the currently active theme.
|
||||
ctx.set_theme(egui::ThemePreference::Dark);
|
||||
let mut visuals = Visuals::dark();
|
||||
visuals.panel_fill = PANEL;
|
||||
visuals.window_fill = PANEL;
|
||||
visuals.extreme_bg_color = BG;
|
||||
visuals.faint_bg_color = PANEL_ALT;
|
||||
visuals.code_bg_color = BG;
|
||||
visuals.override_text_color = Some(TEXT);
|
||||
visuals.selection.bg_fill = ACCENT_DIM;
|
||||
visuals.selection.stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
visuals.window_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.noninteractive.bg_fill = PANEL;
|
||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.inactive.bg_fill = PANEL_ALT;
|
||||
visuals.widgets.inactive.weak_bg_fill = PANEL_ALT;
|
||||
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER);
|
||||
visuals.widgets.inactive.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.hovered.bg_fill = ELEVATED;
|
||||
visuals.widgets.hovered.weak_bg_fill = ELEVATED;
|
||||
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, ACCENT_DIM);
|
||||
visuals.widgets.hovered.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.active.bg_fill = ACCENT_DIM;
|
||||
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
|
||||
visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT);
|
||||
visuals.widgets.active.corner_radius = CornerRadius::same(5);
|
||||
visuals.widgets.open.bg_fill = ELEVATED;
|
||||
visuals.window_corner_radius = CornerRadius::same(8);
|
||||
visuals.menu_corner_radius = CornerRadius::same(6);
|
||||
ctx.set_visuals(visuals);
|
||||
|
||||
let mut style = (*ctx.style()).clone();
|
||||
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
style.spacing.button_padding = egui::vec2(8.0, 4.0);
|
||||
style.spacing.indent = 16.0;
|
||||
style.spacing.interact_size.y = 24.0;
|
||||
style.spacing.scroll.bar_width = 8.0;
|
||||
style.text_styles.insert(
|
||||
TextStyle::Heading,
|
||||
FontId::new(19.0, FontFamily::Proportional),
|
||||
);
|
||||
style
|
||||
.text_styles
|
||||
.insert(TextStyle::Body, FontId::new(13.5, FontFamily::Proportional));
|
||||
style.text_styles.insert(
|
||||
TextStyle::Button,
|
||||
FontId::new(13.0, FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
TextStyle::Small,
|
||||
FontId::new(11.5, FontFamily::Proportional),
|
||||
);
|
||||
style.text_styles.insert(
|
||||
TextStyle::Monospace,
|
||||
FontId::new(12.5, FontFamily::Monospace),
|
||||
);
|
||||
ctx.set_style(style);
|
||||
}
|
||||
|
||||
pub fn card_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(PANEL_ALT)
|
||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||
.corner_radius(CornerRadius::same(7))
|
||||
.inner_margin(egui::Margin::symmetric(10, 8))
|
||||
}
|
||||
|
||||
pub fn inset_frame() -> egui::Frame {
|
||||
egui::Frame::new()
|
||||
.fill(BG)
|
||||
.stroke(Stroke::new(1.0_f32, BORDER))
|
||||
.corner_radius(CornerRadius::same(5))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
}
|
||||
|
||||
pub fn subtle_button(text: impl Into<egui::WidgetText>) -> egui::Button<'static> {
|
||||
egui::Button::new(text).frame(false)
|
||||
}
|
||||
|
||||
/// Muted secondary text used for hints and counts.
|
||||
pub fn hint(text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).color(MUTED).size(11.5)
|
||||
}
|
||||
|
||||
pub fn accent_text(text: impl Into<String>) -> egui::RichText {
|
||||
egui::RichText::new(text).color(ACCENT)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Transient toast notifications.
|
||||
//!
|
||||
//! Toasts stack in the bottom-right corner, above the status bar. They expire on
|
||||
//! their own ([`LIFETIME_SECS`]), can be dismissed with a click and never capture
|
||||
//! pointer input outside their own small area. At most [`MAX_TOASTS`] are kept so
|
||||
//! a burst of errors cannot cover the editor.
|
||||
//!
|
||||
//! Ages are advanced from egui's frame delta rather than wall-clock time, which
|
||||
//! keeps the expiry logic pure and unit-testable via [`Toasts::advance`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// How long a toast stays visible before it expires, in seconds.
|
||||
pub const LIFETIME_SECS: f32 = 4.5;
|
||||
/// Maximum number of toasts kept on screen; the oldest is dropped first.
|
||||
pub const MAX_TOASTS: usize = 5;
|
||||
|
||||
/// Fade-in duration at the start of a toast's life.
|
||||
const FADE_IN_SECS: f32 = 0.15;
|
||||
/// Fade-out duration at the end of a toast's life.
|
||||
const FADE_OUT_SECS: f32 = 0.45;
|
||||
/// Text wrap width of a toast message.
|
||||
const MAX_TOAST_WIDTH: f32 = 340.0;
|
||||
/// One frame of animation never counts for more than this (avoids huge jumps
|
||||
/// after the app was suspended).
|
||||
const MAX_FRAME_DT: f32 = 0.25;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ToastLevel {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ToastLevel {
|
||||
/// Single-glyph marker drawn in a colored pill. Every glyph is covered by the
|
||||
/// bundled egui fonts (NotoEmoji for ✔, Ubuntu for the rest).
|
||||
pub const fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "i",
|
||||
Self::Success => "✔",
|
||||
Self::Warning => "!",
|
||||
Self::Error => "×",
|
||||
}
|
||||
}
|
||||
|
||||
/// Spoken name, used for tooltips and tests.
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "Info",
|
||||
Self::Success => "Success",
|
||||
Self::Warning => "Warning",
|
||||
Self::Error => "Error",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn color(self) -> egui::Color32 {
|
||||
match self {
|
||||
Self::Info => theme::ACCENT,
|
||||
Self::Success => theme::SUCCESS,
|
||||
Self::Warning => theme::WARNING,
|
||||
Self::Error => theme::ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Toast {
|
||||
/// Monotonic key for stable egui widget ids. Never reused, so removing one
|
||||
/// toast cannot move another toast's widget state.
|
||||
key: u64,
|
||||
level: ToastLevel,
|
||||
message: String,
|
||||
/// Seconds since the toast was pushed.
|
||||
age: f32,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Visibility multiplier in `0.0..=1.0`, fading in and out at the edges.
|
||||
fn opacity(&self) -> f32 {
|
||||
let fade_in = (self.age / FADE_IN_SECS).clamp(0.0, 1.0);
|
||||
let fade_out = ((LIFETIME_SECS - self.age) / FADE_OUT_SECS).clamp(0.0, 1.0);
|
||||
fade_in.min(fade_out)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Toasts {
|
||||
entries: Vec<Toast>,
|
||||
next_key: u64,
|
||||
}
|
||||
|
||||
impl Toasts {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, level: ToastLevel, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
if message.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
// A repeated message means the user did the same thing twice; refresh the
|
||||
// existing toast instead of stacking duplicates.
|
||||
if let Some(last) = self.entries.last_mut() {
|
||||
if last.level == level && last.message == message {
|
||||
last.age = 0.0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let key = self.next_key;
|
||||
self.next_key = self.next_key.wrapping_add(1);
|
||||
self.entries.push(Toast {
|
||||
key,
|
||||
level,
|
||||
message,
|
||||
age: 0.0,
|
||||
});
|
||||
while self.entries.len() > MAX_TOASTS {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Info, message);
|
||||
}
|
||||
|
||||
pub fn success(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Success, message);
|
||||
}
|
||||
|
||||
pub fn warning(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Warning, message);
|
||||
}
|
||||
|
||||
pub fn error(&mut self, message: impl Into<String>) {
|
||||
self.push(ToastLevel::Error, message);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Age all toasts by `dt` seconds and drop the expired ones.
|
||||
///
|
||||
/// Kept separate from [`Self::show`] so expiry is testable without an egui
|
||||
/// context.
|
||||
fn advance(&mut self, dt: f32) {
|
||||
let dt = dt.max(0.0);
|
||||
for entry in &mut self.entries {
|
||||
entry.age += dt;
|
||||
}
|
||||
self.entries.retain(|entry| entry.age < LIFETIME_SECS);
|
||||
}
|
||||
|
||||
/// Dismiss one toast by its stable key.
|
||||
fn dismiss(&mut self, key: u64) {
|
||||
self.entries.retain(|entry| entry.key != key);
|
||||
}
|
||||
|
||||
/// Draw the toast stack and expire old entries.
|
||||
pub fn show(&mut self, ctx: &egui::Context) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dt = ctx.input(|input| input.stable_dt).clamp(0.0, MAX_FRAME_DT);
|
||||
self.advance(dt);
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut dismissed = None;
|
||||
egui::Area::new(egui::Id::new("bevy_forge_toasts"))
|
||||
// Sit above the 25 px status bar with a small margin.
|
||||
.anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -34.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.interactable(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_max_width(MAX_TOAST_WIDTH + 32.0);
|
||||
ui.vertical(|ui| {
|
||||
// Oldest first: the newest toast is closest to the status bar.
|
||||
for entry in &self.entries {
|
||||
ui.push_id(entry.key, |ui| {
|
||||
ui.set_opacity(entry.opacity());
|
||||
if toast_ui(ui, entry).clicked() {
|
||||
dismissed = Some(entry.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(key) = dismissed {
|
||||
self.dismiss(key);
|
||||
}
|
||||
|
||||
// Animate only while toasts are visible; an idle editor repaints on demand.
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a single toast. The returned response covers the whole card and is
|
||||
/// clickable, so a click anywhere on the toast dismisses it.
|
||||
fn toast_ui(ui: &mut egui::Ui, entry: &Toast) -> egui::Response {
|
||||
let color = entry.level.color();
|
||||
let frame = egui::Frame::popup(ui.style())
|
||||
.fill(theme::ELEVATED)
|
||||
.stroke(egui::Stroke::new(1.0_f32, color.gamma_multiply(0.65)))
|
||||
.corner_radius(egui::CornerRadius::same(7))
|
||||
.inner_margin(egui::Margin::symmetric(10, 7))
|
||||
.outer_margin(egui::Margin::symmetric(0, 2));
|
||||
|
||||
let inner = frame.show(ui, |ui| {
|
||||
ui.set_max_width(MAX_TOAST_WIDTH);
|
||||
ui.horizontal(|ui| {
|
||||
egui::Frame::new()
|
||||
.fill(color.gamma_multiply(0.16))
|
||||
.corner_radius(egui::CornerRadius::same(9))
|
||||
.inner_margin(egui::Margin::symmetric(5, 1))
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(entry.level.icon())
|
||||
.color(color)
|
||||
.strong()
|
||||
.size(11.0),
|
||||
)
|
||||
.on_hover_text(entry.level.label());
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&entry.message)
|
||||
.color(theme::TEXT)
|
||||
.size(12.0),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
inner
|
||||
.response
|
||||
.interact(egui::Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(format!("{} — click to dismiss", entry.level.label()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toasts_expire_after_their_lifetime() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info("Saved garden.forge.ron");
|
||||
toasts.success("Exported scene.rs");
|
||||
assert_eq!(toasts.len(), 2);
|
||||
|
||||
toasts.advance(LIFETIME_SECS - 0.1);
|
||||
assert_eq!(toasts.len(), 2, "still visible just before the deadline");
|
||||
|
||||
toasts.advance(0.2);
|
||||
assert!(toasts.is_empty(), "expired toasts are removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_five_toasts_are_kept() {
|
||||
let mut toasts = Toasts::new();
|
||||
for index in 0..MAX_TOASTS + 3 {
|
||||
toasts.warning(format!("warning {index}"));
|
||||
}
|
||||
assert_eq!(toasts.len(), MAX_TOASTS);
|
||||
assert!(toasts.entries.iter().all(|entry| entry.age == 0.0));
|
||||
// The oldest three were dropped.
|
||||
assert_eq!(toasts.entries[0].message, "warning 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_messages_refresh_instead_of_stacking() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.error("Export failed");
|
||||
toasts.advance(2.0);
|
||||
assert!(toasts.entries[0].age > 0.0);
|
||||
|
||||
toasts.error("Export failed");
|
||||
assert_eq!(toasts.len(), 1);
|
||||
assert_eq!(toasts.entries[0].age, 0.0, "the toast timer restarts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_messages_are_ignored() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info(" ");
|
||||
assert!(toasts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissal_and_clear_work() {
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.info("one");
|
||||
toasts.info("two");
|
||||
let key = toasts.entries[0].key;
|
||||
toasts.dismiss(key);
|
||||
assert_eq!(toasts.len(), 1);
|
||||
assert_eq!(toasts.entries[0].message, "two");
|
||||
|
||||
toasts.clear();
|
||||
assert!(toasts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_fades_in_and_out() {
|
||||
let mut entry = Toast {
|
||||
key: 0,
|
||||
level: ToastLevel::Info,
|
||||
message: "fade".into(),
|
||||
age: 0.0,
|
||||
};
|
||||
assert_eq!(entry.opacity(), 0.0, "starts transparent");
|
||||
entry.age = FADE_IN_SECS;
|
||||
assert_eq!(entry.opacity(), 1.0, "fully visible while live");
|
||||
entry.age = LIFETIME_SECS - FADE_OUT_SECS / 2.0;
|
||||
assert!(entry.opacity() < 1.0 && entry.opacity() > 0.0, "fades out");
|
||||
entry.age = LIFETIME_SECS;
|
||||
assert_eq!(entry.opacity(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_markers_are_distinct() {
|
||||
let levels = [
|
||||
ToastLevel::Info,
|
||||
ToastLevel::Success,
|
||||
ToastLevel::Warning,
|
||||
ToastLevel::Error,
|
||||
];
|
||||
for (index, level) in levels.iter().enumerate() {
|
||||
assert!(!level.icon().is_empty());
|
||||
assert!(!level.label().is_empty());
|
||||
assert_ne!(level.color(), levels[(index + 1) % levels.len()].color());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_is_headless_safe() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut toasts = Toasts::new();
|
||||
toasts.success("Rendered headlessly");
|
||||
let raw_input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(960.0, 640.0),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = ctx.run(raw_input, |ctx| toasts.show(ctx));
|
||||
assert_eq!(toasts.len(), 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,540 @@
|
||||
//! Welcome screen shown when no project is open: brand hero, project opener,
|
||||
//! recent projects, starter scenes and demo hints.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy_forge_core::{EditorConfig, ProjectTemplate};
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
const FEATURES: &[(&str, &str)] = &[
|
||||
("2D & 3D viewports", "pan, zoom and gizmos"),
|
||||
("Hierarchy & inspector", "transforms, components, assets"),
|
||||
("Undo, redo & validation", "safe scene edits"),
|
||||
("Asset browser", "thumbnails and drag-to-place"),
|
||||
("BSN & Rust export", "paste-ready Bevy 0.20 code"),
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WelcomeState {
|
||||
pub project_path: String,
|
||||
pub error: Option<String>,
|
||||
/// Set once the path field has received initial focus.
|
||||
pub focus_requested: bool,
|
||||
}
|
||||
|
||||
pub fn show(ctx: &egui::Context, state: &mut WelcomeState, config: &EditorConfig) -> Vec<Action> {
|
||||
let mut actions = Vec::new();
|
||||
let margin = 56.0;
|
||||
egui::TopBottomPanel::bottom("welcome_footer")
|
||||
.exact_height(36.0)
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(theme::BG)
|
||||
.inner_margin(egui::Margin::symmetric(0, 4)),
|
||||
)
|
||||
.show(ctx, |ui| footer(ui, margin));
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame::new().fill(theme::BG))
|
||||
.show(ctx, |ui| {
|
||||
paint_backdrop(ui);
|
||||
let content_height = ui.available_height();
|
||||
let gap = 48.0;
|
||||
let inner_width = (ui.available_width() - 2.0 * margin - gap).max(520.0);
|
||||
let right_width = (inner_width * 0.43).clamp(360.0, 500.0);
|
||||
let left_width = inner_width - right_width;
|
||||
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(ui.available_width(), content_height),
|
||||
egui::Layout::left_to_right(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.add_space(margin);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(left_width, content_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
ui.add_space((content_height * 0.22).min(200.0));
|
||||
brand_column(ui);
|
||||
},
|
||||
);
|
||||
ui.add_space(gap);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(right_width, content_height),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(content_height)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.add_space((content_height * 0.27).min(240.0));
|
||||
project_card(ui, state, config, &mut actions);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
actions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hero (left column)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn brand_column(ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
diamond_mark(ui, 15.0, theme::ACCENT);
|
||||
ui.label(
|
||||
egui::RichText::new("BEVY FORGE")
|
||||
.size(11.5)
|
||||
.strong()
|
||||
.color(theme::MUTED),
|
||||
);
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy Forge")
|
||||
.size(52.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new("Design Bevy scenes visually. Export real BSN code.")
|
||||
.size(18.0)
|
||||
.color(theme::MUTED),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
let (rule, _) = ui.allocate_exact_size(egui::vec2(72.0, 2.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rule, 1.0, theme::ACCENT);
|
||||
ui.add_space(16.0);
|
||||
|
||||
for (lead, detail) in FEATURES {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new("•").size(14.0).color(theme::ACCENT));
|
||||
ui.label(
|
||||
egui::RichText::new(*lead)
|
||||
.size(14.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.label(egui::RichText::new(*detail).size(12.5).color(theme::MUTED));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
ui.add_space(18.0);
|
||||
ui.horizontal(|ui| {
|
||||
badge(ui, "ALPHA");
|
||||
badge(ui, concat!("v", env!("CARGO_PKG_VERSION")));
|
||||
badge(ui, "Bevy 0.20 · BSN");
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Open source · MIT OR Apache-2.0")
|
||||
.size(10.5)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
}
|
||||
|
||||
fn badge(ui: &mut egui::Ui, text: &str) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(7, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(text).size(10.0).color(theme::MUTED));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project card (right column)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn project_card(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut WelcomeState,
|
||||
config: &EditorConfig,
|
||||
actions: &mut Vec<Action>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::BORDER))
|
||||
.corner_radius(egui::CornerRadius::same(12))
|
||||
.inner_margin(egui::Margin::symmetric(22, 20))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
|
||||
widgets::section_label(ui, "Open a project");
|
||||
ui.label(
|
||||
egui::RichText::new("Start from a Bevy project")
|
||||
.size(19.0)
|
||||
.strong()
|
||||
.color(theme::TEXT),
|
||||
);
|
||||
ui.add(
|
||||
egui::Label::new(theme::hint(
|
||||
"Scenes load from <project>/scenes and assets from <project>/assets.",
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(9.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let field_width = (ui.available_width() - 84.0).max(140.0);
|
||||
let response = ui.add_sized(
|
||||
[field_width, 28.0],
|
||||
egui::TextEdit::singleline(&mut state.project_path)
|
||||
.id_salt("welcome_project_path")
|
||||
.hint_text("/path/to/bevy-project")
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.margin(egui::Margin::symmetric(7, 5)),
|
||||
);
|
||||
if !state.focus_requested {
|
||||
response.request_focus();
|
||||
state.focus_requested = true;
|
||||
}
|
||||
if response.changed() {
|
||||
state.error = None;
|
||||
}
|
||||
let open_clicked = ui
|
||||
.add_sized([76.0, 28.0], widgets::primary_button("Open"))
|
||||
.clicked();
|
||||
let enter =
|
||||
response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
|
||||
if open_clicked || enter {
|
||||
try_open(state, actions);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(error) = &state.error {
|
||||
ui.add_space(3.0);
|
||||
error_panel(ui, error);
|
||||
}
|
||||
|
||||
let recents: Vec<&PathBuf> = config.recent_projects.iter().take(5).collect();
|
||||
if !recents.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Recent projects");
|
||||
for path in recents {
|
||||
if recent_row(ui, path) {
|
||||
actions.push(Action::OpenProject(path.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Start a new scene");
|
||||
let button_width = ((ui.available_width() - 8.0) / 2.0).max(120.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_sized(
|
||||
[button_width, 30.0],
|
||||
egui::Button::new(egui::RichText::new("2D starter").size(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::TwoD));
|
||||
}
|
||||
if ui
|
||||
.add_sized(
|
||||
[button_width, 30.0],
|
||||
egui::Button::new(egui::RichText::new("3D starter").size(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::NewScene(ProjectTemplate::ThreeD));
|
||||
}
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(theme::hint(
|
||||
"New scenes open in the editor. Save them into a project with Ctrl+S.",
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.separator();
|
||||
widgets::section_label(ui, "Try the demo");
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.button("Open demo project")
|
||||
.on_hover_text("examples/demo-project — a Bevy 0.20 project with two scenes")
|
||||
.clicked()
|
||||
{
|
||||
actions.push(Action::OpenProject(demo_project_path()));
|
||||
}
|
||||
ui.label(theme::hint("garden (3D) + platformer (2D)"));
|
||||
});
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new("cargo run -p bevy_forge_editor -- examples/demo-project")
|
||||
.monospace()
|
||||
.size(9.5)
|
||||
.color(theme::FAINT),
|
||||
)
|
||||
.selectable(true)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn try_open(state: &mut WelcomeState, actions: &mut Vec<Action>) {
|
||||
match crate::dialogs::validate_project_input(&state.project_path) {
|
||||
Ok(path) => {
|
||||
state.error = None;
|
||||
actions.push(Action::OpenProject(path));
|
||||
}
|
||||
Err(message) => state.error = Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_panel(ui: &mut egui::Ui, message: &str) {
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgb(52, 24, 30))
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ERROR))
|
||||
.corner_radius(egui::CornerRadius::same(5))
|
||||
.inner_margin(egui::Margin::symmetric(9, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(message).color(theme::ERROR).size(11.0))
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn recent_row(ui: &mut egui::Ui, path: &Path) -> bool {
|
||||
let name = folder_name(path);
|
||||
let full = path.display().to_string();
|
||||
let subtitle = compact_path(&full, 52);
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 36.0), egui::Sense::click());
|
||||
let hovered = response.hovered();
|
||||
let painter = ui.painter();
|
||||
let row = rect.shrink2(egui::vec2(0.0, 1.5));
|
||||
painter.rect_filled(
|
||||
row,
|
||||
6.0,
|
||||
if hovered {
|
||||
theme::ELEVATED
|
||||
} else {
|
||||
theme::PANEL
|
||||
},
|
||||
);
|
||||
painter.rect_stroke(
|
||||
row,
|
||||
6.0,
|
||||
egui::Stroke::new(1.0_f32, theme::BORDER),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.left() + 9.0, row.top() + 6.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
&name,
|
||||
egui::FontId::proportional(12.5),
|
||||
theme::TEXT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.left() + 9.0, row.top() + 21.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
subtitle,
|
||||
egui::FontId::monospace(9.5),
|
||||
theme::FAINT,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(row.right() - 9.0, row.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
"Open",
|
||||
egui::FontId::proportional(10.0),
|
||||
theme::ACCENT,
|
||||
);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
let response = response.on_hover_text(full);
|
||||
response.clicked()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chrome
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Draw the Bevy Forge diamond mark. Painted instead of a glyph so it renders
|
||||
/// identically with every font configuration.
|
||||
fn diamond_mark(ui: &mut egui::Ui, size: f32, color: egui::Color32) {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover());
|
||||
let center = rect.center();
|
||||
let radius = size * 0.5;
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![
|
||||
egui::pos2(center.x, center.y - radius),
|
||||
egui::pos2(center.x + radius, center.y),
|
||||
egui::pos2(center.x, center.y + radius),
|
||||
egui::pos2(center.x - radius, center.y),
|
||||
],
|
||||
color,
|
||||
egui::Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
fn paint_backdrop(ui: &egui::Ui) {
|
||||
let rect = ui.max_rect();
|
||||
let painter = ui.painter();
|
||||
painter.rect_filled(rect, 0.0, theme::BG);
|
||||
painter.circle_filled(
|
||||
egui::pos2(
|
||||
rect.left() + rect.width() * 0.16,
|
||||
rect.top() + rect.height() * 0.22,
|
||||
),
|
||||
rect.width() * 0.24,
|
||||
egui::Color32::from_rgba_unmultiplied(124, 156, 255, 10),
|
||||
);
|
||||
painter.circle_filled(
|
||||
egui::pos2(
|
||||
rect.left() + rect.width() * 0.88,
|
||||
rect.top() + rect.height() * 0.82,
|
||||
),
|
||||
rect.width() * 0.18,
|
||||
egui::Color32::from_rgba_unmultiplied(66, 84, 140, 14),
|
||||
);
|
||||
painter.rect_filled(
|
||||
egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), 1.0)),
|
||||
0.0,
|
||||
theme::ACCENT_DIM,
|
||||
);
|
||||
}
|
||||
|
||||
fn footer(ui: &mut egui::Ui, margin: f32) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(margin);
|
||||
ui.label(
|
||||
egui::RichText::new("Bevy 0.20 · BSN")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("·").size(11.0).color(theme::BORDER));
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
egui::RichText::new("Made for the Bevy community")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(margin);
|
||||
ui.label(
|
||||
egui::RichText::new("command palette")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
widgets::kbd(ui, "Ctrl+K");
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
egui::RichText::new("shortcuts")
|
||||
.size(11.0)
|
||||
.color(theme::FAINT),
|
||||
);
|
||||
widgets::kbd(ui, "F1");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers (pure; unit-tested)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The bundled demo project, resolved at compile time from the crate location.
|
||||
pub fn demo_project_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project")
|
||||
}
|
||||
|
||||
fn folder_name(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| path.display().to_string())
|
||||
}
|
||||
|
||||
/// Shorten a path to its tail, keeping complete trailing components so recent
|
||||
/// projects stay recognisable. Example: `…/bevy-forge/examples/demo-project`.
|
||||
fn compact_path(path: &str, max_chars: usize) -> String {
|
||||
let count = path.chars().count();
|
||||
if count <= max_chars {
|
||||
return path.to_string();
|
||||
}
|
||||
if max_chars < 8 {
|
||||
return path.to_string();
|
||||
}
|
||||
let keep = max_chars - 2;
|
||||
let tail: String = path.chars().skip(count - keep).collect();
|
||||
// Drop the first (likely partial) component and keep the last components.
|
||||
let tail = tail
|
||||
.find('/')
|
||||
.map(|index| &tail[index..])
|
||||
.unwrap_or(tail.as_str());
|
||||
format!("…{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compacts_long_paths_to_their_tail() {
|
||||
let path = "/home/emil/Documents/deepseek-harness/default-workspace/bevy-forge/examples/demo-project";
|
||||
let short = compact_path(path, 40);
|
||||
assert!(short.chars().count() <= 40);
|
||||
assert!(short.starts_with("…/"));
|
||||
assert!(short.ends_with("examples/demo-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_short_paths_unchanged() {
|
||||
assert_eq!(compact_path("/tmp/game", 40), "/tmp/game");
|
||||
assert_eq!(compact_path("/tmp/game", 4), "/tmp/game");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacting_handles_multibyte_characters() {
|
||||
let path = "/home/émil/jeux/bevy-forge/examples/demo-project";
|
||||
let short = compact_path(path, 20);
|
||||
assert!(short.chars().count() <= 20);
|
||||
assert!(short.starts_with("…/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_name_prefers_the_last_component() {
|
||||
assert_eq!(folder_name(Path::new("/tmp/games/demo")), "demo");
|
||||
assert_eq!(folder_name(Path::new("/")), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_project_path_points_at_the_bundled_example() {
|
||||
let path = demo_project_path();
|
||||
assert!(path.ends_with("examples/demo-project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_validation_errors_are_human_readable() {
|
||||
let mut state = WelcomeState::default();
|
||||
let mut actions = Vec::new();
|
||||
try_open(&mut state, &mut actions);
|
||||
assert!(actions.is_empty());
|
||||
assert!(state.error.as_deref().unwrap_or("").contains("path"));
|
||||
|
||||
state.project_path = " /tmp/game ".into();
|
||||
try_open(&mut state, &mut actions);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![Action::OpenProject(PathBuf::from("/tmp/game"))]
|
||||
);
|
||||
assert!(state.error.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Small reusable widgets shared by the inspector, hierarchy and viewports.
|
||||
|
||||
use bevy_forge_core::{Color, Vec2 as FVec2, Vec3 as FVec3};
|
||||
use egui::{Response, RichText, Ui};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
pub fn color32(color: Color) -> egui::Color32 {
|
||||
egui::Color32::from_rgba_unmultiplied(
|
||||
(color.r.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.g.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.b.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
(color.a.clamp(0.0, 1.0) * 255.0).round() as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn color_from_egui(color: egui::Color32) -> Color {
|
||||
Color::from_rgba8(color.r(), color.g(), color.b(), color.a())
|
||||
}
|
||||
|
||||
pub fn section_label(ui: &mut Ui, text: impl Into<String>) {
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new(text.into().to_uppercase())
|
||||
.color(theme::MUTED)
|
||||
.size(10.5)
|
||||
.strong(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn field_label(ui: &mut Ui, text: &str) -> Response {
|
||||
ui.label(RichText::new(text).color(theme::MUTED).size(12.0))
|
||||
}
|
||||
|
||||
pub fn help_marker(ui: &mut Ui, text: &str) {
|
||||
ui.label(RichText::new("?").color(theme::FAINT).size(11.0))
|
||||
.on_hover_text(text);
|
||||
}
|
||||
|
||||
pub fn vec2_editor(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
value: &mut FVec2,
|
||||
speed: f32,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.x)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.y)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn vec3_editor(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
value: &mut FVec3,
|
||||
speed: f32,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("X").color(theme::X_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.x)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Y").color(theme::Y_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.y)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
ui.label(RichText::new("Z").color(theme::Z_AXIS).size(11.0));
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.z)
|
||||
.speed(speed)
|
||||
.fixed_decimals(3),
|
||||
)
|
||||
.changed();
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn color_editor(ui: &mut Ui, id_salt: impl std::hash::Hash, value: &mut Color) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
let mut color = color32(*value);
|
||||
if ui.color_edit_button_srgba(&mut color).changed() {
|
||||
*value = color_from_egui(color);
|
||||
changed = true;
|
||||
}
|
||||
let mut hex = value.to_hex();
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(&mut hex)
|
||||
.desired_width(78.0)
|
||||
.font(egui::TextStyle::Monospace),
|
||||
);
|
||||
if response.changed() {
|
||||
if let Some(parsed) = Color::from_hex(&hex) {
|
||||
*value = Color::srgba(parsed.r, parsed.g, parsed.b, value.a);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if value.a < 1.0 {
|
||||
changed |= ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut value.a)
|
||||
.speed(0.01)
|
||||
.range(0.0..=1.0)
|
||||
.fixed_decimals(2),
|
||||
)
|
||||
.changed();
|
||||
}
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Asset combo box. Returns true when the selection changed. `current` is
|
||||
/// `None` for "no asset"; the first entry clears the selection.
|
||||
pub fn asset_picker(
|
||||
ui: &mut Ui,
|
||||
id_salt: impl std::hash::Hash,
|
||||
current: &mut Option<String>,
|
||||
assets: &[String],
|
||||
placeholder: &str,
|
||||
) -> bool {
|
||||
ui.push_id(id_salt, |ui| {
|
||||
let mut changed = false;
|
||||
egui::ComboBox::from_id_salt(ui.id())
|
||||
.selected_text(current.as_deref().unwrap_or(placeholder).to_string())
|
||||
.width(ui.available_width().min(260.0))
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(current.is_none(), format!("— {placeholder}"))
|
||||
.clicked()
|
||||
{
|
||||
*current = None;
|
||||
changed = true;
|
||||
}
|
||||
for asset in assets {
|
||||
let selected = current.as_deref() == Some(asset.as_str());
|
||||
if ui.selectable_label(selected, asset).clicked() {
|
||||
*current = Some(asset.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if assets.is_empty() {
|
||||
ui.label(theme::hint("No matching assets in this project"));
|
||||
}
|
||||
});
|
||||
changed
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
pub fn search_field(ui: &mut Ui, hint: &str, text: &mut String) -> Response {
|
||||
let response = ui.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.hint_text(hint)
|
||||
.desired_width(ui.available_width())
|
||||
.margin(egui::Margin::symmetric(6, 4)),
|
||||
);
|
||||
if !text.is_empty() && response.has_focus() {
|
||||
// Keep the field responsive; Escape clears via the global shortcut handler.
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub fn icon_button(ui: &mut Ui, icon: &str, tooltip: &str) -> Response {
|
||||
ui.add(egui::Button::new(RichText::new(icon).size(14.0)).frame(false))
|
||||
.on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
pub fn toolbar_toggle(ui: &mut Ui, selected: bool, label: &str, tooltip: &str) -> Response {
|
||||
ui.selectable_label(selected, label).on_hover_text(tooltip)
|
||||
}
|
||||
|
||||
pub fn primary_button(text: &str) -> egui::Button<'static> {
|
||||
egui::Button::new(RichText::new(text).strong())
|
||||
.fill(theme::ACCENT_DIM)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ACCENT))
|
||||
}
|
||||
|
||||
pub fn danger_button(text: &str) -> egui::Button<'static> {
|
||||
egui::Button::new(RichText::new(text).color(theme::ERROR))
|
||||
.fill(theme::PANEL_ALT)
|
||||
.stroke(egui::Stroke::new(1.0_f32, theme::ERROR))
|
||||
}
|
||||
|
||||
pub fn kbd(ui: &mut Ui, keys: &str) {
|
||||
ui.label(
|
||||
RichText::new(keys)
|
||||
.color(theme::MUTED)
|
||||
.monospace()
|
||||
.size(11.0)
|
||||
.background_color(theme::BG),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn empty_state(ui: &mut Ui, title: &str, hint: &str) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(18.0);
|
||||
ui.label(RichText::new(title).color(theme::MUTED).size(13.0));
|
||||
ui.add_space(3.0);
|
||||
ui.label(RichText::new(hint).color(theme::FAINT).size(11.5));
|
||||
ui.add_space(18.0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
//! Headless end-to-end UI tests for Bevy Forge.
|
||||
//!
|
||||
//! These tests drive the real [`ForgeApp`] through `egui::Context::run` with no
|
||||
//! window, GPU, display or network. They cover:
|
||||
//!
|
||||
//! * render smoke at 1440x900 and 1000x640 in both 2D and 3D view modes,
|
||||
//! * every panel/drawer toggle plus the command palette and all dialogs,
|
||||
//! * app-level actions (undo/redo/duplicate/delete/view mode/grid),
|
||||
//! * `.forge.ron` save/load round-trips through a temporary Bevy project,
|
||||
//! * the unsaved-changes close guard and quit flags.
|
||||
//!
|
||||
//! Only the public editor API is used; panel internals are intentionally not
|
||||
//! inspected. The tests are deterministic and independent (each builds its own
|
||||
//! context/app) and finish in well under a second of CPU time.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Once;
|
||||
|
||||
use bevy_forge_core::{samples, ComponentData, SceneDoc, SpriteData, Transform, Vec3};
|
||||
use bevy_forge_editor::action::Action;
|
||||
use bevy_forge_editor::app::ForgeApp;
|
||||
use bevy_forge_editor::state::ViewMode;
|
||||
use bevy_forge_editor::Screen;
|
||||
use eframe::egui;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Point the editor config at a throwaway directory.
|
||||
///
|
||||
/// `ForgeApp::open_project` (and several toggle actions) persist the editor
|
||||
/// preferences through `EditorConfig::save`, which honours `XDG_CONFIG_HOME`.
|
||||
/// Keeping that inside a temp dir makes the tests hermetic and stops them from
|
||||
/// adding temporary projects to the developer's real recent-projects list.
|
||||
fn isolate_editor_config() {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("bevy-forge-test-config-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create test config dir");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headless frame driver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FrameStats {
|
||||
/// Number of top-level shapes emitted by egui this frame.
|
||||
shapes: usize,
|
||||
/// Number of tessellated, clipped primitives.
|
||||
clipped_primitives: usize,
|
||||
/// Number of tessellated meshes (text/rects/images become meshes).
|
||||
meshes: usize,
|
||||
/// Total vertex count across all tessellated meshes.
|
||||
vertices: usize,
|
||||
/// Area of the screen used by the panels this frame.
|
||||
used_rect: egui::Rect,
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
shapes: 0,
|
||||
clipped_primitives: 0,
|
||||
meshes: 0,
|
||||
vertices: 0,
|
||||
used_rect: egui::Rect::NOTHING,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ForgeApp` plus the headless egui context driving it.
|
||||
struct HeadlessEditor {
|
||||
ctx: egui::Context,
|
||||
app: ForgeApp,
|
||||
size: egui::Vec2,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl HeadlessEditor {
|
||||
fn new(size: egui::Vec2, doc: SceneDoc) -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
let app = ForgeApp::new_for_test(&ctx, doc);
|
||||
Self {
|
||||
ctx,
|
||||
app,
|
||||
size,
|
||||
frame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, action: Action) {
|
||||
let ctx = self.ctx.clone();
|
||||
self.app.dispatch_action(&ctx, action);
|
||||
}
|
||||
|
||||
fn frame(&mut self) -> FrameStats {
|
||||
self.frame_with_events(Vec::new())
|
||||
}
|
||||
|
||||
fn frame_with_events(&mut self, events: Vec<egui::Event>) -> FrameStats {
|
||||
self.frame += 1;
|
||||
let mut raw = egui::RawInput::default();
|
||||
raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size));
|
||||
raw.viewports
|
||||
.entry(egui::ViewportId::ROOT)
|
||||
.or_default()
|
||||
.native_pixels_per_point = Some(1.0);
|
||||
// Deterministic, monotonically increasing time keeps animations/toasts stable.
|
||||
raw.time = Some(self.frame as f64 / 60.0);
|
||||
raw.predicted_dt = 1.0 / 60.0;
|
||||
raw.events = events;
|
||||
|
||||
let ctx = self.ctx.clone();
|
||||
let output = ctx.run(raw, |ctx| self.app.ui(ctx));
|
||||
let shape_count = output.shapes.len();
|
||||
let clipped = ctx.tessellate(output.shapes, output.pixels_per_point);
|
||||
let mut meshes = 0usize;
|
||||
let mut vertices = 0usize;
|
||||
for primitive in &clipped {
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive {
|
||||
meshes += 1;
|
||||
vertices += mesh.vertices.len();
|
||||
}
|
||||
}
|
||||
FrameStats {
|
||||
shapes: shape_count,
|
||||
clipped_primitives: clipped.len(),
|
||||
meshes,
|
||||
vertices,
|
||||
used_rect: ctx.used_rect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(&mut self, count: usize) -> FrameStats {
|
||||
let mut last = FrameStats::empty();
|
||||
for _ in 0..count {
|
||||
last = self.frame();
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
/// Render one frame and assert it painted something tessellable.
|
||||
fn expect_frame(&mut self, context: &str) -> FrameStats {
|
||||
let stats = self.frame();
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"{context}: the frame produced no egui shapes"
|
||||
);
|
||||
assert!(
|
||||
stats.clipped_primitives > 0,
|
||||
"{context}: tessellation produced no clipped primitives"
|
||||
);
|
||||
assert!(
|
||||
stats.meshes > 0 && stats.vertices > 0,
|
||||
"{context}: tessellation produced no mesh vertices"
|
||||
);
|
||||
assert!(
|
||||
stats.used_rect.is_finite(),
|
||||
"{context}: non-finite used rect {:?}",
|
||||
stats.used_rect
|
||||
);
|
||||
stats
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key {
|
||||
key,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rendered(app: &ForgeApp, stats: &FrameStats, label: &str, size: egui::Vec2) {
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"{label} at {size:?}: expected at least one painted egui shape"
|
||||
);
|
||||
assert!(
|
||||
stats.clipped_primitives > 0 && stats.meshes > 0 && stats.vertices > 0,
|
||||
"{label} at {size:?}: tessellation was empty (shapes={}, prims={}, meshes={}, verts={})",
|
||||
stats.shapes,
|
||||
stats.clipped_primitives,
|
||||
stats.meshes,
|
||||
stats.vertices
|
||||
);
|
||||
assert!(
|
||||
stats.used_rect.is_finite() && stats.used_rect.is_positive(),
|
||||
"{label} at {size:?}: used rect is not a finite, positive area: {:?}",
|
||||
stats.used_rect
|
||||
);
|
||||
|
||||
let viewport = app.ui.viewport_rect;
|
||||
assert!(
|
||||
viewport.is_finite(),
|
||||
"{label} at {size:?}: viewport rect is non-finite: {viewport:?}"
|
||||
);
|
||||
assert!(
|
||||
viewport.width() > 1.0 && viewport.height() > 1.0,
|
||||
"{label} at {size:?}: viewport rect has no area: {viewport:?}"
|
||||
);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
|
||||
assert!(
|
||||
viewport.intersects(screen),
|
||||
"{label} at {size:?}: viewport rect {viewport:?} does not intersect the {size:?} screen"
|
||||
);
|
||||
eprintln!(
|
||||
"render-smoke {label} {size:?}: shapes={} prims={} meshes={} verts={} used={:?} viewport={:?}",
|
||||
stats.shapes, stats.clipped_primitives, stats.meshes, stats.vertices,
|
||||
stats.used_rect, viewport
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Render smoke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn render_smoke_both_sizes_and_view_modes() {
|
||||
isolate_editor_config();
|
||||
|
||||
for size in [egui::vec2(1440.0, 900.0), egui::vec2(1000.0, 640.0)] {
|
||||
// 2D demo: starts in 2D, renders, then switches to the 3D viewport.
|
||||
let mut editor = HeadlessEditor::new(size, samples::platformer_demo());
|
||||
assert_eq!(editor.app.screen, Screen::Editor);
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD);
|
||||
|
||||
let stats = editor.frames(8);
|
||||
assert_rendered(&editor.app, &stats, "platformer demo (2D)", size);
|
||||
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
let stats = editor.frames(6);
|
||||
assert_rendered(&editor.app, &stats, "platformer demo (3D)", size);
|
||||
|
||||
// 3D demo: the full garden scene rendered by the 3D viewport.
|
||||
let mut editor = HeadlessEditor::new(size, samples::garden_demo());
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
let stats = editor.frames(8);
|
||||
assert_rendered(&editor.app, &stats, "garden demo (3D)", size);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Panels, palette and dialogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn panel_toggles_palette_and_dialogs_render() {
|
||||
isolate_editor_config();
|
||||
|
||||
let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo());
|
||||
editor.frames(4);
|
||||
|
||||
// Assets drawer.
|
||||
assert!(
|
||||
editor.app.ui.show_assets,
|
||||
"assets drawer is visible by default"
|
||||
);
|
||||
editor.dispatch(Action::ToggleAssets);
|
||||
assert!(
|
||||
!editor.app.ui.show_assets,
|
||||
"ToggleAssets must hide the asset drawer"
|
||||
);
|
||||
editor.expect_frame("assets drawer hidden");
|
||||
editor.dispatch(Action::ToggleAssets);
|
||||
assert!(editor.app.ui.show_assets);
|
||||
editor.expect_frame("assets drawer visible");
|
||||
|
||||
// Left hierarchy panel.
|
||||
assert!(editor.app.ui.show_left_panel);
|
||||
editor.dispatch(Action::ToggleLeftPanel);
|
||||
assert!(!editor.app.ui.show_left_panel);
|
||||
editor.expect_frame("hierarchy panel hidden");
|
||||
editor.dispatch(Action::ToggleLeftPanel);
|
||||
assert!(editor.app.ui.show_left_panel);
|
||||
editor.expect_frame("hierarchy panel visible");
|
||||
|
||||
// Right inspector panel.
|
||||
assert!(editor.app.ui.show_right_panel);
|
||||
editor.dispatch(Action::ToggleRightPanel);
|
||||
assert!(!editor.app.ui.show_right_panel);
|
||||
editor.expect_frame("inspector panel hidden");
|
||||
editor.dispatch(Action::ToggleRightPanel);
|
||||
assert!(editor.app.ui.show_right_panel);
|
||||
editor.expect_frame("inspector panel visible");
|
||||
|
||||
// Grid and snapping flags (both render in the viewport).
|
||||
let grid = editor.app.ui.show_grid;
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(
|
||||
editor.app.ui.show_grid, !grid,
|
||||
"ToggleGrid must flip the flag"
|
||||
);
|
||||
editor.expect_frame("grid off");
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, grid);
|
||||
editor.expect_frame("grid on");
|
||||
|
||||
let snap = editor.app.ui.snap_to_grid;
|
||||
editor.dispatch(Action::ToggleSnap);
|
||||
assert_eq!(
|
||||
editor.app.ui.snap_to_grid, !snap,
|
||||
"ToggleSnap must flip the flag"
|
||||
);
|
||||
editor.expect_frame("snap on");
|
||||
editor.dispatch(Action::ToggleSnap);
|
||||
assert_eq!(editor.app.ui.snap_to_grid, snap);
|
||||
|
||||
// Command palette: opened by action, closed by Escape.
|
||||
editor.dispatch(Action::OpenCommandPalette);
|
||||
assert!(editor.app.palette.open, "palette must open");
|
||||
editor.expect_frame("command palette open");
|
||||
let stats = editor.frame_with_events(vec![key_event(egui::Key::Escape)]);
|
||||
assert!(
|
||||
!editor.app.palette.open,
|
||||
"Escape must close the command palette"
|
||||
);
|
||||
assert!(stats.shapes > 0, "closing frame must still paint");
|
||||
editor.expect_frame("command palette closed");
|
||||
|
||||
// Save-as dialog.
|
||||
editor.dispatch(Action::OpenSaveAsDialog);
|
||||
assert!(editor.app.dialogs.save_as_open, "Save As must open");
|
||||
assert!(
|
||||
!editor.app.dialogs.save_as_path.is_empty(),
|
||||
"Save As pre-fills a path"
|
||||
);
|
||||
editor.expect_frame("save-as dialog open");
|
||||
editor.app.dialogs.save_as_open = false;
|
||||
editor.expect_frame("save-as dialog closed");
|
||||
|
||||
// Open-project dialog.
|
||||
editor.dispatch(Action::OpenProjectDialog);
|
||||
assert!(editor.app.dialogs.open_project_open);
|
||||
editor.expect_frame("open-project dialog open");
|
||||
editor.app.dialogs.open_project_open = false;
|
||||
editor.expect_frame("open-project dialog closed");
|
||||
|
||||
// Open-scene dialog.
|
||||
editor.dispatch(Action::OpenSceneDialog);
|
||||
assert!(editor.app.dialogs.open_scene_open);
|
||||
editor.expect_frame("open-scene dialog open");
|
||||
editor.app.dialogs.open_scene_open = false;
|
||||
editor.expect_frame("open-scene dialog closed");
|
||||
|
||||
// Export dialog (exercises code generation for the preview).
|
||||
editor.dispatch(Action::OpenExportDialog(bevy_forge_core::ExportFormat::Bsn));
|
||||
assert!(editor.app.export.dialog_open, "export dialog must open");
|
||||
assert!(
|
||||
editor.app.export.code.is_some() || editor.app.export.error.is_some(),
|
||||
"export dialog must have a preview or an error"
|
||||
);
|
||||
editor.expect_frame("export dialog open");
|
||||
editor.app.export.dialog_open = false;
|
||||
editor.expect_frame("export dialog closed");
|
||||
|
||||
// Shortcuts + about dialogs.
|
||||
editor.dispatch(Action::ShowShortcuts);
|
||||
assert!(editor.app.dialogs.shortcuts_open);
|
||||
editor.expect_frame("shortcuts dialog open");
|
||||
editor.app.dialogs.shortcuts_open = false;
|
||||
editor.expect_frame("shortcuts dialog closed");
|
||||
|
||||
editor.dispatch(Action::ShowAbout);
|
||||
assert!(editor.app.dialogs.about_open);
|
||||
editor.expect_frame("about dialog open");
|
||||
editor.app.dialogs.about_open = false;
|
||||
let stats = editor.expect_frame("all dialogs closed");
|
||||
|
||||
// Everything restored: the viewport is still laid out after all toggles.
|
||||
assert!(
|
||||
editor.app.ui.viewport_rect.is_positive(),
|
||||
"viewport rect must be laid out after all toggles: {:?}",
|
||||
editor.app.ui.viewport_rect
|
||||
);
|
||||
assert!(stats.shapes > 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn actions_undo_redo_duplicate_delete_and_flags() {
|
||||
isolate_editor_config();
|
||||
|
||||
let mut editor = HeadlessEditor::new(egui::vec2(1280.0, 800.0), samples::platformer_demo());
|
||||
editor.frames(2);
|
||||
|
||||
let base_entities = editor.app.session.doc().entities.len();
|
||||
assert!(base_entities > 0, "demo scene must have entities");
|
||||
|
||||
// -- Undo / redo after an edit -----------------------------------------
|
||||
let player = editor
|
||||
.app
|
||||
.session
|
||||
.doc()
|
||||
.find_by_name("Player")
|
||||
.expect("platformer demo contains a Player entity");
|
||||
editor.app.ui.selection.select_only(player);
|
||||
editor.app.session.edit("Rename Player", |doc| {
|
||||
doc.rename(player, "Hero");
|
||||
});
|
||||
assert!(editor.app.session.can_undo());
|
||||
assert!(editor.app.session.is_dirty());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Rename Player"));
|
||||
|
||||
editor.dispatch(Action::Undo);
|
||||
assert!(
|
||||
editor.app.session.doc().find_by_name("Player").is_some(),
|
||||
"Undo must restore the original name"
|
||||
);
|
||||
assert!(
|
||||
editor.app.ui.selection.contains(player),
|
||||
"selection survives undo when the entity still exists"
|
||||
);
|
||||
assert!(editor.app.session.can_redo());
|
||||
assert_eq!(editor.app.session.redo_label(), Some("Rename Player"));
|
||||
editor.expect_frame("after undo");
|
||||
|
||||
editor.dispatch(Action::Redo);
|
||||
assert!(
|
||||
editor.app.session.doc().find_by_name("Hero").is_some(),
|
||||
"Redo must reapply the renamed entity"
|
||||
);
|
||||
editor.expect_frame("after redo");
|
||||
|
||||
// -- View mode flags ----------------------------------------------------
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::ThreeD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::ThreeD);
|
||||
editor.expect_frame("3D view mode");
|
||||
assert!(
|
||||
editor.app.ui.viewport_rect.is_positive(),
|
||||
"3D viewport rect must be laid out: {:?}",
|
||||
editor.app.ui.viewport_rect
|
||||
);
|
||||
editor.dispatch(Action::SetViewMode(ViewMode::TwoD));
|
||||
assert_eq!(editor.app.ui.view_mode, ViewMode::TwoD);
|
||||
editor.expect_frame("2D view mode");
|
||||
|
||||
// -- Grid toggle --------------------------------------------------------
|
||||
let grid = editor.app.ui.show_grid;
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, !grid);
|
||||
editor.dispatch(Action::ToggleGrid);
|
||||
assert_eq!(editor.app.ui.show_grid, grid);
|
||||
|
||||
// -- Duplicate (deep copy of Player + child Hat) ------------------------
|
||||
assert_eq!(editor.app.ui.selection.len(), 1);
|
||||
editor.dispatch(Action::DuplicateSelection);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities + 2,
|
||||
"duplicating Player must also duplicate its child Hat"
|
||||
);
|
||||
let after_duplicate = editor.app.session.doc().entities.len();
|
||||
let selection_after_duplicate = editor.app.ui.selection.len();
|
||||
assert_eq!(
|
||||
editor.app.ui.selection.len(),
|
||||
1,
|
||||
"the duplicated subtree root becomes the selection"
|
||||
);
|
||||
let duplicate = editor
|
||||
.app
|
||||
.ui
|
||||
.selection
|
||||
.primary()
|
||||
.expect("duplicate becomes primary");
|
||||
assert_ne!(duplicate, player);
|
||||
assert!(editor.app.session.doc().entity(duplicate).is_some());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Duplicate entities"));
|
||||
editor.expect_frame("after duplicate");
|
||||
|
||||
// -- Delete selection ---------------------------------------------------
|
||||
editor.dispatch(Action::DeleteSelection);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities,
|
||||
"deleting the duplicate removes the copied subtree"
|
||||
);
|
||||
assert!(editor.app.ui.selection.is_empty());
|
||||
assert_eq!(editor.app.session.undo_label(), Some("Delete entities"));
|
||||
editor.expect_frame("after delete");
|
||||
let after_delete = editor.app.session.doc().entities.len();
|
||||
|
||||
editor.dispatch(Action::Undo);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities + 2,
|
||||
"undo restores the deleted duplicate subtree"
|
||||
);
|
||||
let after_undo_delete = editor.app.session.doc().entities.len();
|
||||
editor.dispatch(Action::Redo);
|
||||
assert_eq!(
|
||||
editor.app.session.doc().entities.len(),
|
||||
base_entities,
|
||||
"redo deletes it again"
|
||||
);
|
||||
eprintln!(
|
||||
"actions: base_entities={base_entities}, after_duplicate={after_duplicate} \
|
||||
selection_after_duplicate={selection_after_duplicate}, after_delete={after_delete}, \
|
||||
after_undo_delete={after_undo_delete}, after_redo_delete={}",
|
||||
editor.app.session.doc().entities.len()
|
||||
);
|
||||
|
||||
// -- Palette command path ----------------------------------------------
|
||||
editor.dispatch(Action::OpenCommandPalette);
|
||||
assert!(editor.app.palette.open);
|
||||
editor.expect_frame("palette via action");
|
||||
editor.frame_with_events(vec![key_event(egui::Key::Escape)]);
|
||||
assert!(!editor.app.palette.open);
|
||||
|
||||
// -- Save without a project must ask for a path -------------------------
|
||||
editor.dispatch(Action::Save);
|
||||
assert!(
|
||||
editor.app.dialogs.save_as_open,
|
||||
"Save without a project must open the Save As dialog"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a minimal Bevy project on disk and return its root.
|
||||
fn write_temp_project(dir: &Path) -> PathBuf {
|
||||
std::fs::write(
|
||||
dir.join("Cargo.toml"),
|
||||
"[package]\n\
|
||||
name = \"forge-test\"\n\
|
||||
version = \"0.1.0\"\n\
|
||||
edition = \"2021\"\n\
|
||||
\n\
|
||||
[dependencies]\n\
|
||||
bevy = \"0.20.0\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::fs::create_dir_all(dir.join("assets")).expect("create assets dir");
|
||||
std::fs::create_dir_all(dir.join("scenes")).expect("create scenes dir");
|
||||
dir.to_path_buf()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistence_save_load_round_trip_and_corrupt_file() {
|
||||
isolate_editor_config();
|
||||
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = write_temp_project(temp.path());
|
||||
|
||||
// First session: open the project, author a scene, save it.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = ForgeApp::new_for_test(&ctx, samples::empty_scene());
|
||||
assert!(
|
||||
app.open_project(&root),
|
||||
"open_project must accept a Bevy project"
|
||||
);
|
||||
assert_eq!(app.screen, Screen::Editor);
|
||||
let project = app.project.clone().expect("project is open");
|
||||
assert_eq!(project.name, "forge-test");
|
||||
assert_eq!(app.assets.len(), 0, "project assets dir is empty");
|
||||
|
||||
app.session.edit("Build round-trip scene", |doc| {
|
||||
doc.name = "Round Trip".into();
|
||||
let level = doc.add_entity("Level", None);
|
||||
let level_node = doc.entity_mut(level).expect("level exists");
|
||||
level_node.transform = Transform::from_xyz(1.5, -2.25, 3.75);
|
||||
|
||||
let crate_id = doc.add_entity("Crate", Some(level));
|
||||
let crate_node = doc.entity_mut(crate_id).expect("crate exists");
|
||||
crate_node.transform = Transform {
|
||||
translation: Vec3::new(0.5, 1.0, -0.5),
|
||||
rotation: Vec3::new(0.0, 45.0, 0.0),
|
||||
scale: Vec3::new(2.0, 2.0, 2.0),
|
||||
};
|
||||
doc.add_component(crate_id, ComponentData::Sprite(SpriteData::default()));
|
||||
let camera = doc.add_entity("Camera", None);
|
||||
doc.add_component(camera, ComponentData::Camera2d);
|
||||
});
|
||||
assert!(app.session.is_dirty());
|
||||
|
||||
let scene_path = project.scenes_dir().join("test.forge.ron");
|
||||
app.save_scene_to(&scene_path);
|
||||
assert!(
|
||||
scene_path.is_file(),
|
||||
"save_scene_to must write {}",
|
||||
scene_path.display()
|
||||
);
|
||||
assert!(!app.session.is_dirty(), "saving must clear the dirty flag");
|
||||
assert_eq!(
|
||||
app.current_scene_path.as_deref(),
|
||||
Some(scene_path.as_path()),
|
||||
"the saved path becomes the current scene"
|
||||
);
|
||||
assert_eq!(
|
||||
app.session.doc().check_integrity(),
|
||||
Vec::<String>::new(),
|
||||
"saved scene must be structurally valid"
|
||||
);
|
||||
eprintln!(
|
||||
"persistence: saved {} ({} entities, dirty={})",
|
||||
scene_path.display(),
|
||||
app.session.doc().entities.len(),
|
||||
app.session.is_dirty()
|
||||
);
|
||||
|
||||
let saved_text = std::fs::read_to_string(&scene_path).expect("read saved scene");
|
||||
assert!(
|
||||
saved_text.contains("Round Trip"),
|
||||
"scene file must contain the scene name"
|
||||
);
|
||||
let loaded = project.load_scene(&scene_path).expect("reload scene");
|
||||
assert_eq!(loaded.name, "Round Trip");
|
||||
assert_eq!(loaded.entities.len(), 3);
|
||||
|
||||
// Second session: reopen the project and the scene, compare the document.
|
||||
let ctx2 = egui::Context::default();
|
||||
let mut app2 = ForgeApp::new_for_test(&ctx2, samples::empty_scene());
|
||||
assert!(app2.open_project(&root), "reopen project");
|
||||
assert!(
|
||||
app2.current_scene_path.is_some(),
|
||||
"opening a project with a scene must auto-open it"
|
||||
);
|
||||
assert!(app2.open_scene(&scene_path), "explicitly reopen the scene");
|
||||
assert_eq!(app2.screen, Screen::Editor);
|
||||
assert!(!app2.session.is_dirty());
|
||||
|
||||
let doc2 = app2.session.doc();
|
||||
assert_eq!(doc2.name, "Round Trip");
|
||||
assert_eq!(doc2.entities.len(), 3);
|
||||
assert_eq!(doc2.check_integrity(), Vec::<String>::new());
|
||||
|
||||
let level = doc2.find_by_name("Level").expect("Level survives reload");
|
||||
let level_node = doc2.entity(level).expect("level node");
|
||||
assert_eq!(
|
||||
level_node.transform.translation,
|
||||
Vec3::new(1.5, -2.25, 3.75)
|
||||
);
|
||||
|
||||
let crate_id = doc2.find_by_name("Crate").expect("Crate survives reload");
|
||||
let crate_node = doc2.entity(crate_id).expect("crate node");
|
||||
assert_eq!(crate_node.parent, Some(level));
|
||||
assert_eq!(crate_node.transform.translation, Vec3::new(0.5, 1.0, -0.5));
|
||||
assert_eq!(crate_node.transform.rotation, Vec3::new(0.0, 45.0, 0.0));
|
||||
assert_eq!(crate_node.transform.scale, Vec3::new(2.0, 2.0, 2.0));
|
||||
assert!(matches!(
|
||||
crate_node.component("sprite"),
|
||||
Some(ComponentData::Sprite(_))
|
||||
));
|
||||
|
||||
// Corrupt scene files must fail gracefully and leave the session untouched.
|
||||
let corrupt = root.join("scenes").join("corrupt.forge.ron");
|
||||
std::fs::write(&corrupt, "this is not a SceneDoc {{{").expect("write corrupt scene");
|
||||
app2.toasts.clear();
|
||||
let before_name = app2.session.doc().name.clone();
|
||||
let before_entities = app2.session.doc().entities.len();
|
||||
assert!(
|
||||
!app2.open_scene(&corrupt),
|
||||
"opening a corrupt scene must report failure"
|
||||
);
|
||||
assert_eq!(
|
||||
app2.session.doc().name,
|
||||
before_name,
|
||||
"session must be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
app2.session.doc().entities.len(),
|
||||
before_entities,
|
||||
"session entity count must be unchanged"
|
||||
);
|
||||
assert_eq!(app2.screen, Screen::Editor, "the editor stays open");
|
||||
assert!(
|
||||
!app2.toasts.is_empty(),
|
||||
"a load failure must surface an error toast"
|
||||
);
|
||||
|
||||
// Missing project / scene paths do not panic either.
|
||||
let mut bare = ForgeApp::new_for_test(&ctx2, samples::empty_scene());
|
||||
assert!(!bare.open_scene(&root.join("scenes/nope.forge.ron")));
|
||||
assert!(!bare.toasts.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Close guard / quit flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn close_guard_requests_confirmation_and_quit_flags() {
|
||||
isolate_editor_config();
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
// A clean editor quits immediately.
|
||||
let mut clean = ForgeApp::new_for_test(&ctx, samples::starter_2d());
|
||||
let mut frame = 0u64;
|
||||
run_one(&ctx, &mut clean, &mut frame);
|
||||
assert!(!clean.session.is_dirty());
|
||||
clean.dispatch_action(&ctx, Action::RequestQuit);
|
||||
assert!(clean.quit_now, "a clean editor quits without prompting");
|
||||
assert!(
|
||||
!clean.dialogs.confirm_quit_open,
|
||||
"no confirmation for a clean document"
|
||||
);
|
||||
|
||||
// A dirty document asks for confirmation instead of quitting.
|
||||
let mut dirty = ForgeApp::new_for_test(&ctx, samples::starter_2d());
|
||||
dirty.session.edit("Add entity", |doc| {
|
||||
doc.add_entity("Extra", None);
|
||||
});
|
||||
assert!(dirty.session.is_dirty());
|
||||
dirty.dispatch_action(&ctx, Action::RequestQuit);
|
||||
assert!(
|
||||
dirty.dialogs.confirm_quit_open,
|
||||
"RequestQuit on a dirty document must open the confirmation dialog"
|
||||
);
|
||||
assert!(!dirty.quit_now, "the app must not quit before confirmation");
|
||||
assert!(!dirty.allow_close, "the close guard must stay armed");
|
||||
|
||||
// The confirmation dialog renders without closing the context.
|
||||
let mut frame = 2u64;
|
||||
let stats = run_one(&ctx, &mut dirty, &mut frame);
|
||||
assert!(stats.shapes > 0, "the unsaved-changes dialog must paint");
|
||||
|
||||
// Confirming via QuitNow sets both flags; the test ends without a window.
|
||||
dirty.dispatch_action(&ctx, Action::QuitNow);
|
||||
assert!(dirty.allow_close, "QuitNow must allow the close");
|
||||
assert!(dirty.quit_now, "QuitNow must request the quit");
|
||||
let stats = run_one(&ctx, &mut dirty, &mut frame);
|
||||
assert!(
|
||||
stats.shapes > 0,
|
||||
"the app keeps painting until eframe closes"
|
||||
);
|
||||
}
|
||||
|
||||
/// One deterministic headless frame for a bare `ForgeApp` (close-guard test).
|
||||
fn run_one(ctx: &egui::Context, app: &mut ForgeApp, frame: &mut u64) -> FrameStats {
|
||||
let mut raw = egui::RawInput::default();
|
||||
raw.screen_rect = Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(1200.0, 760.0),
|
||||
));
|
||||
raw.viewports
|
||||
.entry(egui::ViewportId::ROOT)
|
||||
.or_default()
|
||||
.native_pixels_per_point = Some(1.0);
|
||||
*frame += 1;
|
||||
raw.time = Some(*frame as f64 / 60.0);
|
||||
raw.predicted_dt = 1.0 / 60.0;
|
||||
let output = ctx.run(raw, |ctx| app.ui(ctx));
|
||||
let shapes = output.shapes.len();
|
||||
let clipped = ctx.tessellate(output.shapes, output.pixels_per_point);
|
||||
let mut meshes = 0usize;
|
||||
let mut vertices = 0usize;
|
||||
for primitive in &clipped {
|
||||
if let egui::epaint::Primitive::Mesh(mesh) = &primitive.primitive {
|
||||
meshes += 1;
|
||||
vertices += mesh.vertices.len();
|
||||
}
|
||||
}
|
||||
FrameStats {
|
||||
shapes,
|
||||
clipped_primitives: clipped.len(),
|
||||
meshes,
|
||||
vertices,
|
||||
used_rect: ctx.used_rect(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user