diff --git a/crates/processing_pyo3/README.md b/crates/processing_pyo3/README.md index a0d154a..dc852ff 100644 --- a/crates/processing_pyo3/README.md +++ b/crates/processing_pyo3/README.md @@ -23,6 +23,21 @@ $ python >>> processing_py.size(500, 500) ``` +### Shader editor support + +The `processing` and `lygia` WESL packages are built into the library. To have +[wgsl-analyzer](https://github.com/wgsl-analyzer/wgsl-analyzer) resolve +`import processing::...` and `import lygia::...` in your sketch's shaders: + +``` +$ python -m processing_py shaders init +``` + +This writes the packages into the virtual environment and creates (or updates) +`wesl.toml` in the current directory to point at them. Pass `--vendor`, or run +without an environment, to write them to `.processing_py/shaders` instead. +Rerun after upgrading processing-py. + ## Acknowledgements Thank you to [Faruk Hammoud](https://github.com/FarukHammoud) for generously handing over the `processing-py` name. Their original [processing_py](https://github.com/FarukHammoud/processing_py) project, a Python graphics library based on Processing, was published under this name from 2020 to 2021 (versions 0.1.0–0.3.7). diff --git a/crates/processing_pyo3/processing_py/__init__.py b/crates/processing_pyo3/processing_py/__init__.py index d126294..e7735ae 100644 --- a/crates/processing_pyo3/processing_py/__init__.py +++ b/crates/processing_pyo3/processing_py/__init__.py @@ -18,6 +18,7 @@ import importlib as _importlib math = _importlib.import_module(f"{__name__}.math") # noqa: E402 from .math import * # noqa: E402,F401,F403 +from ._shaders import shader_workspace # noqa: E402,F401 # global var handling. for wildcard import of our module, we copy into globals, otherwise # we dispatch to get attr and call the underlying getter method diff --git a/crates/processing_pyo3/processing_py/__main__.py b/crates/processing_pyo3/processing_py/__main__.py new file mode 100644 index 0000000..fff94ee --- /dev/null +++ b/crates/processing_pyo3/processing_py/__main__.py @@ -0,0 +1,34 @@ +import argparse + +from ._shaders import shader_packages, shader_workspace + + +def main(argv=None): + parser = argparse.ArgumentParser(prog="python -m processing_py") + commands = parser.add_subparsers(dest="command", required=True) + + shaders = commands.add_parser("shaders", help="built-in WESL packages") + shaders_commands = shaders.add_subparsers(dest="shaders_command", required=True) + + init = shaders_commands.add_parser( + "init", help="create or update wesl.toml so editors resolve processing:: and lygia::" + ) + init.add_argument("dir", nargs="?", default=".") + init.add_argument( + "--vendor", + action="store_true", + help="write the packages to DIR/.processing_py/shaders instead of the virtual environment", + ) + + shaders_commands.add_parser("path", help="write the packages and print where they are") + + args = parser.parse_args(argv) + if args.shaders_command == "init": + print(f"wrote {shader_workspace(args.dir, vendor=args.vendor)}") + else: + for name, path in shader_packages().items(): + print(f"{name}\t{path}") + + +if __name__ == "__main__": + main() diff --git a/crates/processing_pyo3/processing_py/_shaders.py b/crates/processing_pyo3/processing_py/_shaders.py new file mode 100644 index 0000000..afe9e6c --- /dev/null +++ b/crates/processing_pyo3/processing_py/_shaders.py @@ -0,0 +1,122 @@ +"""The built-in WESL packages as files, and wiring them into a project's `wesl.toml` +so editor tooling (wgsl-analyzer) resolves `import processing::...` and +`import lygia::...` the same way `Shader` does at runtime.""" + +import json +import shutil +import sys +from pathlib import Path + +from . import processing_py as _native + +_BEGIN = "# BEGIN processing_py" +_END = "# END processing_py" +_VENDOR_DIR = Path(".processing_py") / "shaders" + + +def _default_dir(base): + # a virtual environment is per project and replaced along with the install + if sys.prefix != sys.base_prefix: + return Path(sys.prefix) / "share" / "processing_py" / "shaders" + return base / _VENDOR_DIR + + +def shader_packages(dest=None): + """Write the built-in WESL packages under `dest` and return `{name: directory}`. + + `dest` defaults to the virtual environment, or `.processing_py/shaders` in the + current directory without one. + """ + dest = Path(dest).resolve() if dest is not None else _default_dir(Path.cwd()) + written = {} + for name, files in _native._shader_packages().items(): + target = dest / name + if target.exists(): + shutil.rmtree(target) + for rel, contents in files.items(): + path = target / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents, encoding="utf-8") + written[name] = target + return written + + +def _toml_path(path, base): + try: + p = path.relative_to(base) + except ValueError: + p = path + return json.dumps(p.as_posix()) + + +def _dependency_block(packages, base): + lines = [_BEGIN] + for name, path in packages.items(): + lines.append(f"{name} = {{ path = {_toml_path(path, base)} }}") + lines.append(_END) + return lines + + +def _splice(text, block): + lines = text.splitlines() + if _BEGIN in lines: + start = lines.index(_BEGIN) + try: + end = lines.index(_END, start) + except ValueError: + raise ValueError(f"`{_BEGIN}` without a matching `{_END}`") from None + lines[start : end + 1] = block + else: + header = next( + (i for i, l in enumerate(lines) if l.split("#", 1)[0].strip() == "[dependencies]"), + None, + ) + if header is None: + if lines and lines[-1].strip(): + lines.append("") + lines += ["[dependencies]", *block] + else: + lines[header + 1 : header + 1] = block + return "\n".join(lines) + "\n" + + +def _validate(text, path): + try: + import tomllib + except ImportError: + return + try: + doc = tomllib.loads(text) + except tomllib.TOMLDecodeError as e: + raise ValueError(f"updating {path} would produce invalid TOML ({e}); edit it by hand") from None + if not isinstance(doc.get("dependencies"), dict): + raise ValueError(f"{path}: `dependencies` is not a table") + + +def shader_workspace(path=".", vendor=False): + """Create or update `wesl.toml` in `path` so the built-in `processing` and + `lygia` packages resolve in the editor. + + The packages are written into the virtual environment. With `vendor=True`, + or without an environment, they go to `.processing_py/shaders` in `path`, + for editors that can't see the environment or for checking them in. Rerun + after upgrading processing-py. Returns the path of the `wesl.toml`. + """ + base = Path(path).resolve() + base.mkdir(parents=True, exist_ok=True) + dest = base / _VENDOR_DIR if vendor else _default_dir(base) + packages = shader_packages(dest) + + manifest = base / "wesl.toml" + block = _dependency_block(packages, base) + if manifest.exists(): + text = _splice(manifest.read_text(encoding="utf-8"), block) + else: + header = ['edition = "2026_pre"', 'root = "."'] + # keep the project's own module scan out of the packages + if dest.is_relative_to(base): + header.append(f'exclude = ["{dest.relative_to(base).as_posix()}/**"]') + text = "\n".join([*header, "", "[dependencies]", *block]) + "\n" + _validate(text, manifest) + manifest.write_text(text, encoding="utf-8") + return manifest diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 1d66f5a..f0235ca 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -881,6 +881,20 @@ pub mod processing_py { Ok(()) } + /// The built-in WESL packages as `{package: {relative path: source}}`. + #[pyfunction] + fn _shader_packages(py: Python<'_>) -> PyResult> { + let packages = PyDict::new(py); + for package in processing_render::material::custom::shader_packages() { + let files = PyDict::new(py); + for (path, contents) in package.files { + files.set_item(path, contents)?; + } + packages.set_item(package.name, files)?; + } + Ok(packages) + } + #[pyfunction] #[pyo3(pass_module)] fn window_title(module: &Bound<'_, PyModule>, title: &str) -> PyResult<()> { diff --git a/crates/processing_render/build.rs b/crates/processing_render/build.rs index d5b62aa..715507c 100644 --- a/crates/processing_render/build.rs +++ b/crates/processing_render/build.rs @@ -1,3 +1,5 @@ +use std::path::Path; + // packages wesl libraries for processing and lygia // note that bevy does not (as of yet) understand how to use these packages // they are, instead, used by us as part of shader pre-processing in the @@ -9,9 +11,25 @@ fn main() { .build_artifact() .expect("failed to build processing package artifact"); - wesl::PkgBuilder::new("lygia") + let mut lygia = wesl::PkgBuilder::new("lygia") .scan_root("../../lygia") - .expect("failed to scan Lygia WESL files") + .expect("failed to scan Lygia WESL files"); + wesl_only(&mut lygia.root, Path::new("../../lygia")); + lygia.root.submodules.retain(|sub| sub.name != "test"); + lygia .build_artifact() .expect("failed to build Lygia package artifact"); } + +// lygia's manifest includes `**/*.wesl` only; the scan also picks up its legacy `.wgsl` +fn wesl_only(module: &mut wesl::Module, path: &Path) { + if !path.with_extension("wesl").is_file() { + module.source.clear(); + } + for sub in &mut module.submodules { + wesl_only(sub, &path.join(&sub.name)); + } + module + .submodules + .retain(|sub| !sub.source.is_empty() || !sub.submodules.is_empty()); +} diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 44716d3..5619e03 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -72,7 +72,7 @@ fn specialize( // the first argument is bevy's `MaterialPipeline`; the material key rides in the pipeline key let key: CustomMaterialKey = pipeline_key.material_key.to_key(); crate::material::apply_pipeline_state(descriptor, key.blend_state, key.depth_write); - // two-sided unless asked otherwise, like every other mewnala material + // two-sided unless asked otherwise, like every other material descriptor.primitive.cull_mode = match key.double_sided { Some(false) => Some(Face::Back), _ => None, @@ -141,6 +141,57 @@ impl wesl::Resolver for ProcessingResolver<'_> { } } +// wgsl-analyzer rejects lygia's own `unstable_2025` edition. +const PACKAGE_MANIFEST: &str = "edition = \"2026_pre\"\nroot = \".\"\n"; + +/// A built-in WESL package as source files, for editor tooling to resolve imports against. +pub struct ShaderPackage { + pub name: &'static str, + /// `(path relative to the package root, contents)` + pub files: Vec<(String, &'static str)>, +} + +fn package_files(pkg: &'static wesl::CodegenPkg, extra: &[(&str, &'static str)]) -> ShaderPackage { + fn walk( + module: &'static wesl::CodegenModule, + dir: &str, + files: &mut Vec<(String, &'static str)>, + ) { + for sub in module.submodules { + // a directory module has no source of its own + if !sub.source.is_empty() || sub.submodules.is_empty() { + files.push((format!("{dir}{}.wesl", sub.name), sub.source)); + } + walk(sub, &format!("{dir}{}/", sub.name), files); + } + } + let mut files = vec![ + ("wesl.toml".to_string(), PACKAGE_MANIFEST), + ("package.wesl".to_string(), pkg.root.source), + ]; + files.extend( + extra + .iter() + .map(|(path, contents)| (path.to_string(), *contents)), + ); + walk(pkg.root, "", &mut files); + ShaderPackage { + name: pkg.root.name, + files, + } +} + +/// The WESL packages every shader can import, exactly as the runtime resolves them. +pub fn shader_packages() -> Vec { + vec![ + package_files(&processing::PACKAGE, &[]), + package_files( + &lygia::PACKAGE, + &[("LICENSE.md", include_str!("../../../../lygia/LICENSE.md"))], + ), + ] +} + pub(crate) fn compile_shader(source: &str) -> Result<(String, naga::Module)> { compile_shader_with_features(source, &[]) }