Skip to content

11 - Triangle meshes

When vegetation is represented by triangle meshes, every leaf and every branch segment of the model is a real triangle. This keeps the structural detail of the canopy, such as leaf clumping, gaps and shadows. This chapter covers how mesh objects are built and placed, and ends with two complete scenes: an autumn forest and a maize field.

Objects and components

An object (less.Object) usually comes from an OBJ file. Each group in the file becomes a component with its own properties:

import less

tree = less.Object("ash", mesh=less.asset_path("FREX.obj"))
print(tree.components)            # ['leaves', 'stem_branch']

tree.set_property("leaves", less.Prospect(cab=40, car=10))
tree.set_property("stem_branch", less.Lambertian(reflectance=0.1))

Components can also be added one at a time, possibly from different files:

tree2 = less.Object("ash_by_parts")
tree2.add_component("leaves", less.Mesh(less.asset_path("FREX.obj"), group="leaves"))
tree2.add_component("stem_branch", less.Mesh(less.asset_path("FREX.obj"), group="stem_branch"))

With group, only the triangles of that group are read.

LESS includes three plant models:

File Content Components
FREX.obj European ash leaves, stem_branch
maize.obj Maize plant a single component
tutorial_tree.obj Simplified tree for teaching crown (closed crown), trunk, leaves

With your own models, mind the OBJ coordinate convention and the orientation of the triangles' front faces (see chapter 5).

Placing instances

An object can be placed in the scene many times; each placement is an instance. All instances share one copy of the geometry, so even large numbers of instances use almost no extra memory:

import numpy as np

scene = less.Scene()
scene.size = 50.0

positions = np.array([[10, 10, 0], [20, 15, 0], [30, 25, 0]])
handle = scene.add(tree, positions=positions,
                   scales=[0.8, 1.0, 1.2],      # scale factor of each instance
                   rotations=[0, 90, 180])      # rotation about Z (degrees)

scales can be:

Form Meaning
A single number The same factor for every instance
An array with one value per instance A uniform scale per instance
An array of shape (instances, 3) Separate X, Y and Z scales per instance

Placement helpers

less.place generates positions in common patterns:

positions = less.place.random(scene, n=60, min_dist=3.0, seed=42)         # random, at least 3 m apart
positions = less.place.grid(scene, spacing=5.0, jitter=0.5, seed=1)       # regular grid with jitter
positions = less.place.grid(scene, spacing=(0.75, 0.40), margin=0.5)      # 0.75 m east–west, 0.40 m north–south
groups = less.place.random_split(scene, counts=[40, 20], min_dist=3.0, seed=7)  # two groups that do not overlap

Random sizes and orientations:

n = 60
scales = less.place.uniform_scale(n, lo=0.6, hi=1.5, seed=42)
rotations = less.place.random_rotation(n, seed=42)

If you have a stem map, read the coordinates with less.place.from_csv("trees.csv", x="x", y="y").

Changing instances

Once the scene is built, the handle can move, rotate or scale instances; scene.update_instances() applies the changes without rebuilding the whole scene:

scene.build()                               # build first if the scene has not been simulated
handle.set_scale(1.5, instance=0)           # one instance
handle.set_scale(np.full(3, 0.9))           # all instances
handle.set_position(np.array([[12, 10, 0], [22, 15, 0], [32, 25, 0]]))
scene.update_instances()

Periodic boundaries

By default a scene is finite: there is nothing outside it, and sunlight can enter through the top and through the sides facing the sun. For large, uniform stands or fields, the scene can repeat horizontally:

scene.repetitive = True

Scene repetition

A ray leaving one side then re-enters from the opposite side, as if the scene were tiled infinitely. The geometry is stored only once, so this costs no extra memory. True lets a ray cross the boundary up to 100 times; an integer of at least 5 sets a different limit.

Setting Use
scene.repetitive = False (default) Bounded targets: single trees, isolated plots, city blocks
scene.repetitive = True Uniform canopies that can be treated as horizontally infinite

Periodic boundaries require flat terrain.

Leaf area index

The leaf area index (LAI) is the total one-sided leaf area divided by the horizontal area of the scene:

lai = scene.measure(less.LAIMeasurement(property_filter=less.Prospect))
by_component = scene.measure(less.LAIMeasurement(group_by="component"))

property_filter selects components by property type: above, only components whose optical property is Prospect (the leaves) are counted. Without a filter every object component, including branches, is counted. group_by can be "total", "object" or "component".

Terrain is never counted as leaf area. The denominator is always the horizontal area given by scene.size, also on slopes.

Example 1: an autumn forest

A 50 m × 50 m deciduous stand: 60 ash trees in five autumn leaf colours, with random sizes and orientations:

import numpy as np
import less

