use super::*;
pub fn add_pass(
project_path: &Path,
name: &str,
kind: PassKind,
source: Option<&Path>,
) -> Result<Output> {
use crate::manifest::{Pass, validate_project_relative_path};
use crate::scaffold::write_manifest;
let mut loaded = LoadedManifest::load(project_path)?;
if loaded.manifest.passes.iter().any(|pass| pass.name == name)
|| loaded
.manifest
.assets
.iter()
.any(|asset| asset.name == name)
{
bail!("pass/asset name '{}' already exists", name);
}
if kind == PassKind::Image {
bail!(
"project already has its required final image pass; add a buffer or cubemap pass instead"
);
}
let source_rel = match source {
Some(path) => {
let value = path
.to_str()
.context("pass source must be valid UTF-8")?
.replace('\\', "/");
PathBuf::from(value)
}
None => default_pass_source(&loaded, name),
};
let source_text = source_rel
.to_str()
.context("pass source must be valid UTF-8")?
.replace('\\', "/");
validate_project_relative_path(&source_text, "pass source")?;
let source_abs = project_write_target(&loaded.root, &source_rel)?;
if source_abs.exists() && !source_abs.is_file() {
bail!("pass source is not a file: {}", source_abs.display());
}
loaded.manifest.passes.insert(
loaded.manifest.passes.len().saturating_sub(1),
Pass {
name: name.to_string(),
kind,
source: source_text,
inputs: Vec::new(),
},
);
loaded.manifest.validate_structure()?;
let created_source = !source_abs.exists();
if created_source {
if let Some(parent) = source_abs.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&source_abs, pass_stub(kind))?;
}
if let Err(error) = write_manifest(&loaded.root, &loaded.manifest) {
if created_source {
let _ = fs::remove_file(&source_abs);
}
return Err(error);
}
Ok(Output {
human: format!(
"Added {:?} pass '{}' -> {}",
kind,
name,
source_rel.display()
),
json: json!({
"ok": true,
"action": "pass-add",
"pass": name,
"kind": format!("{kind:?}").to_lowercase(),
"source": source_rel,
}),
})
}
pub fn remove_pass(project_path: &Path, name: &str, force: bool) -> Result<Output> {
use crate::scaffold::write_manifest;
let mut loaded = LoadedManifest::load(project_path)?;
let index = loaded
.manifest
.passes
.iter()
.position(|pass| pass.name == name)
.with_context(|| format!("unknown pass '{name}'"))?;
if loaded.manifest.passes[index].kind == PassKind::Image {
bail!("cannot remove the required final image pass");
}
let consumers = loaded
.manifest
.passes
.iter()
.filter(|pass| pass.inputs.iter().any(|input| input.source == name))
.map(|pass| pass.name.clone())
.collect::<Vec<_>>();
if !consumers.is_empty() && !force {
bail!(
"pass '{}' is used by {}; remove those channels first or use --force",
name,
consumers.join(", ")
);
}
if force {
for pass in &mut loaded.manifest.passes {
pass.inputs.retain(|input| input.source != name);
}
}
let removed = loaded.manifest.passes.remove(index);
write_manifest(&loaded.root, &loaded.manifest)?;
Ok(Output {
human: format!("Removed pass '{}'", name),
json: json!({
"ok": true,
"action": "pass-remove",
"pass": name,
"source_left_on_disk": removed.source,
"removed_consumer_channels": if force { consumers } else { Vec::<String>::new() },
}),
})
}
#[derive(Debug, Clone)]
pub struct ChannelSetOptions {
pub pass: String,
pub channel: u8,
pub source: String,
pub kind: Option<crate::manifest::InputKind>,
pub previous: bool,
pub filter: crate::manifest::Filter,
pub wrap: crate::manifest::Wrap,
}
pub fn set_channel(project_path: &Path, options: &ChannelSetOptions) -> Result<Output> {
let pass_name = options.pass.as_str();
let channel = options.channel;
let source = options.source.as_str();
let kind = options.kind;
let previous = options.previous;
let filter = options.filter;
let wrap = options.wrap;
use crate::manifest::{FrameRef, Input};
use crate::scaffold::write_manifest;
if channel > 3 {
bail!("channel must be between 0 and 3");
}
let mut loaded = LoadedManifest::load(project_path)?;
let pass = loaded
.manifest
.passes
.iter_mut()
.find(|pass| pass.name == pass_name)
.with_context(|| format!("unknown pass '{pass_name}'"))?;
let input = Input {
channel,
source: source.to_string(),
kind,
frame: if previous {
FrameRef::Previous
} else {
FrameRef::Current
},
filter,
wrap,
};
if let Some(existing) = pass
.inputs
.iter_mut()
.find(|input| input.channel == channel)
{
*existing = input;
} else {
pass.inputs.push(input);
pass.inputs.sort_by_key(|input| input.channel);
}
loaded.manifest.validate_structure()?;
write_manifest(&loaded.root, &loaded.manifest)?;
Ok(Output {
human: format!("Set {} iChannel{} <- {}", pass_name, channel, source),
json: json!({
"ok": true,
"action": "channel-set",
"pass": pass_name,
"channel": channel,
"source": source,
"previous": previous,
"filter": format!("{filter:?}").to_lowercase(),
"wrap": format!("{wrap:?}").to_lowercase(),
}),
})
}
pub fn remove_channel(project_path: &Path, pass_name: &str, channel: u8) -> Result<Output> {
use crate::scaffold::write_manifest;
if channel > 3 {
bail!("channel must be between 0 and 3");
}
let mut loaded = LoadedManifest::load(project_path)?;
let pass = loaded
.manifest
.passes
.iter_mut()
.find(|pass| pass.name == pass_name)
.with_context(|| format!("unknown pass '{pass_name}'"))?;
let before = pass.inputs.len();
pass.inputs.retain(|input| input.channel != channel);
if pass.inputs.len() == before {
bail!("{} has no iChannel{} binding", pass_name, channel);
}
write_manifest(&loaded.root, &loaded.manifest)?;
Ok(Output {
human: format!("Removed {} iChannel{}", pass_name, channel),
json: json!({
"ok": true,
"action": "channel-remove",
"pass": pass_name,
"channel": channel,
}),
})
}
fn default_pass_source(loaded: &LoadedManifest, name: &str) -> PathBuf {
let base = slug(name);
for suffix in 1usize.. {
let stem = if suffix == 1 {
base.clone()
} else {
format!("{base}-{suffix}")
};
let candidate = PathBuf::from(format!("shaders/{stem}.frag"));
let serialized = candidate.to_string_lossy();
if !loaded
.manifest
.passes
.iter()
.any(|pass| pass.source == serialized)
{
return candidate;
}
}
unreachable!("unbounded pass-source suffix search must find a free name")
}
fn project_write_target(root: &Path, relative: &Path) -> Result<PathBuf> {
let candidate = root.join(relative);
let canonical_root = fs::canonicalize(root)
.with_context(|| format!("failed to resolve project root {}", root.display()))?;
let candidate_metadata = fs::symlink_metadata(&candidate).ok();
if candidate_metadata
.as_ref()
.is_some_and(|metadata| metadata.file_type().is_symlink())
{
bail!(
"pass source must not be a symbolic link: {}",
candidate.display()
);
}
let mut probe = if candidate_metadata.is_some() {
candidate.as_path()
} else {
candidate.parent().unwrap_or(root)
};
while !probe.exists() {
probe = probe
.parent()
.context("pass source has no existing project ancestor")?;
}
let canonical_probe = fs::canonicalize(probe)
.with_context(|| format!("failed to resolve pass source ancestor {}", probe.display()))?;
if !canonical_probe.starts_with(&canonical_root) {
bail!(
"pass source resolves outside the project root: {}",
candidate.display()
);
}
Ok(candidate)
}
fn slug(value: &str) -> String {
let mut result = String::new();
for character in value.chars() {
if character.is_ascii_alphanumeric() {
result.push(character.to_ascii_lowercase());
} else if !result.ends_with('-') {
result.push('-');
}
}
let result = result.trim_matches('-');
if result.is_empty() {
"pass".to_string()
} else {
result.to_string()
}
}
fn pass_stub(kind: PassKind) -> &'static str {
match kind {
PassKind::Buffer | PassKind::Image => crate::include_file!("templates/pass/buffer.frag"),
PassKind::Cubemap => crate::include_file!("templates/pass/cubemap.frag"),
}
}