Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/processing_pyo3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions crates/processing_pyo3/processing_py/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions crates/processing_pyo3/processing_py/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
122 changes: 122 additions & 0 deletions crates/processing_pyo3/processing_py/_shaders.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions crates/processing_pyo3/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bound<'_, PyDict>> {
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<()> {
Expand Down
22 changes: 20 additions & 2 deletions crates/processing_render/build.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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());
}
53 changes: 52 additions & 1 deletion crates/processing_render/src/material/custom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ShaderPackage> {
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, &[])
}
Expand Down
Loading