The light solver needs a scene description with four kinds of information:
an XY plot domain
geometry placed in the scene
per-component metadata used for model matching
stable node ids that let outputs be traced back to the original scene
Historically that information often comes from .ops, .opf, and .gwa files. In an interactive workflow, the same information can be created directly in Julia and passed to PlantGeom.prepare_scene.
Whatever the source format, the prepared PlantGeom.SceneGeometry used by the solver must be able to answer these questions:
what is the horizontal plot footprint, and which tile repeats if toricity is enabled?
where is each geometric object located?
which functional group does each geometric component belong to?
which component type does each geometric component belong to?
which plant or object does each component belong to?
which original topology id did this component come from?
For new interactive workflows, start with PlantGeom.make_scene:
using ArchimedLight
using PlantGeom
using FileIO, MeshIO
sensor_mesh = load("sensor.obj")
scene = make_scene(domain=(0.0, 0.0, 2.0, 2.0)) do s
add_plant!(s, "plant.opf"; group="coffee", id=1, at=(0.0, 0.0, 0.0), rotate=(z=15.0,), deg=true)
add_plant!(s, plant_mtg; group="banana", id=2, at=(1.0, 0.0, 0.0), scale=0.8)
add_object!(s, sensor_mesh; group="sensor", type="panel", id=10, at=(0.5, 0.0, 1.2), scale=0.05)
add_ground!(s; group="soil", type="ground", nx=20, ny=20)
endThis creates a prepared PlantGeom.SceneGeometry. The important user-facing concepts are:
domain: the XY plot footprint used for ground placement and the repeating tile when toricity is enabled
add_plant!: imports or places one plant, and assigns its functional group and object id
add_object!: imports or places a non-plant MTG, GeometryBasics mesh, .opf, or .gwa object
group: the high-level model key, such as "coffee" or "soil"
type: the component type, usually read from the OPF/GWA/MTG node symbol or :type attribute
id: the plant or object id used for grouping outputs
Both add_plant! and add_object! accept placement keywords:
add_plant!(s, plant_mtg; group="coffee", id=1, at=(1.0, 0.0, 0.0), scale=0.8)
add_object!(s, mesh; group="sensor", type="panel", id=10, rotate=(z=90.0,), deg=true)Use at for translation, scale for uniform or axis-wise scaling, and rotate for local rotations. Tuple rotations use fixed X, then Y, then Z order. Named-tuple rotations preserve the field order:
add_object!(s, mesh; group="sensor", type="panel", id=10, rotate=(x=10, y=20, z=30), deg=true)
add_object!(s, mesh; group="sensor", type="panel", id=11, rotate=(y=20, z=30, x=10), deg=true)For OPS-compatible placement, use scalar scale with rotation, inclination_azimut, and inclination_angle.
For mesh files, let MeshIO/FileIO do the format-specific IO and pass the loaded mesh to add_object!:
using FileIO, MeshIO
sensor_mesh = load("sensor.obj")
add_object!(s, sensor_mesh; group="sensor", type="panel", id=10)If you already have a complete MTG, you can still use PlantGeom.prepare_scene directly.
The scene has an XY plot domain, which can differ from the bounds of its objects. With toricity enabled, this domain defines the horizontal raster and the repeating tile. For example, a tile containing one representative plant commonly spans the inter-row and intra-row spacing.
With toricity=false, each projection canvas is instead fitted to all scene geometry on a plane perpendicular to its light direction. Objects are not clipped to the plot domain during projection. This does not enlarge any ground mesh created from that domain.
In an .ops, the plot domain is usually defined by the terrain line:
# T xmin ymin zmin xmax ymax flat
T 0 0 0 2 2 flatThis is what read_scene uses to recover the scene XY bounds.
In a dynamic workflow, the equivalent is scene_xy_bounds= in PlantGeom.prepare_scene:
scene = PlantGeom.prepare_scene(
mtg;
scene_xy_bounds=(0.0, 0.0, 2.0, 2.0),
)This is the direct equivalent of the .ops terrain rectangle.
If you want to add explicit soil or paving later on, the same bounds can also be reused by PlantGeom.add_ground!:
add_ground!(
scene;
nx=20, # Number of tiles in X direction
ny=20, # Number of tiles in Y direction
xy_bounds=(0.0, 0.0, 2.0, 2.0), # Define the domain here if not already defined in PlantGeom.prepare_scene
group="pavement",
type="Cobblestone",
)The light model needs actual geometry in 3D space. It does not care whether that geometry came from an imported file or from code, as long as the final MTG contains geometric nodes with valid transformations.
In an .ops, each object row places one imported object in the scene:
#sceneId objectId FilePath x y z scale inclinationAzimut inclinationAngle rotation
1 1 opf/coffee.opf 0 0 0 0 0 0 0
1 2 opf/coffee.opf 1 0 0 0 0 0 0Those rows provide:
the object file to import
its translation, scaling and orientation parameters
an object-level id
The detailed organ geometry usually comes from the referenced .opf or .gwa.
In a dynamic workflow, placement is simply the geometry and transformation you construct on the MTG nodes.
For example, a node can carry geometry directly:
leaf = Node(mtg, MutableNodeMTG(:+, :Leaf, 1, 2))
leaf[:geometry] = some_geometryor a transformed geometry:
leaf[:geometry] = PlantGeom.Geometry(
ref_mesh=leaf_ref_mesh,
transformation=PlantGeom.Translation(0.5, 0.0, 0.3),
)If you build the plant procedurally with PlantGeom's growth API, the same placement information is implicit in the internode and leaf parameters you pass to the constructors.
The functional group is the high-level key used to resolve optical models. It is one half of the (group, type) lookup pair.
In .ops, ARCHIMED adds the convention:
#[Archimed] coffeeAll following object rows inherit that functional group until another #[Archimed] ... line appears.
In an MTG built in memory, the equivalent is to set the :functional_group attribute on the relevant nodes:
plant[:functional_group] = "coffee"This can be attached at the plant level or directly on geometric nodes, depending on how your MTG is organized. What matters is that PlantGeom.prepare_scene can recover the right group for each geometric component.
Example with two plants carrying different groups:
coffee = Node(scene_root, MutableNodeMTG(:/, :Plant, 1, 1))
coffee[:functional_group] = "coffee"
banana = Node(scene_root, MutableNodeMTG(:/, :Plant, 2, 1))
banana[:functional_group] = "banana"The type is the lower-level key used to distinguish leaves, stems, paving, sensors, and other components inside one functional group.
In .opf or .gwa, the type typically comes from the topology node type or from explicit type metadata stored on the geometric nodes.
In a dynamic workflow, PlantGeom.prepare_scene derives the type from:
explicit type-like attributes such as :type
otherwise the node symbol
So these are equivalent:
leaf = Node(axis, MutableNodeMTG(:+, :Leaf, 1, 2))and
leaf = Node(axis, MutableNodeMTG(:+, :Organ, 1, 2))
leaf[:type] = "Leaf"This is what lets one group such as "coffee" resolve different optical models for "Leaf", "Internode", "Fruit", or "Sensor".
object_id is used to regroup several geometric components under the same plant or object.
In .ops, the object rows already carry an object id column.
In an interactive workflow, you can assign it directly:
plant[:object_id] = 1If you create several plants in one scene, giving them different object_id values makes it easier to aggregate outputs by individual later on.
source_topology_id is the stable id of the original source component when that information is available.
An imported .opf often already carries stable topology ids.
You can also set them manually when your own simulator already has stable organ ids:
leaf[:source_topology_id] = 42If you do not provide them, PlantGeom.prepare_scene and PlantGeom.add_ground! create consistent fallback ids automatically.
Once the scene metadata and geometry are in place, PlantGeom.prepare_scene converts the original MTG into the dense representation used by the solver:
scene = PlantGeom.prepare_scene(
mtg;
source_path="interactive.opf",
scene_xy_bounds=(-1.0, -1.0, 1.0, 1.0),
)The resulting PlantGeom.SceneGeometry stores:
one merged mesh for efficient geometric processing
a face2node map linking triangles back to scene node ids
node areas and barycentres
per-node group, type, object_id, and source_topology_id
the scene XY bounds
This is the object that the light pipeline actually consumes.
The file formats are still useful, but they are just ways to deliver the scene semantics described above.
.ops#Useful when you want one file to define:
the plot domain
the placement of several imported objects
ARCHIMED functional-group tags
.opf#Useful when you want:
plant topology
organ-scale identity
geometry with reusable reference meshes
outputs that can be attached back to plant nodes
.gwa#Useful when you want:
geometry without plant topology
simple objects such as paving, walls, frames, or sensors
lightweight geometric containers
Ground is not a special scene concept. It is just geometry with a group and a type, often something like (group="pavement", type="Cobblestone").
You can obtain it in two ways:
from files, for example with automatic paving materialized by read_simulation
dynamically with PlantGeom.add_ground! on an existing prepared scene
The dynamic route is often preferable when you want explicit inspectable ground geometry in the final MTG.
Before running a simulation, check that:
scene_xy_bounds really match the intended plot footprint
every simulated component resolves to a valid (group, type) pair
object_id is meaningful when several plants share the same scene
toricity=true is used only with a footprint that really represents the repeated tile
If those semantics are correct, the solver does not particularly care whether they came from .ops / .opf / .gwa files or from Julia code.