scene = less.Scene()
scene.size = 50.0
scene.terrain = less.Terrain(property=less.Lambertian(reflectance=0.08))
scene.illumination = less.Illumination(
    source=less.Sun(zenith=40, azimuth=150), atmosphere=less.NoAtmosphere())

autumn_leaves = {
    "golden":       less.Prospect(cab=10, car=20, cbrown=0.15, cw=0.012, cm=0.009, N=1.8),
    "orange":       less.Prospect(cab=3,  car=12, canth=8,  cbrown=0.25, cw=0.010, cm=0.008, N=1.7),
    "red":          less.Prospect(cab=2,  car=5,  canth=30, cbrown=0.15, cw=0.011, cm=0.009, N=1.8),
    "dark_red":     less.Prospect(cab=1,  car=3,  canth=40, cbrown=0.35, cw=0.009, cm=0.010, N=2.0),
    "yellow_green": less.Prospect(cab=20, car=15, cbrown=0.05, cw=0.013, cm=0.009, N=1.7),
}

groups = less.place.random_split(scene, counts=[12] * len(autumn_leaves), min_dist=3.0, seed=42)
for (name, leaf), positions in zip(autumn_leaves.items(), groups):
    tree = less.Object(f"ash_{name}", mesh=less.asset_path("FREX.obj"))
    tree.set_property("leaves", leaf)
    tree.set_property("stem_branch", less.Lambertian(reflectance=0.06))
    n = len(positions)
    scene.add(tree, positions=positions,
              scales=less.place.uniform_scale(n, lo=0.6, hi=1.5, seed=len(name)),
              rotations=less.place.random_rotation(n, seed=len(name)))

print("LAI:", round(scene.measure(less.LAIMeasurement(property_filter=less.Prospect)), 2))

nadir = scene.simulate(less.OpticalImager(
    less.Orthographic(image_size=256), bands=[630, 532, 465], quality=32))
nadir.save("autumn_forest_nadir.png")

oblique = scene.simulate(less.OpticalImager(
    less.Perspective(resolution=256, fov=35, position=(25, 60, 45), target=(25, 25, 6)),
    bands=[630, 532, 465], quality=32))
oblique.save("autumn_forest_oblique.png")

Each leaf colour is a separate object, so it can have its own properties; random_split keeps the minimum distance between trees of different groups as well. The second sensor is a perspective camera: position is the camera position, target the point it looks at, and fov the field of view in degrees.

Example 2: a maize field

A 10 m × 10 m maize field with 0.75 m rows and 0.40 m plant spacing:

import numpy as np
import less

scene = less.Scene()
scene.size = 10.0
scene.terrain = less.Terrain(property=less.Lambertian(reflectance=0.15))
scene.illumination = less.Illumination(
    source=less.Sun(zenith=30, azimuth=150), atmosphere=less.NoAtmosphere())

maize = less.Object("maize", mesh=less.asset_path("maize.obj"))
maize.set_property(less.Prospect(cab=45, car=10, cw=0.012, cm=0.006, N=1.55))

positions = less.place.grid(scene, spacing=(0.75, 0.40), jitter=0.04, margin=0.3, seed=42)
n = len(positions)
rng = np.random.default_rng(42)
plants = scene.add(maize, positions=positions,
                   scales=rng.uniform(0.9, 1.1, n),
                   rotations=rng.uniform(0, 360, n))
print(n, "plants, LAI =", round(scene.measure(less.LAIMeasurement()), 2))

sensor = less.OpticalImager(less.Orthographic(image_size=128),
                            bands=[650, 550, 450], quality=32)
field = scene.simulate(sensor)
field.save("maize_field.png")

side = scene.simulate(less.OpticalImager(
    less.Perspective(resolution=256, fov=35, position=(5, 14, 6), target=(5, 5, 1.2)),
    bands=[650, 550, 450], quality=32))
side.save("maize_field_side.png")

Growth stages

Changing only the plant size gives a simple approximation of different growth stages, without placing the plants again:

plants.set_scale(np.full(n, 0.3))          # seedlings
scene.update_instances()
seedling = scene.simulate(sensor)

plants.set_scale(rng.uniform(0.9, 1.1, n)) # mature plants
scene.update_instances()
mature = scene.simulate(sensor)

Typical planting parameters:

Crop Row spacing (m) Plant spacing (m) LAI
Maize 0.60–0.75 0.20–0.40 2–5
Wheat 0.15–0.25 0.03–0.05 3–7
Soybean 0.40–0.60 0.05–0.10 3–6
Rice 0.25–0.30 0.15–0.20 4–8

The LAI of a scene depends on the leaf area of the plant model and the planting density.

Companion script: scripts/11_triangle_mesh.py

Next