Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6cb9947b2 | |||
| e88a7e2bb1 |
@@ -164,10 +164,34 @@ breaking references, since the uid travels with the file in the manifest.
|
|||||||
| `Model` | `models/` | `gltf`, `glb`, `obj` |
|
| `Model` | `models/` | `gltf`, `glb`, `obj` |
|
||||||
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
|
| `Audio` | `audio/` | `wav`, `ogg`, `mp3`, `flac` |
|
||||||
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
|
| `Ui` | `ui/` | (recognised by folder — shares `.ron` with scenes) |
|
||||||
|
| `Script` | `scripts/` | `rhai` |
|
||||||
|
|
||||||
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
|
`AssetKind::classify(path)` infers the kind: the leading folder wins, with the
|
||||||
file extension as a fallback for files dropped directly in `assets/`.
|
file extension as a fallback for files dropped directly in `assets/`.
|
||||||
|
|
||||||
|
### File operations — rename, move, delete
|
||||||
|
|
||||||
|
The database also *performs* file reorganisation, so the editor's file explorer
|
||||||
|
(and any tool) can rename or move assets **without breaking saved references**
|
||||||
|
— the disk operation and the uid map are updated together:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
# use oxide_engine::asset::{AssetDatabase, AssetUid};
|
||||||
|
# fn demo(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), oxide_engine::asset::AssetDbError> {
|
||||||
|
db.move_asset(uid, "textures/env/brick.png")?; // rename/move; uid unchanged
|
||||||
|
db.move_folder("textures/env", "textures/world")?; // every entry under it follows
|
||||||
|
db.delete_asset(uid)?; // file + entry; uid never reused
|
||||||
|
db.save()?; // persist the new paths
|
||||||
|
# Ok(())
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
All three refuse to touch anything outside `assets/` (`..` is rejected) and
|
||||||
|
refuse to overwrite an existing destination ([`AssetDbError`] has a variant per
|
||||||
|
failure). Kinds are re-classified from the new path, since a move can change
|
||||||
|
the typed folder. Deleting drops the entry but never reuses its uid — a
|
||||||
|
dangling reference stays dangling instead of silently pointing at a new file.
|
||||||
|
|
||||||
### Asset-reference fields in the inspector
|
### Asset-reference fields in the inspector
|
||||||
|
|
||||||
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
|
A component points at an asset with an [`AssetRef<T>`] field — **not** a live
|
||||||
@@ -209,6 +233,7 @@ projects are seeded with a bundled default UI font (Inter, SIL OFL) under
|
|||||||
`fonts/`, referenced by its project-relative path like any other asset.
|
`fonts/`, referenced by its project-relative path like any other asset.
|
||||||
|
|
||||||
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
[`AssetDatabase`]: ../engine/src/asset/database.rs
|
||||||
|
[`AssetDbError`]: ../engine/src/asset/database.rs
|
||||||
[`AssetKind`]: ../engine/src/asset/database.rs
|
[`AssetKind`]: ../engine/src/asset/database.rs
|
||||||
[`AssetUid`]: ../engine/src/asset/database.rs
|
[`AssetUid`]: ../engine/src/asset/database.rs
|
||||||
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
[`AssetRef<T>`]: ../engine/src/asset/database.rs
|
||||||
|
|||||||
+123
-2
@@ -1,5 +1,7 @@
|
|||||||
//! Bundled editor assets — locating the shared `assets/` tree and seeding a
|
//! Bundled editor assets — locating the shared `assets/` tree, seeding a new
|
||||||
//! new project's default content (currently the default UI font).
|
//! project's default content (currently the default UI font), and creating new
|
||||||
|
//! script files from the built-in template (the inspector's "New Script"
|
||||||
|
//! button).
|
||||||
//!
|
//!
|
||||||
//! The editor ships a small set of shared assets (icons, the default UI font, …)
|
//! The editor ships a small set of shared assets (icons, the default UI font, …)
|
||||||
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
|
//! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have
|
||||||
@@ -91,6 +93,79 @@ pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `.rhai` source the "New Script" button writes, personalised with the
|
||||||
|
/// script's file stem so the Console output identifies which script speaks.
|
||||||
|
///
|
||||||
|
/// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the
|
||||||
|
/// `update` body ships commented out so a freshly created script visibly runs
|
||||||
|
/// (the `init` print) without moving anything until the author opts in.
|
||||||
|
pub fn script_template(stem: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"// {stem}.rhai — attached via a Script component.
|
||||||
|
//
|
||||||
|
// Top-level statements run once when the script (re)starts. `init()` runs
|
||||||
|
// once after them; `update(dt)` runs every frame (dt = seconds).
|
||||||
|
|
||||||
|
fn init() {{
|
||||||
|
print("{stem}: init");
|
||||||
|
}}
|
||||||
|
|
||||||
|
fn update(dt) {{
|
||||||
|
// e.g. rotate_y(dt * 1.5);
|
||||||
|
}}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics,
|
||||||
|
/// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`,
|
||||||
|
/// collapses runs, trims the ends, and drops a trailing `.rhai` the user may
|
||||||
|
/// have typed. An unusable input yields `"new_script"`.
|
||||||
|
pub fn sanitize_script_stem(name: &str) -> String {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed);
|
||||||
|
let mut stem = String::with_capacity(trimmed.len());
|
||||||
|
for c in trimmed.chars() {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
stem.push(c);
|
||||||
|
} else if !stem.ends_with('_') {
|
||||||
|
stem.push('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stem = stem.trim_matches('_');
|
||||||
|
if stem.is_empty() {
|
||||||
|
"new_script".to_owned()
|
||||||
|
} else {
|
||||||
|
stem.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new script file under `<assets_dir>/scripts/` from the template,
|
||||||
|
/// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for
|
||||||
|
/// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase).
|
||||||
|
///
|
||||||
|
/// The desired name is [sanitized](sanitize_script_stem); a taken name gets a
|
||||||
|
/// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting,
|
||||||
|
/// so the button always succeeds on a writable project.
|
||||||
|
pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result<String> {
|
||||||
|
use oxide_engine::asset::AssetKind;
|
||||||
|
|
||||||
|
let stem = sanitize_script_stem(desired_name);
|
||||||
|
let dir = assets_dir.join(AssetKind::Script.folder());
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
let mut candidate = stem.clone();
|
||||||
|
let mut n = 1;
|
||||||
|
while dir.join(format!("{candidate}.rhai")).exists() {
|
||||||
|
n += 1;
|
||||||
|
candidate = format!("{stem}_{n}");
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(format!("{candidate}.rhai")),
|
||||||
|
script_template(&candidate),
|
||||||
|
)?;
|
||||||
|
Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -124,4 +199,50 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(tmp).ok();
|
std::fs::remove_dir_all(tmp).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_covers_typed_names() {
|
||||||
|
assert_eq!(sanitize_script_stem("spin"), "spin");
|
||||||
|
assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script");
|
||||||
|
assert_eq!(sanitize_script_stem("door.rhai"), "door");
|
||||||
|
assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c");
|
||||||
|
assert_eq!(sanitize_script_stem("čárka"), "rka");
|
||||||
|
// Unusable inputs fall back rather than producing "" or "_".
|
||||||
|
assert_eq!(sanitize_script_stem(""), "new_script");
|
||||||
|
assert_eq!(sanitize_script_stem("!!!"), "new_script");
|
||||||
|
assert_eq!(sanitize_script_stem(".rhai"), "new_script");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_script_writes_template_and_dodges_collisions() {
|
||||||
|
let mut tmp = std::env::temp_dir();
|
||||||
|
tmp.push(format!("oxide_newscript_{}", std::process::id()));
|
||||||
|
let assets = tmp.join("assets");
|
||||||
|
std::fs::create_dir_all(&assets).unwrap();
|
||||||
|
|
||||||
|
let rel = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel, "scripts/door_opener.rhai");
|
||||||
|
let text = std::fs::read_to_string(assets.join(&rel)).unwrap();
|
||||||
|
assert!(text.contains("fn update(dt)"));
|
||||||
|
|
||||||
|
// Same name again: suffixed, nothing overwritten.
|
||||||
|
let rel2 = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel2, "scripts/door_opener_2.rhai");
|
||||||
|
let rel3 = create_script_file(&assets, "door opener").unwrap();
|
||||||
|
assert_eq!(rel3, "scripts/door_opener_3.rhai");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_compiles_in_the_script_engine() {
|
||||||
|
// The template must never ship a syntax error: compile it exactly as
|
||||||
|
// the runtime would.
|
||||||
|
let asset = oxide_script::ScriptAsset::from_source(
|
||||||
|
"new_script.rhai",
|
||||||
|
script_template("new_script"),
|
||||||
|
);
|
||||||
|
let engine = oxide_script::ScriptEngine::new();
|
||||||
|
engine.compile(&asset).expect("template compiles");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -2251,7 +2251,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
stroke_rect(
|
stroke_rect(
|
||||||
&painter,
|
&painter,
|
||||||
tab_rect.shrink(1.5),
|
tab_rect.shrink(1.5),
|
||||||
egui::Stroke::new(3.0, color),
|
egui::Stroke::new(3.0_f32, color),
|
||||||
);
|
);
|
||||||
painter.text(
|
painter.text(
|
||||||
tab_rect.left_top() + egui::vec2(8.0, 6.0),
|
tab_rect.left_top() + egui::vec2(8.0, 6.0),
|
||||||
@@ -2312,7 +2312,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
egui::Color32::from_rgb(110, 220, 120) // green: solid
|
egui::Color32::from_rgb(110, 220, 120) // green: solid
|
||||||
};
|
};
|
||||||
let selected = self.state.selected == Some(entity);
|
let selected = self.state.selected == Some(entity);
|
||||||
let stroke = egui::Stroke::new(if selected { 2.0 } else { 1.2 }, color);
|
let stroke = egui::Stroke::new(if selected { 2.0_f32 } else { 1.2_f32 }, color);
|
||||||
for (a, b) in collider_wire_segments(&col) {
|
for (a, b) in collider_wire_segments(&col) {
|
||||||
let (Some(pa), Some(pb)) = (
|
let (Some(pa), Some(pb)) = (
|
||||||
project(pose.transform_point(a), &overlay.view_proj, tab_rect),
|
project(pose.transform_point(a), &overlay.view_proj, tab_rect),
|
||||||
@@ -2344,7 +2344,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
project(viz.origin, &overlay.view_proj, tab_rect),
|
project(viz.origin, &overlay.view_proj, tab_rect),
|
||||||
project(viz.end, &overlay.view_proj, tab_rect),
|
project(viz.end, &overlay.view_proj, tab_rect),
|
||||||
) {
|
) {
|
||||||
painter.line_segment([a, b], egui::Stroke::new(1.5, cyan));
|
painter.line_segment([a, b], egui::Stroke::new(1.5_f32, cyan));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The hit surface: a dot at the contact point + a normal whisker.
|
// The hit surface: a dot at the contact point + a normal whisker.
|
||||||
@@ -2359,7 +2359,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
project(hit.point, &overlay.view_proj, tab_rect),
|
project(hit.point, &overlay.view_proj, tab_rect),
|
||||||
project(tip, &overlay.view_proj, tab_rect),
|
project(tip, &overlay.view_proj, tab_rect),
|
||||||
) {
|
) {
|
||||||
painter.line_segment([a, b], egui::Stroke::new(2.0, magenta));
|
painter.line_segment([a, b], egui::Stroke::new(2.0_f32, magenta));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2470,7 +2470,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
) else {
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
painter.line_segment([pa, pb], egui::Stroke::new(2.5, color));
|
painter.line_segment([pa, pb], egui::Stroke::new(2.5_f32, color));
|
||||||
// Arrowhead at the tip — a small filled triangle perpendicular to
|
// Arrowhead at the tip — a small filled triangle perpendicular to
|
||||||
// the line direction in screen space.
|
// the line direction in screen space.
|
||||||
let dir = (pb - pa).normalized();
|
let dir = (pb - pa).normalized();
|
||||||
@@ -2521,7 +2521,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 })
|
egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 })
|
||||||
};
|
};
|
||||||
let stroke = egui::Stroke::new(1.5, color);
|
let stroke = egui::Stroke::new(1.5_f32, color);
|
||||||
painter.add(egui::Shape::convex_polygon(projected, color, stroke));
|
painter.add(egui::Shape::convex_polygon(projected, color, stroke));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2551,7 +2551,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let color = axis_color(axis, engaged);
|
let color = axis_color(axis, engaged);
|
||||||
painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0, color)));
|
painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0_f32, color)));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hierarchy(&mut self, ui: &mut egui::Ui) {
|
fn hierarchy(&mut self, ui: &mut egui::Ui) {
|
||||||
@@ -2705,7 +2705,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
DropZone::Child
|
DropZone::Child
|
||||||
};
|
};
|
||||||
let accent = ui.visuals().selection.bg_fill;
|
let accent = ui.visuals().selection.bg_fill;
|
||||||
let line = egui::Stroke::new(2.0, accent);
|
let line = egui::Stroke::new(2.0_f32, accent);
|
||||||
match zone {
|
match zone {
|
||||||
DropZone::Before => {
|
DropZone::Before => {
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
@@ -3333,7 +3333,7 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
if r.contains(pos) {
|
if r.contains(pos) {
|
||||||
let above = (pos.y - r.top()) < r.height() / 2.0;
|
let above = (pos.y - r.top()) < r.height() / 2.0;
|
||||||
let accent = ui.visuals().selection.bg_fill;
|
let accent = ui.visuals().selection.bg_fill;
|
||||||
let line = egui::Stroke::new(2.0, accent);
|
let line = egui::Stroke::new(2.0_f32, accent);
|
||||||
let y = if above { r.top() } else { r.bottom() };
|
let y = if above { r.top() } else { r.bottom() };
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[egui::pos2(r.left(), y), egui::pos2(r.right(), y)],
|
[egui::pos2(r.left(), y), egui::pos2(r.right(), y)],
|
||||||
@@ -5165,7 +5165,7 @@ fn draw_ui_preview(ui: &mut egui::Ui, panel: &UiPanel) {
|
|||||||
stroke_rect(
|
stroke_rect(
|
||||||
&painter,
|
&painter,
|
||||||
egui::Rect::from_min_size(origin, draw),
|
egui::Rect::from_min_size(origin, draw),
|
||||||
egui::Stroke::new(1.0, egui::Color32::DARK_GRAY),
|
egui::Stroke::new(1.0_f32, egui::Color32::DARK_GRAY),
|
||||||
);
|
);
|
||||||
|
|
||||||
let theme = UiTheme::new();
|
let theme = UiTheme::new();
|
||||||
|
|||||||
@@ -178,6 +178,30 @@ pub fn asset_ref_target(type_name: &str) -> Option<&str> {
|
|||||||
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
|
Some(inner.rsplit("::").next().unwrap_or(inner).trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Errors from [`AssetDatabase`] file operations ([`move_asset`], [`delete_asset`],
|
||||||
|
/// [`move_folder`]) — the ops that touch both the on-disk file *and* the uid map.
|
||||||
|
///
|
||||||
|
/// [`move_asset`]: AssetDatabase::move_asset
|
||||||
|
/// [`delete_asset`]: AssetDatabase::delete_asset
|
||||||
|
/// [`move_folder`]: AssetDatabase::move_folder
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AssetDbError {
|
||||||
|
/// The uid is not recorded in the database.
|
||||||
|
#[error("unknown asset uid {0:?}")]
|
||||||
|
UnknownUid(AssetUid),
|
||||||
|
/// The destination path is already taken, in the database or on disk.
|
||||||
|
/// Nothing was changed; pick another name.
|
||||||
|
#[error("destination already exists: {0}")]
|
||||||
|
DestinationExists(String),
|
||||||
|
/// The path is empty, escapes the assets directory (`..`), or would move a
|
||||||
|
/// folder into itself.
|
||||||
|
#[error("invalid assets-relative path: {0:?}")]
|
||||||
|
InvalidPath(String),
|
||||||
|
/// The underlying filesystem operation failed.
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
/// A stable, per-project identifier for one asset.
|
/// A stable, per-project identifier for one asset.
|
||||||
///
|
///
|
||||||
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
|
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
|
||||||
@@ -507,6 +531,126 @@ impl AssetDatabase {
|
|||||||
Some(server.load::<T>(path))
|
Some(server.load::<T>(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- file operations (disk + uid map together) --------------------------
|
||||||
|
//
|
||||||
|
// These keep the uid attached to its asset across renames/moves — the whole
|
||||||
|
// point of the uid layer — so the editor's file explorer can reorganise a
|
||||||
|
// project without breaking any saved `AssetRef`. Deletion drops the entry
|
||||||
|
// but never reuses its uid (the allocator only counts up).
|
||||||
|
|
||||||
|
/// Renames/moves the asset `uid` to `new_relative_path` (relative to
|
||||||
|
/// `assets/`), on disk and in the map, **keeping its uid** so saved
|
||||||
|
/// references still resolve. The kind is re-classified from the new path.
|
||||||
|
/// Parent directories are created as needed; moving onto an existing path
|
||||||
|
/// is refused. A no-op if the path is unchanged.
|
||||||
|
///
|
||||||
|
/// Call [`save`](Self::save) afterwards to persist the new path.
|
||||||
|
pub fn move_asset(
|
||||||
|
&mut self,
|
||||||
|
uid: AssetUid,
|
||||||
|
new_relative_path: impl AsRef<str>,
|
||||||
|
) -> Result<(), AssetDbError> {
|
||||||
|
let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?;
|
||||||
|
let new_path = checked_relative(new_relative_path.as_ref())?;
|
||||||
|
if new_path == entry.path {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.by_path.contains_key(&new_path) {
|
||||||
|
return Err(AssetDbError::DestinationExists(new_path));
|
||||||
|
}
|
||||||
|
let assets_dir = self.assets_dir();
|
||||||
|
let from_abs = assets_dir.join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
let to_abs = assets_dir.join(new_path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
if to_abs.exists() {
|
||||||
|
return Err(AssetDbError::DestinationExists(new_path));
|
||||||
|
}
|
||||||
|
if let Some(parent) = to_abs.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&from_abs, &to_abs)?;
|
||||||
|
|
||||||
|
let old_path = entry.path.clone();
|
||||||
|
self.by_path.remove(&old_path);
|
||||||
|
self.by_path.insert(new_path.clone(), uid);
|
||||||
|
let entry = self.by_uid.get_mut(&uid).expect("entry checked above");
|
||||||
|
entry.kind = AssetKind::classify(&new_path);
|
||||||
|
entry.path = new_path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the asset `uid`: removes its file from disk and drops its entry.
|
||||||
|
/// A file already missing from disk is fine (the entry is still dropped);
|
||||||
|
/// the uid is never reused. Call [`save`](Self::save) afterwards.
|
||||||
|
pub fn delete_asset(&mut self, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||||
|
let entry = self.by_uid.get(&uid).ok_or(AssetDbError::UnknownUid(uid))?;
|
||||||
|
let abs = self
|
||||||
|
.assets_dir()
|
||||||
|
.join(entry.path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
match std::fs::remove_file(&abs) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
}
|
||||||
|
let path = entry.path.clone();
|
||||||
|
self.by_uid.remove(&uid);
|
||||||
|
self.by_path.remove(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames/moves the folder `from` to `to` (both relative to `assets/`),
|
||||||
|
/// on disk and in the map: every entry under `from/` is rewritten to live
|
||||||
|
/// under `to/`, **keeping its uid** (kinds are re-classified — moving out
|
||||||
|
/// of a typed folder can change them). Refuses to overwrite an existing
|
||||||
|
/// destination or to move a folder into itself. Returns how many entries
|
||||||
|
/// moved. Call [`save`](Self::save) afterwards.
|
||||||
|
pub fn move_folder(
|
||||||
|
&mut self,
|
||||||
|
from: impl AsRef<str>,
|
||||||
|
to: impl AsRef<str>,
|
||||||
|
) -> Result<usize, AssetDbError> {
|
||||||
|
let from = checked_relative(from.as_ref())?;
|
||||||
|
let to = checked_relative(to.as_ref())?;
|
||||||
|
if from == to {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
if to.starts_with(&format!("{from}/")) {
|
||||||
|
return Err(AssetDbError::InvalidPath(to));
|
||||||
|
}
|
||||||
|
let assets_dir = self.assets_dir();
|
||||||
|
let from_abs = assets_dir.join(from.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
let to_abs = assets_dir.join(to.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||||
|
if !from_abs.is_dir() {
|
||||||
|
return Err(AssetDbError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("no such folder under assets/: {from}"),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if to_abs.exists() {
|
||||||
|
return Err(AssetDbError::DestinationExists(to));
|
||||||
|
}
|
||||||
|
if let Some(parent) = to_abs.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&from_abs, &to_abs)?;
|
||||||
|
|
||||||
|
let prefix = format!("{from}/");
|
||||||
|
let moved: Vec<AssetUid> = self
|
||||||
|
.by_uid
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.path.starts_with(&prefix))
|
||||||
|
.map(|e| e.uid)
|
||||||
|
.collect();
|
||||||
|
for &uid in &moved {
|
||||||
|
let entry = self.by_uid.get_mut(&uid).expect("collected above");
|
||||||
|
let new_path = format!("{to}/{}", &entry.path[prefix.len()..]);
|
||||||
|
let old_path = std::mem::replace(&mut entry.path, new_path.clone());
|
||||||
|
entry.kind = AssetKind::classify(&new_path);
|
||||||
|
self.by_path.remove(&old_path);
|
||||||
|
self.by_path.insert(new_path, uid);
|
||||||
|
}
|
||||||
|
Ok(moved.len())
|
||||||
|
}
|
||||||
|
|
||||||
// --- internals ---------------------------------------------------------
|
// --- internals ---------------------------------------------------------
|
||||||
|
|
||||||
fn highest_uid(&self) -> u64 {
|
fn highest_uid(&self) -> u64 {
|
||||||
@@ -522,6 +666,20 @@ fn normalize_relative(path: &str) -> String {
|
|||||||
trimmed.trim_start_matches('/').to_string()
|
trimmed.trim_start_matches('/').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalizes like [`normalize_relative`] and rejects paths that are empty or
|
||||||
|
/// escape the assets directory (`..` components) — file operations must never
|
||||||
|
/// touch anything outside `assets/`.
|
||||||
|
fn checked_relative(path: &str) -> Result<String, AssetDbError> {
|
||||||
|
let norm = normalize_relative(path);
|
||||||
|
let escapes = Path::new(&norm)
|
||||||
|
.components()
|
||||||
|
.any(|c| !matches!(c, std::path::Component::Normal(_)));
|
||||||
|
if norm.is_empty() || escapes {
|
||||||
|
return Err(AssetDbError::InvalidPath(path.to_string()));
|
||||||
|
}
|
||||||
|
Ok(norm)
|
||||||
|
}
|
||||||
|
|
||||||
/// Recursively collects files under `dir`, pushing each one's path relative to
|
/// Recursively collects files under `dir`, pushing each one's path relative to
|
||||||
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
|
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
|
||||||
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
|
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
|
||||||
@@ -738,6 +896,128 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(root).ok();
|
std::fs::remove_dir_all(root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_asset_keeps_uid_and_reclassifies() {
|
||||||
|
let root = temp_root("move_asset");
|
||||||
|
touch_asset(&root, "textures/wall.png");
|
||||||
|
let mut db = AssetDatabase::new(&root);
|
||||||
|
db.scan();
|
||||||
|
let uid = db.uid_of("textures/wall.png").unwrap();
|
||||||
|
|
||||||
|
// Rename within the folder: same uid, file moved on disk.
|
||||||
|
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||||
|
assert_eq!(db.relative_path(uid), Some("textures/brick.png"));
|
||||||
|
assert!(db.uid_of("textures/wall.png").is_none());
|
||||||
|
assert!(!root.join(ASSETS_DIR).join("textures/wall.png").exists());
|
||||||
|
assert!(root.join(ASSETS_DIR).join("textures/brick.png").exists());
|
||||||
|
|
||||||
|
// Move into a subfolder that does not exist yet (created), then out of
|
||||||
|
// the typed folder entirely — kind follows the path.
|
||||||
|
db.move_asset(uid, "textures/env/brick.png").unwrap();
|
||||||
|
assert!(root
|
||||||
|
.join(ASSETS_DIR)
|
||||||
|
.join("textures/env/brick.png")
|
||||||
|
.exists());
|
||||||
|
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Texture);
|
||||||
|
db.move_asset(uid, "misc/brick.dat").unwrap();
|
||||||
|
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
||||||
|
|
||||||
|
// A rescan does not disturb the moved entry's uid... (misc/ is not a
|
||||||
|
// typed folder, so the entry survives only because scan never saw it —
|
||||||
|
// move back first to prove the typed-folder case.)
|
||||||
|
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||||
|
db.scan();
|
||||||
|
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
|
||||||
|
|
||||||
|
// No-op and error cases.
|
||||||
|
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
||||||
|
touch_asset(&root, "textures/taken.png");
|
||||||
|
db.scan();
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_asset(uid, "textures/taken.png"),
|
||||||
|
Err(AssetDbError::DestinationExists(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_asset(uid, "../outside.png"),
|
||||||
|
Err(AssetDbError::InvalidPath(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_asset(AssetUid(999), "textures/x.png"),
|
||||||
|
Err(AssetDbError::UnknownUid(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_asset_removes_file_and_entry_without_reusing_uid() {
|
||||||
|
let root = temp_root("delete_asset");
|
||||||
|
touch_asset(&root, "audio/click.wav");
|
||||||
|
let mut db = AssetDatabase::new(&root);
|
||||||
|
db.scan();
|
||||||
|
let uid = db.uid_of("audio/click.wav").unwrap();
|
||||||
|
|
||||||
|
db.delete_asset(uid).unwrap();
|
||||||
|
assert!(db.entry(uid).is_none());
|
||||||
|
assert!(!root.join(ASSETS_DIR).join("audio/click.wav").exists());
|
||||||
|
|
||||||
|
// The freed uid is not handed to the next registration.
|
||||||
|
let next = db.register("audio/other.wav");
|
||||||
|
assert_ne!(next, uid);
|
||||||
|
|
||||||
|
// Deleting an entry whose file is already gone still drops the entry.
|
||||||
|
std::fs::remove_file(root.join(ASSETS_DIR).join("audio/other.wav")).ok();
|
||||||
|
db.delete_asset(next).unwrap();
|
||||||
|
assert!(db.entry(next).is_none());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
db.delete_asset(uid),
|
||||||
|
Err(AssetDbError::UnknownUid(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn move_folder_rewrites_entries_and_guards_self_moves() {
|
||||||
|
let root = temp_root("move_folder");
|
||||||
|
touch_asset(&root, "textures/env/wall.png");
|
||||||
|
touch_asset(&root, "textures/env/floor.png");
|
||||||
|
touch_asset(&root, "textures/hero.png");
|
||||||
|
let mut db = AssetDatabase::new(&root);
|
||||||
|
db.scan();
|
||||||
|
let wall = db.uid_of("textures/env/wall.png").unwrap();
|
||||||
|
let hero = db.uid_of("textures/hero.png").unwrap();
|
||||||
|
|
||||||
|
let moved = db.move_folder("textures/env", "textures/world").unwrap();
|
||||||
|
assert_eq!(moved, 2);
|
||||||
|
assert_eq!(db.relative_path(wall), Some("textures/world/wall.png"));
|
||||||
|
assert!(db.uid_of("textures/world/floor.png").is_some());
|
||||||
|
assert_eq!(db.relative_path(hero), Some("textures/hero.png"));
|
||||||
|
assert!(root
|
||||||
|
.join(ASSETS_DIR)
|
||||||
|
.join("textures/world/wall.png")
|
||||||
|
.exists());
|
||||||
|
assert!(!root.join(ASSETS_DIR).join("textures/env").exists());
|
||||||
|
|
||||||
|
// Guards: into itself, onto an existing folder, missing source.
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_folder("textures", "textures/world/deeper"),
|
||||||
|
Err(AssetDbError::InvalidPath(_))
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(root.join(ASSETS_DIR).join("models")).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_folder("textures/world", "models"),
|
||||||
|
Err(AssetDbError::DestinationExists(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
db.move_folder("textures/nope", "textures/x"),
|
||||||
|
Err(AssetDbError::Io(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
struct TxtLoader;
|
struct TxtLoader;
|
||||||
impl crate::asset::AssetLoader for TxtLoader {
|
impl crate::asset::AssetLoader for TxtLoader {
|
||||||
type Asset = String;
|
type Asset = String;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ mod handle;
|
|||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
asset_ref_target, AssetDatabase, AssetEntry, AssetKind, AssetRef, AssetUid, ASSET_MANIFEST_FILE,
|
asset_ref_target, AssetDatabase, AssetDbError, AssetEntry, AssetKind, AssetRef, AssetUid,
|
||||||
|
ASSET_MANIFEST_FILE,
|
||||||
};
|
};
|
||||||
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
|
pub use gltf::{load_gltf, load_gltf_slice, GltfError, GltfLoader, GltfMesh, GltfModel};
|
||||||
pub use handle::{AssetId, Handle, LoadState};
|
pub use handle::{AssetId, Handle, LoadState};
|
||||||
|
|||||||
Reference in New Issue
Block a user