Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ jobs:
uses: astral-sh/setup-uv@v4
with:
version: "latest"
# rendered example images are keyed on the examples and the render script;
# render.py re-renders only those whose sources changed
- name: Cache example renders
uses: actions/cache@v4
with:
path: docs/examples/images
key: example-renders-${{ hashFiles('docs/examples/code/*.py', 'docs/examples/render.py') }}
restore-keys: example-renders-

- name: Build Docs
run: |
uv sync --all-extras
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ docs/objects.json
/docs/_variables.yml
tests/plots/*.png
.coverage
docs/examples/images/
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ format:
uv run ty check --fix src
uv run ruff format

# Render the example images (only those whose example or shot changed);
# `make docs` also does this through the quarto pre-render step.
examples:
uv run python docs/examples/render.py

docs:
cd docs && uv run quartodoc build
cd docs && uv run quartodoc interlinks
Expand Down
6 changes: 5 additions & 1 deletion docs/_quarto.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
project:
type: website
output-dir: _build
pre-render: _config.py
pre-render:
- _config.py
- examples/render.py

website:
title: "nodebpy {{< var version >}}"
Expand All @@ -22,6 +24,8 @@ website:
- nodes-to-code.qmd
- using.qmd
- comparisons.qmd
- href: examples/index.qmd
text: Examples
- href: reference/
text: Reference
- href: changelog.qmd
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ title: Changelog

- **`link_assets(*groups)`** links the node groups of several asset classes with one read of each `.blend` library, and returns the tree for each class. Creating an asset group reads its whole library, a fixed cost however few groups it takes, so linking the groups a tree will use up front avoids paying it for each one. Groups already in the file are reused, and groups that build from source are skipped, as when creating a single group, which now goes through `link_assets`.

### Documentation

- **An [Examples](examples/index.qmd) gallery** of nine complete trees ported from Geometry Script and geonodes (voxelize, LEGO bricks, a city builder, a golf ball, a forest, a gear train, an explosion simulation and more), each with its script, a render, its node tree and a link to the original. `docs/examples/render.py` renders the images with Cycles as a Quarto pre-render step, and re-renders only the examples that changed.

## v520.34.1

### Fixes
Expand Down
39 changes: 39 additions & 0 deletions docs/examples/arrow-field.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
title: Arrow Field
description: Visualise a vector field with arrows, coloured by an attribute read in the material.
image: images/arrow_field.png
categories: [geonodes, fields, materials, instancing]
---

![A vortex drawn as a grid of arrows, warm where it is strongest.](images/arrow_field.png)

A grid of points samples a vortex: a field that swirls around the Z axis and is strongest near it.
Each point gets an arrow that points along the field and is scaled by its strength, and the material colours the arrows by the same strength.

```{python}
{{< include code/arrow_field.py >}}
```

## How it works

- **The arrow** comes from `arrow()`, a Python function that joins a cylinder and a cone into a unit arrow pointing up Z. Calling a function like this adds its nodes to whichever tree is open at the time.
- **The field** is written as ordinary vector arithmetic. `position.cross((0, 0, -1))` is a vector at right angles to the position, which makes the swirl, and dividing by `position.length() ** 2 + 0.5` makes it fall off away from the centre. Each operator adds a Vector Math or Math node.
- **Strength** is the field's length divided by its largest value over all points, `field.length().point.max()`. The `.point` domain methods (`.max()`, `.mean()`, `.total()` and others) add the matching statistic node evaluated on the point domain.
- **Instancing.** Align Rotation to Vector turns each arrow's Z axis along the field, and the strength, remapped to between 0.3 and 1, sets its scale.
- **Colour.** The strength is stored as a point attribute before instancing, which carries it onto each instance. The material reads it with `s.Attribute.instancer("strength")`, the instancer type being the one that reads attributes stored on instances, and maps it through a three-stop Color Ramp. The ramp's stops are a list of `(position, colour)` pairs.

## Node trees

```{python}
#| echo: false
tree
```

```{python}
#| echo: false
material
```

## Original

Based on the arrow field in [`demos/arrows.py`](https://github.com/al1brn/geonodes/blob/main/demos/arrows.py) in geonodes ([write-up](https://github.com/al1brn/geonodes/blob/main/docs/demos/arrows.md)), which also offers several head shapes through closures and a menu, and builds arrows from cartesian, polar or spherical coordinates.
37 changes: 37 additions & 0 deletions docs/examples/city-builder.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: City Builder
description: Draw roads with a curve and fill the space between them with buildings.
image: images/city_builder.png
categories: [Geometry Script, curves, proximity]
---

![Three Bézier roads through a block of randomly sized buildings.](images/city_builder.png)

The modifier goes on a curve object: each spline is a road.
Building lots are scattered over a grid, any lot too close to a road is removed, and a box of random size is placed on each lot that is left.
Drawing a new road in Edit Mode clears a path through the city straight away.

```{python}
{{< include code/city_builder.py >}}
```

## How it works

- **Roads.** Curve to Mesh sweeps a straight profile, as wide as `Road Width`, along every spline. The profile's ends are built from the input with `g.CombineXYZ(x=...)`, and `road_width / 2` is a Math node made by the `/` operator.
- **Lots.** Distribute Points on Faces scatters points on a grid. Geometry Proximity measures how far each point is from the nearest point on the evaluated road curves, and `distance < road_width` creates a Compare node whose result selects the points to delete.
- **Buildings.** The cube is moved up by half its height first, so when it is scaled on a lot it grows upwards from the ground rather than in both directions. Random Value in vector mode gives each building its own footprint and height between `Size Min` and `Size Max`.
- **Materials.** The roads get an asphalt material created in the same script with `s.material`. The buildings take theirs from the `Material` input.

`with tree.inputs.panel("City"):` groups inputs into a collapsible panel in the modifier, and `with g.Frame("Roads"):` puts every node created in the block into a frame, so the tree's layout follows the script's structure.

## Node tree

```{python}
#| echo: false
tree
```

## Original

Ported from the [City Builder tutorial](https://carson-katri.github.io/geometry-script/tutorials/city-builder.html) and [`City Builder.py`](https://github.com/carson-katri/geometry-script/blob/main/examples/City%20Builder.py) in Geometry Script.
The original `yield`s the roads and buildings so they are joined automatically; here the two results are named variables passed to an explicit Join Geometry.
57 changes: 57 additions & 0 deletions docs/examples/code/arrow_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from nodebpy import geometry as g
from nodebpy import shader as s


def arrow(vertices):
"""A unit-length arrow pointing up Z, built from a shaft and a head."""
shaft = g.Cylinder(vertices, radius=0.05, depth=0.7)
head = g.Cone(vertices, radius_bottom=0.12, depth=0.3)
return g.JoinGeometry(
[
shaft >> g.TransformGeometry(translation=(0, 0, 0.35)),
head >> g.TransformGeometry(translation=(0, 0, 0.7)),
]
)


with s.material("Arrow") as material:
strength = s.Attribute.instancer("strength").o.fac
ramp = s.ColorRamp(
strength,
items=[
(0.0, (0.05, 0.15, 0.6, 1.0)),
(0.5, (0.1, 0.7, 0.6, 1.0)),
(1.0, (1.0, 0.5, 0.05, 1.0)),
],
)
s.PrincipledBSDF(base_color=ramp, roughness=0.4) >> s.MaterialOutput()


with g.tree("Arrow Field", is_modifier=True) as tree:
size = tree.inputs.float("Size", 6.0, min_value=0.0)
count = tree.inputs.integer("Count", 15, min_value=2)
scale = tree.inputs.float("Arrow Scale", 0.5, min_value=0.0)
vertices = tree.inputs.integer("Arrow Vertices", 12, min_value=3)

with g.Frame("Vortex"):
# swirl around Z, strongest near the axis
position = g.Position().o.position
field = position.cross((0, 0, -1)) / (position.length() ** 2 + 0.5)
strength = field.length() / field.length().point.max()

points = (
g.Grid(size, size, count, count)
>> g.MeshToPoints()
>> g.StoreNamedAttribute.point.float(name="strength", value=strength)
)

(
points
>> g.InstanceOnPoints(
instance=arrow(vertices),
rotation=g.AlignRotationToVector(vector=field),
scale=strength.map_range(0.0, 1.0, 0.3, 1.0) * scale,
)
>> g.SetMaterial(material=material.material)
>> tree.outputs.geometry("Geometry")
)
52 changes: 52 additions & 0 deletions docs/examples/code/city_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from nodebpy import geometry as g
from nodebpy import shader as s

with s.material("Asphalt") as asphalt:
(
s.PrincipledBSDF(base_color=(0.02, 0.02, 0.025, 1.0), roughness=0.8)
>> s.MaterialOutput()
)

with g.tree("City Builder", is_modifier=True) as tree:
roads = tree.inputs.geometry("Roads")
road_width = tree.inputs.float("Road Width", 0.25, min_value=0.0)
with tree.inputs.panel("City"):
size_x = tree.inputs.float("Size X", 5.0, min_value=0.0)
size_y = tree.inputs.float("Size Y", 5.0, min_value=0.0)
density = tree.inputs.float("Density", 10.0, min_value=0.0)
seed = tree.inputs.integer("Seed")
with tree.inputs.panel("Buildings"):
smallest = tree.inputs.vector("Size Min", (0.1, 0.1, 0.2))
largest = tree.inputs.vector("Size Max", (0.3, 0.3, 1.0))
material = tree.inputs.material("Material")

with g.Frame("Roads"):
half = road_width / 2
profile = g.CurveLine(start=g.CombineXYZ(x=-half), end=g.CombineXYZ(x=half))
road_mesh = (
roads
>> g.CurveToMesh(profile_curve=profile)
>> g.SetMaterial(material=asphalt.material)
)

with g.Frame("Building Lots"):
# scatter lots everywhere, then clear the ones that land on a road
road_points = g.CurveToPoints.evaluated(roads)
distance = g.GeometryProximity(road_points, target_element="POINTS").o.distance
lots = (
g.Grid(size_x, size_y)
>> g.DistributePointsOnFaces(density=density, seed=seed)
>> g.DeleteGeometry.point(selection=distance < road_width)
)

with g.Frame("Buildings"):
# move the cube up so it sits on the ground before it is scaled
block = (
g.Cube()
>> g.TransformGeometry(translation=(0, 0, 0.5))
>> g.SetMaterial(material=material)
)
size = g.RandomValue.vector(min=smallest, max=largest, seed=seed)
buildings = lots >> g.InstanceOnPoints(instance=block, scale=size)

g.JoinGeometry([road_mesh, buildings]) >> tree.outputs.geometry("City")
36 changes: 36 additions & 0 deletions docs/examples/code/explosion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from nodebpy import geometry as g

with g.tree("Explosion", is_modifier=True) as tree:
geometry = tree.inputs.geometry("Geometry")
speed = tree.inputs.float("Speed", 4.0, min_value=0.0)
gravity = tree.inputs.float("Gravity", 9.81)
seed = tree.inputs.integer("Seed")

with g.Frame("Fragments"):
# split every face off and give it its own launch velocity
launch = g.Normal() * speed * g.RandomValue.float(0.4, 1.6, seed=seed)
fragments = (
geometry
>> g.SplitEdges()
>> g.ScaleElements.face(scale=0.9)
>> g.StoreNamedAttribute.face.vector(name="velocity", value=launch)
)

with g.Frame("Ballistics"):
sim = g.SimulationZone()
pieces = sim.items.geometry(fragments, "Pieces")
velocity = g.NamedAttribute.vector("velocity")
dt = sim.delta_time

# Set Position reads `velocity` after it has been stored,
# so the offset already includes this step's gravity
(
pieces.current
>> g.StoreNamedAttribute.point.vector(
name="velocity", value=velocity - g.CombineXYZ(z=gravity * dt)
)
>> g.SetPosition(offset=velocity * dt)
>> pieces.next
)

pieces.result >> tree.outputs.geometry("Geometry")
Loading
Loading