The recipe
A recipe is one TOML file in the app’s repo, written by an AI agent such as Claude or by a person, patched by Manfred’s window, read by manfred build or by the app’s own build binary (docs/apps.md). The recipe crate is its one implementation and its tests are the spec.
Conventions
Section titled “Conventions”- Paths are relative to the recipe file. Outputs go where
[output]says; scratch goes under the repo’stmp/. - Units are metres. Axes are the engine’s and glTF’s: Y up, right-handed, a default camera looks along -Z. “Depth” in a pocket grows away from the camera, along -Z. Blender’s Z-up never appears in a recipe.
- Angles are degrees. Colours are linear RGB triplets in 0..1, or a
"#rrggbb"string where a key says so. The key iscolor. - Keys are snake_case. Objects are an array of tables,
[[object]], in the order they appear. An unknown key is an error. A missing name, an unknown material, a reference to an object that doesn’t exist, and a degenerate result are errors that name the key. manfred = 1is the schema version. A reader refuses a newer schema and migrates an older one. An older header,manf = 1, has no alias: a recipe or an included file that still starts withmanf = 1is refused at that line, “manf = 1is the old header; Manfred recipes start withmanfred = 1”.- Nothing reads the clock or the machine. The only randomness comes from
seed, which generators consume.
Top level
Section titled “Top level”manfred = 1include = ["rig.toml"]name = "toybox"seed = 7params = "scene/toybox.params.json"
[plate]width = 1600height = 1000metres_per_pixel = 0.0004origin = "centre"
[output]glb = "assets/scene/toybox.glb"sidecar = "assets/scene/toybox.json"sidecar_dialect = "facts"order = "natural"bake = "assets/bake.toml"scene = "assets/scene/toybox.scene.toml"scene_camera = "main"scene_format = 1include: other files whose cameras, lights, sky, look, materials and objects merge into this recipe, under “Includes and uses” below.params: an optional JSON or TOML file whose values can be substituted into numeric fields as"${params.camera.focal_mm}". An app’s own solver writes the file; the recipe consumes it.[plate]: declares plate pixels, and fixes theplateUV projection.cameranames the camera that projection looks through; without it, the first[[camera]].- Twins: with a plate, these keys have a
_pxtwin read in pixels:at_px,size_px,rect_px,radius_px,circle_px,points_px, and a mover’spivot_px,travel_px,clip_pxandclip2_px. A key and its twin together are an error, and a twin without a[plate]is a problem. - Origin:
originis where pixel 0,0 sits,"centre"or"top_left". Either way the plate is centred on the world origin. - Axes: pixel y grows down and world y up:
y = -(py - oy) * metres_per_pixel.rect_pxis normalised to min and max. - Depth:
at_pxtakes x and y in pixels, plus an optional z in metres. A box’ssize_pxtakes three values, the last in metres; a card’s or plane’s takes two.
- Twins: with a plate, these keys have a
order:file(the default) writes nodes in recipe order;naturalsorts them the way Blender’s exporter did, for migration. An app that depends on order says which.sidecar_dialect: which writer turns the scene’s facts into the sidecar:none(the default;[output]itself may be absent),facts, Manfred’s own dialect (docs/apps.md, “The facts”), or the name of a writer the app’s build binary registers. The recipe carries one camera and sun description; the writer turns it into the keys that app reads. A dialect other thannoneneeds asidecarpath, and asidecarwith dialectnoneis a problem. A name no writer is registered under is a build error that lists the registered ones; a writer may refuse a recipe with a sentence of its own, reported underoutput.sidecar_dialect.scene: when set,manfred buildalso writes the engine’sscene.tomlfor this recipe, and its material library beside it, under “The scene” below. The path ends in.toml;.scene.tomlis the convention.scene_cameranames the[[camera]]it carries; without it, the first. Ascene_camerawithout ascene, or naming no camera, is a problem.scene_formatis 1 (the default: scene format 1, pfx-scene’s, under “Format 1” below, which the engine and Manfred’s window open) or 0 (the engine’s olderscene.toml, which the engine still reads, migrating it in memory with one warning a file); any other number, or ascene_formatwithout ascene, is a problem.bake: when set,manfred buildalso writes apfx bakerecipe for this scene, in the engine’sbake.tomlschema, with the same sky, sun and files. The path ends inbake.toml, sincepfx bake --scene <folder>reads that file of the folder. The keys only a bake needs come from the[bake]table, under “Variants, look and bake”.
Spelling shared with scene.toml
Section titled “Spelling shared with scene.toml”Where the recipe and the engine’s scene.toml (pfx’s docs/scenes.md) name the same thing, the same table reads the same in both. The recipe is Manfred’s authoring layer and the scene file is the engine’s runtime data; manfred build writes the second from the first. An old spelling is refused with a problem that names the new one, and there are no aliases.
| Old recipe spelling | Now |
|---|---|
[sun] with toward, color, irradiance |
[sun] with model = "authored" and the same keys |
[daylight] with hour, day, latitude, heading |
[sun] with model = "daylight" and the same keys |
prepare = "capped" in [sky] |
[sky.prepare] with cap = 60.0 and mean = 0.3 |
balance = [r, g, b] in [sky] |
balance in [sky.prepare] |
--set and variant paths daylight.hour, sky.balance |
sun.hour, sky.prepare.balance |
colour |
never a recipe key; color everywhere, and colour is refused as any unknown key |
[sun]takesmodel, required:"daylight"(hourandlatitudeandheadingrequired,day172,reference_hour) or"authored"(towardandirradiancerequired,colorwhite,radius_deg). Each model takes only its own keys.[sky.prepare]takescap,balanceandmeanas the engine’s bake does, and the table needs one of them.[look]stays. The engine’s[finish]is the post chain’s own keys (style,bloom,vignette,toneand the rest), whichmanfreddoes not hold:[look]is what an author says (view,exposureand agradeof five amounts from 0 to 1, haze among them, which is the room’s and not a post pass), and the writer turns it into a[finish]of numbers. A[finish]in a recipe is refused with a pointer to[look].- The same, unchanged:
[sky]’skind(analytic,hdr,room),path,rotation_degandintensity;[sky.room]with its[[sky.room.lights]]; and[[light]]’sname,position,color,intensityandradius.
Left different on purpose:
- Daylight defaults. The scene file defaults
latitudeto 45 andheadingto 180, and a recipe that left them out would read differently in the two. The recipe requires both, so it never does.reference_houris Manfred’s 16, where the scene file’s default is 12: the recipe accepts only 16.0 or the key left out, and the writer writesreference_hour = 16.0into the scene. - An authored sun’s
houris refused: Manfred’s authored light follows the hour of the shot or the window, which no scene key says.sky_fillis Manfred’s alone;radius_degis the scene’sradius. [[light]]’sshadowandrange. The recipe’s defaults arefalseand 0 (reach derived from the intensity, aslight.rsdoes); the scene file’s aretrueand 10. The writer writes the resolved values.- The sky.
kind = "mix",source,fill,[sky.shade]andambient = "authored"(the room scaled by the sun’s hour) are Manfred’s. The scene’sambientis a number on the analytic sky,turbidityandground_albedoare not recipe keys, and amixis written as two layers whose weights carryfill. - Cameras. The recipe’s
[[camera]]list withposition,targetandfov_y_degis not the scene’s single[camera]withat,look_atandfov; the writer picks one and converts. - Movers, variants,
[sidecar]and[bake]have no scene spelling and stay on the authoring side: the scene’s[[mover]]is a preview motion with a kind, a period and a curve, and a recipe mover has none. [trace]is the scene’s own table, withtransmissive_shadows,clamp_indirectandfilter_glossy;spp,thresholdandmin_samplesaremanfred render’s alone and stay out of the scene.
Objects
Section titled “Objects”Common keys on every [[object]]:
| Key | Meaning |
|---|---|
name |
exact, unique, the glTF node name the app looks up |
id |
optional: 10 lowercase Crockford base32 characters (0-9, a-z without i, l, o and u), unique among the objects. A format 1 scene derives the ids of the object’s entries from it in place of the recipe’s path and the object’s name, so they survive a rename. The window writes it on the first save of a recipe that writes a scene ([output] scene), as the id the writer derives for the object’s first node today; manfred_build::object_ids gives it to both |
kind |
one of the kinds below |
at, rotate, scale |
the node transform, applied into the mesh unless keep_transform = true. They compose as T·R·S. rotate is XYZ Euler in degrees in the recipe’s Y-up frame, applied X, then Y, then Z (the matrix Rz·Ry·Rx). From Blender’s Z-up: a position (x, y, z) becomes [x, z, −y], and a rotation Rx(θ), Ry(θ), Rz(θ) becomes Rx(θ), Rz(−θ), Ry(θ) |
parent |
another object’s name; transforms compose, the GLB stays flat unless [output] hierarchy = true |
material |
a name resolved through the app’s registered catalogue, then the recipe’s own [materials]; or materials = ["a", "b"] for slots, assigned by face groups the kind defines. Only cylinder defines groups today: [side, caps]. Every other kind takes one name |
smooth_angle |
split normals on edges sharper than this; absent means flat shading; smooth = true means smooth everywhere |
flat_faces |
true (default false) keeps a large flat face’s own normal at all its vertices instead of smoothing it into its bevel; needs smooth_angle; see Flat faces |
bevel |
{ width, segments, angle = 30, clamp = true } for kinds that take an edge profile |
uv |
"local" (the default: the builder’s own UVs, or a box projection where the kind has none), "plate", "none", or an override table, { kind = "rect", … } or { kind = "cylinder", … }; see UVs |
detail_uv |
{ texels_per_m, gutter = 16 } adds a second UV set, TEXCOORD_1: a unique layout for detail bakes; see Detail UVs |
shadow |
"cast" (default), "only" (an occluder), "none" |
two_sided |
draw both faces |
hidden |
built, not exported; useful for cutters you want to see in the window |
dynamic |
true or false: whether the object moves or changes at run time, so a baked plate leaves it out and the live view draws it over the plate. Without it, a mover’s part, a card that faces the camera, and an object whose parent is dynamic are dynamic, and everything else is static, as pfx derives it. true marks what the app moves itself; false keeps a mover’s part in the plate. A text takes it too, under “Text” below; a pocket doesn’t |
proxy |
"box" (the default) or "hull": the invisible stand-in of a static object in the scene’s proxies file, under “The scene”. Only with a plate folder ([bake.plates] dir), never on a dynamic object, a text, a pocket or a hidden object |
A part whose material the live renderer draws as glass (transmission above 0, or the glass or liquid family) is a glass surface, and it is not also an opaque instance. shadow = "cast" sets glass::Surface::casts_shadow, so the surface draws its own tinted sun shadow: transmission × base, or transmission × Beer–Lambert over the thickness when both are set (glass::cast_tint). shadow = "none" draws the glass and casts nothing. shadow = "only" keeps the surface with shadow_only: true and casts_shadow: true: it casts its tinted shadow and is not drawn. A part of a mover carries the mover’s clip and clip2 planes on its surface, so glass is cut at them as the opaque parts are. Glass tints the sun’s shadow and, with engine v0.19.0, the shadowed local lights.
Flat faces
Section titled “Flat faces”flat_faces = true, with smooth_angle, stops a large flat face from being smoothed into its bevel. Without it the face’s corner vertices share a normal with the bevel’s first segment (18° apart is under a smooth_angle of 35°), so the interpolated normal tilts across the face by up to a few degrees, the way the triangulation falls. With it:
- A planar region is a connected set of triangles, joined edge to edge, whose normals are all within 0.5° of the normal of the region’s first triangle (the lowest triangle index it contains). It is the unit the rule works on, so a face split into any number of triangles is one region, and a curved surface of small facets is not.
- A region is larger when its area is at least twice that of every neighbouring region it would otherwise be smoothed with (neighbours across edges under
smooth_angle; sharper edges are split anyway). The ratio of 2 is a fixed number so that a bevel’s segments, each a thin strip beside the face, never rival it, while a low-poly curved surface, whose facets are all alike, never has a face of its own. - A larger region keeps one normal at every vertex, its area-weighted mean, constant across the face whatever the triangulation. Its edges to every other region are split. Everything else smooths by
smooth_angleas before: the bevel’s segments stay smooth among themselves and into the object’s other curved faces, and only the bevel’s vertices on the face’s boundary stop averaging in the face. - Deterministic; without the key every mesh is byte for byte as before. A pocket’s floor, a slab’s front and back, a box’s faces and a cylinder’s caps are regions of this kind.
Kinds:
box:size = [x, y, z]. Withbevel, a rounded box.cylinder:radius,depth(orradius_pxanddepth_pxunder a[plate]),segments = 64,caps = "ngon" | "none", axis Y. Blender’s topology, so a Blender cylinder’s counts match.sphere:radius,segments = 96,rings = 48.plane:size = [x, z], a single quad;grid = [nu, nv]subdivides it. A card bends with the shaping keys:bend = { radius, form = "wrap" | "lift" }puts it on a drum.wrap(the default) wraps it round;liftkeeps the edges across v in the plane and raises the middle toward the face by the drum’s sag.curl = { width | width_px, depth }: the two edges across v curl back bydepth·(1 − cos θ)overwidth.cup = { from, depth }: the sides pastfrom(a fraction of the half width) bend back bydepth·t².jag = [...]orjag_px: the two side columns move by these amounts, cycling by grid row, inward on even rows and outward on odd ones.
disc:radius,segments; an n-gon fan with no centre vertex, as Blender’s.torus:major,minor,segments = 96,rings = 24.capsule:radius,length(the straight section between the two hemispheres’ centres, not the total length); built with 32 segments and 8 rings.outline: a 2D shape extruded betweenfrontandbackdepths with an edgeprofile: slabs, discs, keys, buttons, frames, washers and glyphs.- Holes: an outline whose shape has holes extrudes as one closed solid: the front and back faces are cut around the holes, an outer wall runs along the outer contour and an inner wall along each hole, and the
profilerounds both, outward on the outer wall and into the hole on each inner wall, its sharp corners included. The normals face out of the solid, an inner wall’s into its hole. Separate pieces, each with its own holes, are one object, as aunionof separate shapes is. - A hole comes from a
differencewhose cutter lies inside the outer contour, from aunionthat closes round a gap, or fromholes = [outline, …]beside the outeroutline. differenceandunionare set operations (nonzero): a cutter removes everything inside its outer contour, and a part of aunionthat lies inside another part’s hole stays, as an island.holesis even-odd: the outline and each entry are contours, and a contour inside a hole is solid again, sooutline = { circle = [0, 0, 0.05] }withholes = [{ circle = [0, 0, 0.04] }, { circle = [0, 0, 0.03] }, { circle = [0, 0, 0.02] }]is two rings, one inside the other.- Refused, at the key: an entry of
holesthat touches or crosses the outline’s edge or another entry, or lies outside the outline (object[frame].holes[1]); a cutter or a union part that touches the edge or another hole, naming both (`outline.difference[1]` touches `outline.difference[0]` at [0.1, 0]); and aprofilewider than half the narrowest wall between a hole and the outer contour or between two holes, atprofile.width, naming the width, the wall and the widest width it takes. Within the last 5% below half the wall the profile is built at 95% of half, so the faces never meet. A cutter that crosses the edge is a notch, not a hole, and stays allowed. outline = { rect = [x0, y0, x1, y1], radius = r },{ rect = …, radius = r, turned = deg },{ circle = [cx, cy, r], segments = 64 },{ points = [[x, y], …] },{ points = [[x, y, r], …] }with a fillet radius per corner,{ superellipse = { half = [a, b], exponent = n, segments = 64, centre = [0, 0] } },{ union = [outline, …] },{ difference = [a, b] }.superellipseis the closed curve|x/a|^n + |y/b|^n = 1aboutcentre: 2 is an ellipse (witha = b, the circle of the same segments), 4 a squircle, and higher exponents square it off. Its points run counter-clockwise from+xas(a·cos^(2/n) t, b·sin^(2/n) t), built a quarter at a time and mirrored, so it is exact at the four axes ([cx + a, cy],[cx, cy + b]and so on), symmetric about its diagonals, and the same bytes every time.halfis above 0 on both axes,exponentabove 0, andsegmentsa multiple of 4 from 8 up. It reads as apointsoutline of those corners with no fillets, so a profile, a union, a difference and a pocket take it as they take any other outline (manfred_mesh::outline::superellipse).profile = { kind = "round" | "chamfer" | "none", width, segments }rounds the front and back rims, and also the outline’s corners that turn more than 40°, as Blender’s angle-limited bevel did on vertical edges. This is what Blender’s bevel did on these parts.turnedis in degrees, counter-clockwise as seen from the camera.
- Holes: an outline whose shape has holes extrudes as one closed solid: the front and back faces are cut around the holes, an outer wall runs along the outer contour and an inner wall along each hole, and the
pocket: a cut into another object’s front face:into = "body",outline,depth(the floor’s absolute depth, on the same axis as the slab’sfrontandback),rim = { width, segments }rounds the pocket’s top edge,floor = { width, segments }fillets its floor. Pockets take the place of Blender’s boolean cutters; they are built in 2D and never through a 3D boolean. A pocket’s outline is in its target’s outline frame; its ownat,rotateandscaleare ignored, and its target must be anoutlinethat resolves to one shape, holes and all. A pocket that reaches past its target’s outer edge or into one of its holes is refused, naming the pocket. A pocket’s own outline has no hole.lathe:profile = [[r, y], …]revolved around Y,segments = 128,weld = 1e-7.sweep:path = [[x, y, z], …],radiusorradii = [r, …],ring = 16,closed,caps. Beads and tubes. Caps are n-gon fans, as Blender’s.along = { outline = <outline>, offset, depth }replacespath. The ring is swept along an outline in the plate frame, pushed out byoffsetalong the outline’s outward normal, atdepth. It is closed and capless, with oneradius. The ring starts on the outward normal and turns toward the camera: a lip round a pocket, a bezel round a slot. Its outline is one contour, with no hole.
mesh:path = "file.glb", optionalnode. Imported geometry, glTF only. Withoutnode, every node of the file’s scene is read into the one object, with its transforms applied, so a GLB of many parts is one mesh. It keeps the file’s material names, one slot per material, unless the object setsmaterial; its normals come from the object’ssmoothorsmooth_angle, and it is flat without either.generator:generator = "app/birds",params = { … }. Built by a function an app registers under that name, in the app’s build binary, which callsmanfred::cli::mainwith its registry and behaves asmanfreddoes (docs/apps.md). Manfred itself registers none. The window builds it through the same registry and lets you place it.- The generator gets the object, its
params, the recipe’s folder and a seed: the recipe’sseedmixed with the object’s index in the file (manfred::build::seed), so two objects never share one. - It returns one or more pieces, each a named mesh; each becomes a node with the piece’s name. A piece may share its own object’s name, never another object’s or another piece’s.
- The object’s transform,
parent,keep_transform,shadow,two_sidedandhiddenapply to every piece. The pieces keep their own material slots, normals and UVs unless the object setsmaterial(or one name inmaterials),smoothorsmooth_angle, oruv. - It may also return named materials in the engine’s material form, which merge under the recipe’s
[materials]: a recipe entry of the same name wins, andmanfred buildprints a line when the entry differs from the one the generator returned. Two generators returning one name with different values is an error naming both objects. They reach the GLB’s material names,manfred shot,manfred renderand the window as the recipe’s own do, but never the recipe file. A recipe with a[bake]refuses them as it refuses its own. - An unknown name is an error that lists the registered generators, or, when none is registered, says that the app’s own build binary builds it; a generator’s own error is reported under
object[<name>].generator.
- The generator gets the object, its
card:size = [w, h],image = "file.png"orslot = "name",face_camera = false,alpha_cutoff = 0.5. A flat surface with content; it casts shadows. The 2D and 2.5D building block.- The quad lies in the object’s XY plane facing +Z, and its UVs span the image: (0, 0) at the top left, (1, 1) at the bottom right. A card keeps these UVs whatever its
uvsays. The GLB carries the plain quad in the card’smaterial. imageis a PNG or JPEG path relative to the recipe, read by its first bytes.manfred shotand the window decode every card image once per build, pack them with the engine’sContentAtlasinto content slot 0, and give each card its crop, so the whole image lands on the quad. Cards that name one file share one cell. The image is the card’s albedo, laidOverits material: where it is opaque it replaces the material’s colour, where it is clear the material shows. A missing or unreadable image is an error naming the card.- The atlas is square,
Rgba8UnormSrgbwith premultiplied alpha and the engine’s 16-texel gutters (level 4, clean to 16× minification), and as small as holds the images: 256² first, doubling to at most 16384². No cards, no atlas. The window uploads it again only when an image’s texels change, not on every edit. slotnames an app’s live content, which Manfred cannot produce. The card shows a placeholder from the atlas instead: a 64² checker of 8-texel squares in two mid greys inside a 2-texel dark grey frame, opaque, so the card is seen and picked.alpha_cutoffgoes onto the engine instance (0.5 unless set). When it is above 0 and the image has a texel below it, the card stays one quad andInstanceSurface::cutoutdiscards where the content’s bilinear alpha, read at level 0, is below the cutoff, in the prepass, the opaque pass and the sun and local-light casters. The cut edge follows the image to the texel, and the cut part casts no shadow. A cutoff of 0, or an image with no texel below it, keeps the plain quad with no cutout.manfred rendercuts at the same authored cutoff.face_camera = trueturns the card each frame, in the window and inmanfred shot, about its own centre and its own up, so its front faces the camera: the eye’s position for a perspective camera, the view direction for an orthographic one. Its up stays the recipe’s; a camera straight above or below leaves it as authored.
- The quad lies in the object’s XY plane facing +Z, and its UVs span the image: (0, 0) at the top left, (1, 1) at the bottom right. A card keeps these UVs whatever its
text:spans = [{ text, family, size, weight, italic, color }],wrap,anchor,space = "surface" | "overlay" | "lit",on = "object". Static text only; what the apps print live stays in the apps. The scene draws it through the engine’s text, inmanfred shot,manfred renderand the window; the GLB carries only an empty node by its name, with no mesh.- Spans run on in one paragraph.
sizeis required and above 0: metres forsurfaceandlit, pixels of the still foroverlay; lines are 1.25 sizes apart.coloris linear RGB, white by default.weightruns from 1 to 1000, 400 by default and 700 bold.italicslants the face. familyis"mono"by default, JetBrains Mono, embedded in Manfred with its regular and bold faces. Anything else is a TrueType or OpenType file’s path, relative to the recipe; the font’s own family name picks it.wrapis the paragraph’s width, in metres or pixels assize. Without it each line is as long as its text and lines start flush left; with it, lines break at words and align by the anchor’s column.anchoris the point of the paragraph’s box that sits at the text’s place:"top_left","top","top_right","left","centre","right","bottom_left","bottom"or"bottom_right"."centre"by default, and"top_left"foroverlay."surface"text is flat and unlit, depth-tested against the scene. Withouton, it lies in its own XY plane at its transform (withparent), reading along +X with +Y up, facing +Z.on = "object"lays it on that object’s front face: the +Z face of its bounding box in its own frame, centred, reading along the object’s +X. Aplaneordisclies flat facing +Y, so its text lies on its top, reading along +X with the top of the text toward −Z. The text’s ownat,rotateandscalethen move it within that face, x across, y up and z out, and it is lifted 0.5 mm off the face.onandparentare not set together."lit"is surface text shaded by the scene’s sun, sky and lights."overlay"text is drawn over the finished image, in pixels. The anchor names both the screen’s point and the paragraph’s:"bottom_right"puts the paragraph’s bottom-right corner at the screen’s.at’s x and y move it in pixels, x right and y down, as plate pixels do; its z,rotateandscaleare not used.dynamicmarks live text: what the app changes or moves is drawn over a plate, and static text is baked into it. Text on a dynamic object (byparentoron) is dynamic too; without the key, the rest is static.- In the window, C2 shows a selected text object’s spans: each span’s text, size and colour. A finished edit, Enter or the focus leaving a field, or the release of a dragged number, is one gesture; Esc in a span’s text puts it back.
- Spans run on in one paragraph.
Includes and uses
Section titled “Includes and uses”Recipes that share a camera, a sky, lights or parts keep them in files of their own: a rig file that recipes include, and part files that recipes place copies of. Both are plain TOML files beside the recipes, read on every build, so a change to the rig reaches every recipe that includes it.
manfred = 1name = "panel"include = ["rig.toml"]
[sky]intensity = 1.5
[[object]]name = "board"kind = "box"size = [0.2, 0.1, 0.01]
[[use]]file = "parts/knob.toml"prefix = "left_"at = [-0.05, 0.05, 0.0]
[[use]]file = "parts/knob.toml"prefix = "right_"parent = "board"at = [0.05, 0.0, 0.005]rotate = [0.0, 0.0, 45.0]scale = 1.5The rules follow the engine’s scene.toml include where they fit (pfx’s docs/scenes.md):
- Paths are relative to the file that names them: an
includeor a use’sfile, and a sky’spath, a card’simage, a mesh’spathand a span’s fontfamilyinside an included or used file. An absolute path is refused. A"${params.…}"inside one reads the recipe’s own params, and a_pxkey the recipe’s own[plate]. include = ["rig.toml", …]merges each file’s tables into the recipe, in order and depth first: an included file’s own includes come before it, and every included entry comes before the recipe’s own. A file is included once per recipe; a second include of it is refused, and so is a cycle, naming the chain (a.toml → b.toml → a.toml is a cycle). A missing file is an error at the include that names it:include: cannot read nope.toml: no such file.- What an included file holds:
[[object]],[[camera]],[[light]],[[mover]],[[use]],[sun],[sky],[look],[materials.<name>], its owninclude, and optionallymanfred.name,seed,params,[plate],[output], variants,[sidecar]and[bake]belong to the recipe and are refused in an included file. - Names: an object, camera, light, mover group, non-empty use prefix or material is defined once across a recipe and the files it includes and uses. A second definition in another file is an error naming both files:
light[lamp]: `lamp` is defined in rig.toml and in panel.toml. The recipe overrides an entry by changing it in the file that holds it, never by defining it again. - Tables:
[sun],[sky]and[look]are set by one included file at most (two siblings setting one is refused, naming both), and the including file’s own keys win over the included ones key by key, through nested tables such as[sky.prepare]and[sky.room]. Above, the panel keeps the rig’s whole sky and changes only its intensity. An array or an inline table is one value and is replaced whole. - A problem in another file is reported at the recipe’s line that brings the file in, with that file’s own name and line before the key:
manfred check panel.tomlprintspanel.toml:3: rig.toml:24: light[lamp].colour: unknown key …, andRecipe::origin_ofgives the file and line of any key.
[[use]] places a copy of another recipe file’s objects. It takes the place of concatenating part files with a shell script and renaming objects with sed.
| Key | Meaning |
|---|---|
file |
the part file, relative to the file that holds the use. It may be a whole recipe that opens and builds on its own: only its objects are copied, after its own includes and uses; its cameras, lights, sky, movers, variants and the rest stay its own |
prefix |
put before every copied object’s name, and before every parent, pocket into and text on inside the copy, so the copy’s references stay inside the copy. Without it (or ""), the copy keeps the part’s names: one placement of a part whose names are already its own |
at, rotate, scale |
the copy’s placement, as an object’s (at_px under a [plate] too). It composes with each copied root object’s own placement as T·R·S times the object’s, so a root at at = [0.1, 0, 0] in a use at [1, 0, 0] turned 90° about Y lands at [1, 0, −0.1]. A turn about the same single axis adds degrees; any other pair of turns is converted back to XYZ Euler degrees. A scale that differs between axes places only unrotated objects and is refused otherwise, since the result would shear |
parent |
an object of the using recipe: every copied root object takes it as its parent, so the placement is in that object’s frame |
- A root is a copied object without a
parentin the part; children keep their own placement in their parent’s frame. Pockets, textonan object and overlay text follow their target and take no placement. - Order: the copies follow the recipe’s own objects, use by use, each in the part’s order.
Recipe::useslists every use with the names of the objects it placed;Recipe::copy_of(name)finds the use behind a copy. - Uses nest: a part may use other parts; their copies are copied again with the outer prefix before the inner one (
m_a_bit), and the placements compose level by level. A part that uses itself, directly or not, is a cycle. - After the copy, the copies are objects like any other: variants
dropthem by glob,--set object.left_cap.radius=0.01changes one copy, and--set use.left_.at=[0, 0, 0]moves a whole copy. A variant or--setreplays on the merged recipe, includes and copies included. - Refused: a use without
file, a part with no[[object]], a copy whose name another object already has (naming both files), and aparentthat names no object of the recipe or one of the use’s own copies (manfred check).
In the window, a recipe with includes and uses opens, edits and saves as one recipe:
- Write-back: each edit goes to the file that holds the key. A light from
rig.tomlmoved in the window is written intorig.toml;[sky] intensity, which the panel overrides, goes into the panel; a key new to an included table goes to the file that holds the table. A new object, camera or light goes into the recipe itself. Undo puts an entry deleted from an included file back into that file at its place. A file the window did not change is never written, and an included file changed on disk since the window read it is re-read, and the outside change wins, as the recipe’s own does. - Copies show in the outliner as the objects they are, under their prefixed names (
left_base,left_cap), and are picked, framed and shown like any other. They are read-only: an edit to one is refused with a sentence that names the use and its file (`left_cap` is a copy that use[left_] places from parts/knob.toml; change the part in parts/knob.toml, or move every copy of it by the use's at, rotate and scale). The[[use]]entry itself is editable by its prefix (use.left_.at), or by its place when it has none, and moving it moves the whole copy. Files::pathslists every file a recipe read, the recipe first, for a caller that watches them.
An object carries one UV set, written as TEXCOORD_0 exactly as the app reads it: u grows right, v grows down the image, as glTF stores it. Blender’s v is flipped on export, so a Blender UV (u, v) is (u, 1 − v) here. An object that asks for a detail layout carries a second set, TEXCOORD_1, in the same convention.
"plate"projects every face’s corners through the plate’s camera onto the[plate]:uacross its width,vdown its height, with the camera’sshift. A face whose normal points away from the camera (seen from its centre) takes the sentinel[0.0005, 0.0005], Blender’s(0.0005, 0.9995)after the flip. Vertices split where neighbouring faces disagree.{ kind = "rect", rect = [x0, y0, x1, y1] }, orrect_pxunder a[plate], maps a rectangle of the plate frame onto the atlas:sruns from x0 to x1,tfrom the top edge down, and the UV isatlas = [u0, v0, u1, v1]at(s, t): a photo, a screen, a printed card.atlasdefaults to[0.0, 0.0, 1.0, 1.0].facing = 0.5: a face takes the rect when its normal’s z, toward the camera’s side, is above this; every other face takesaway.clamp = false: true clampssandtto 0..1.away = [-1.0, -1.0]: Blender’s(-1.0, 2.0)after the flip.
{ kind = "cylinder", degrees = 360, atlas = [u0, v0, u1, v1] }wraps the object around its own Y axis, in its own frame before its transform:sis the angle from +Z toward +X, withdegreesat the middle of the atlas, andtruns from the top down: a printed vase or mug wall.band = [y0, y1]:truns from 0 at y1 to 1 at y0, clamped. Without it, over the object’s height, unclamped.wall = [y0, y1]: only faces whose normal points away from the axis and whose centre lies strictly between these heights take the wrap; every other face takesaway. Without it, every face does.away = [-1.0, -1.0].
uv = { kind = "rect", rect_px = [1300, 160, 1500, 960], atlas = [0.78125, 0.0, 0.9765625, 0.9765625] }uv = { kind = "rect", rect_px = [1100, 570, 1400, 710], facing = 0.0, clamp = true, atlas = [0.0, 0.0, 0.25, 0.125] }uv = { kind = "cylinder", degrees = -30.0, atlas = [0.0, 0.5, 0.5, 0.25], band = [0.004, 0.09], wall = [0.002, 0.098] }Detail UVs (TEXCOORD_1)
Section titled “Detail UVs (TEXCOORD_1)”detail_uv = { texels_per_m = 400 } on an object adds a second UV set, written as TEXCOORD_1 beside its TEXCOORD_0, which stays exactly as it was. It is a unique layout: no two points of the surface share a texel, so the engine’s detail bake (pfx bake --detail, the engine’s docs/bake.md) can bake the object’s procedural layers into maps. An object without the key writes no TEXCOORD_1, and its bytes don’t change.
- Charts: the faces are grown into planar charts in face order. A chart takes a neighbouring face across a shared edge while that face’s normal is within 8° of the chart’s first face and its projection overlaps none of the chart’s faces. Each chart is projected along that first normal, turned to its smallest bounding rectangle and laid wide.
- Scale:
texels_per_mis texels per metre of surface, in the world frame afterat,rotate,scaleandparent. The map is the smallest power of two from 64 to 4096 texels across that the charts pack into, and one metre spanstexels_per_m / sizeof the UV tile. The 8° limit keeps every face within 1% of the asked density. That map size is the texels across the tile that the engine’s[detail.view]sizing counts, so it can be compared with the sizepfx bake --detailprints for the part. - Packing: charts are packed in shelves, tallest first, with
guttertexels (16 by default, at most 64) between charts and around the edge of the tile. A layout that doesn’t fit 4096 texels across is an error namingdetail_uv, which asks for a lowertexels_per_m. - Seams: a vertex splits where two charts meet. TEXCOORD_0 is unchanged at every corner.
- Refused:
detail_uvwithuv = "none", on apocket(it goes on the pocket’s target) and ontext.texels_per_mis above 0. - Checked:
manfred diffmarks a node whose TEXCOORD_1 appears or goes (uvs1 false -> true) and a change in its layout’s area use, the share of the tile its charts cover (uv1 area use 0.00% -> 70.28%).
The toybox’s backdrop (tests/golden/toybox/) carries detail_uv = { texels_per_m = 400 }: its 1.6 by 1.0 m plane is one 640 by 400 texel chart in a 1024 map. The 16-texel gutter is the engine’s rule: the detail bake dilates 8 texels past each chart and keeps five mip levels, so 16 keeps mips 0 to 4 from mixing neighbouring charts.
Cameras, sun, sky, lights, movers
Section titled “Cameras, sun, sky, lights, movers”[[camera]]name = "main"kind = "perspective"position = [0.0, 0.12, 1.6]target = [0.0, 0.12, 0.0]up = [0.0, 1.0, 0.0]fov_y_deg = 13.3778shift = [0.0, -0.6395]focus_m = 1.6fstop = 0.0
[[camera]]name = "plan"kind = "orthographic"position = [0.0, 10.0, 0.0]target = [0.0, 0.0, 0.0]up = [0.0, 0.0, -1.0]ortho_height = 2.2- A perspective camera takes
fov_y_deg, orfocal_mmwithsensor_mm, the sensor’s longest side in millimetres, fitted to the frame’s longer side as Blender’s automatic sensor fit does.shiftis a fraction of the half-height, positive up and right; an app’s writer converts it to whatever its loader reads. - The window’s own orbit camera is not in the recipe.
A camera may carry a path, which manfred clip follows (docs/render.md, clips). It is a table under its [[camera]]: [camera.path] belongs to the camera written last above it.
[camera.path]kind = "orbit"target = [0.0, 0.03, -0.02]turns = 1.0rise_deg = 10.0radius_scale = 1.0ease = "linear"[camera.path]kind = "keys"ease = "smooth"
[[camera.path.keys]]t = 0.5position = [0.4, 0.3, 0.5]target = [0.0, 0.03, -0.02]focal_mm = 35.0
[[camera.path.keys]]t = 1.0position = [0.0, 0.6, 0.1]target = [0.0, 0.0, -0.05]kindis"orbit"or"keys"; each takes only its own keys.easeis"linear"(the default) or"smooth"(in and out,3t² − 2t³), and shapes the whole path’s time.- Orbit: the camera turns about the vertical line through
targetand looks attarget.turns(any number; positive turns counter-clockwise seen from above,0.5is half a turn) is required.rise_deg(0) raises the camera’s elevation above the target by that many degrees over the path, from where the camera stands; the elevation at the end stays between −90 and 90.radius_scale(1, above 0) scales the camera’s distance from the target. Withradius_scale = 1and the camera’s own target, the path starts on the camera’s own pose. A camera straight above or belowtargethas nothing to turn from and is a problem. - Keys: each key is a time
tfrom 0 to 1, after the key before it, apositionand atarget, and optionallyfocal_mm. Position, target and focal length run straight from key to key; past the last key the camera holds it. Without a key at 0, the camera’s own position, target and lens are the key at 0. A key’sfocal_mmneeds a perspective camera with its ownfocal_mm(a key without one takes the camera’s). - Time runs from 0 to 1 over the clip whatever its length. An orbit’s frames share the loop, frame
iofNati / N, so a whole number of turns loops without a repeated frame; keys end on their last key, frameiati / (N − 1). --setreaches the path’s values:camera.main.path.turns=2.
[sun]model = "authored"toward = [0.3, 0.8, 0.5]radius_deg = 2.5color = [1.0, 0.97, 0.92]irradiance = 3.0
[sky]kind = "hdr"path = "assets/sky/window.hdr"source = "made by the app, no licence"rotation_deg = 169.4intensity = 4.2[sky.prepare]cap = 60.0balance = [1.0, 0.92, 0.8]mean = 0.3[sky.shade]hours = { 6 = [0.6, 0.6, 0.7], 12 = [1.0, 1.0, 1.0], 20 = [0.8, 0.6, 0.4] }[sky.room]width = 1024floor = [0.2197, 0.1352, 0.0676]wall = [0.19, 0.13, 0.07]ceiling = [0.2845, 0.2276, 0.1593][[sky.room.lights]]name = "window"az = -64.0el = 14.0width = 37.5height = 29.0power = 10.585color = [1.0, 0.86, 0.66]soft = 0.05slats = 9.0open = 0.62[sun]is one table with amodel, as the scene file’s is:model = "authored":toward,colorandirradianceare the light itself, andradius_deg(0.27) its disc. The window and the stills turn and cool it with the hour they light at; the sidecar’ssuncarries it as written.model = "daylight": the sun of a place and a time,hour(0 to 24),day(172),latitude,heading, andreference_hour(16.0 only, or left out):[sun]model = "daylight"hour = 9.5day = 172latitude = 44.4heading = 0.0- Without a
[sun]the stills use the solar sun of the reference hour. The sidecar’sdaylightis written as before.
[sky]mirrors the engine’s bake recipe keys, so the bake sees the sky the stills see. Every HDR has asourceline.kindis one of four:hdrdraws the file atpath. A[sky.prepare]ofcap = 60.0andmean = 0.3prepares it as the engine’s daylight preparation does: luminance capped at 60, balanced towardbalanceat constant luminance whenbalanceis set, and the mean scaled to 0.3. A[sky.prepare]ofbalancealone balances the file.capandmeango together and are those numbers, which is the one preparation Manfred has; any other value is refused namingsky.prepare. The table needs one of its three keys.roomdraws a lamp room from[sky.room]through the engine’s room sky. It needs[sky.room]and refusespathand[sky.prepare]. By default the room is the same at every hour.ambient = "authored"scales it by the authored sun’s hourly ambient, from the recipe’s authored[sun](Manfred’s default authored sun when there is none), aspfx bakescales each anchor: live, the room exposed by that ambient. Onlykind = "room"takesambient, and"authored"is its only value. No sidecar key carries it.mix: the prepared HDR plus the room’s lamps alone, atfill(0.25) of the HDR’s mean luminance. It needspathand[sky.room]. Only a mix takesfill. A mix is never baked.analyticis the engine’s clear sky with the sun;intensityscales its ambient.
- Then, for every kind, the sky turns by the negative of
rotation_deg(default 0) and is scaled byintensity(default 1). Live, a room sky is the engine’s room turned by-rotationand exposed byintensity, aspfx bakebakes it. [sky.shade]tints the sky by hour, interpolated between the hours given.[sky.room]is the lamp room, in the engine’s room keys and defaults. An unknown key is refused.- The room:
widthis the room texture’s width in texels, not metres (1024, from 2 to 8192; the sky iswidth×width / 2).floor([0.16, 0.10, 0.06]),wall([0.30, 0.24, 0.18]) andceiling([0.55, 0.47, 0.38]) are linear RGB: a blend from wall to floor below the horizon and from wall to ceiling above it. - The lamps: each
[[sky.room.lights]]takesname(optional),az(0) andel(30) in degrees, with azimuth 0 along +Z and positive turning towards +X.power(4) multipliescolor([1.0, 0.9, 0.78]).width(30) andheight(20) are angular sizes in degrees, above 0 and below 180.soft(0.08) is the edge falloff as a fraction of the half extent.slats(0, none) bands the lamp across its height like blinds, withopen(1) of each band lit. - Beside an HDR: a recipe may carry
[sky.room]withkind = "hdr". It is not drawn then, and still reaches the facts assky.room, for an app’s loader that draws its own room. - The draft’s
[[sky.light]]is gone. It is refused with a pointer to[[sky.room.lights]].
- The room:
[[light]]name = "lamp"position = [0.2, 0.3, 0.1]color = [1.0, 0.8, 0.6]intensity = 20.0radius = 0.02range = 3.0shadow = true
[[mover]]group = "knob"pivot = [0.1, 0.05, 0.0]axis = [0.0, 1.0, 0.0]travel = 0.003clip = [0.0, 0.0, 1.0, 0.0]- Lights are the engine’s point lights, four at most today.
- A mover’s parts are the objects named
mv_<group>andmv_<group>__<part>, or listed inparts. The facts carry pivot, axis, travel and clip planes as written. Two movers may not share agroup. - A mover’s keys:
group,pivot,axisandtravelare required;clipandclip2are optional;partsis optional.axisis not [0, 0, 0].pivotis in metres, in the recipe’s own axes.pivot_px = [x, y, z]reads x and y as plate pixels, asat_pxdoes, and z in metres, the depth along the recipe’s z (0 when left out).travelis the distance in metres.travel_px = 1is one plate pixel, which ismetres_per_pixel.clipandclip2are planes[nx, ny, nz, d]: a pointpwithdot(n, p) > dis cut away while the part moves.clip_px = [0, 1, 0, row]andclip2_px = [0, -1, 0, row]put the plane on a plate row: the normal must be [0, ±1, 0], anddis the normal’s y times the row’s height in metres. Soclip_px = [0, 1, 0, 151]cuts everything above row 151, andclip2_px = [0, -1, 0, 738]everything below row 738.- In the live view (
manfred shotand the window) every piece of every part of a mover carries its mover’sclipandclip2as the engine’s per-instance clip planes, in world space as written: the pieces are drawn at their rest pose, so the recipe’s axes are the world’s. A part is cut, its shadow too. A part two movers claim takes the first mover’s planes, as the extras do.manfred renderclips movers too (docs/render.md). The glass parts of a mover are not clipped live until an engine release clips glass surfaces per instance. - In the sidecar: the facts dialect writes
movers.<group>withpivot,axis,travel, andclipandclip2when the mover has them; an app’s writer writes them as its loader reads them.
Variants, look and bake
Section titled “Variants, look and bake”[variant.bare]drop = ["slat*", "wall_*", "vase", "coin"]set = { "sun.irradiance" = 0.0 }remove = ["light.lamp"]
[look]view = "graded"exposure = 1.0grade = { vignette = 0.2, saturation = 1.0, warmth = 0.1, bloom = 0.1, haze = 0.0 }manfred build --variant bare applies the variant before building.
dropremoves objects by glob.setandremovetake dotted paths, with entries of an array of tables addressed by name:camera.main.fov_y_deg,light.lamp.setmay add a missing last key but not a missing table.removeof a key that isn’t there is an error.- Sidecar keys are reachable like any other:
remove = ["sidecar.motion"]drops the[sidecar.motion]table below, andset = { "sidecar.motion.density" = 2.0 }changes a number in it.
A variant’s files. A variant writes its own GLB and sidecar, so building it never overwrites the recipe’s.
- The naming rule: the variant’s name goes before the last extension of the file name, in the same folder:
scene/toybox.glbandscene/toybox.jsongivescene/toybox-bare.glbandscene/toybox-bare.json. A recipe with nooutput.glbwrites<name>.glb, so its variant writes<name>-<variant>.glb. A variant has a sidecar only when the recipe does. - The scene: a variant writes its own scene when the recipe does, with its name before
.scene.toml(scene/toybox.scene.tomlgivesscene/toybox-bare.scene.toml), or before the last extension of another name. [variant.bare.output]overrides the rule withglb,sidecar,bakeandscene, each a path as in[output].- The bake: a variant writes no bake unless
[variant.<name>.output]givesbake. The recipe’s[bake]table stays in that case and goes with it. - Checked: two files of the recipe and its variants may not be the same path; the later one is the problem, reported as
variant.<name>.output.
manfred build <recipe> --all-variants builds the recipe and then every variant, in the variants’ name order, and prints each path it writes. --variant and --all-variants together are a line it cannot parse, and exit 2. --out <dir> puts every file in one folder, so the names have to differ there too.
Overrides on the command line. manfred build, manfred shot and manfred render take --set <key>=<value>, as often as needed, so a caller renders another input without writing a recipe: --set object.liquid.params.dump=/abs/tmp/lab/a.liquid --set sky.intensity=0.6.
- The key is a dotted path as a variant’s
settakes it, with an array of tables entry addressed by itsname:sky.intensity,camera.eye.fov_y_deg,object.liquid.params.width. - Strict: it replaces a value the recipe already has. A key the recipe does not carry or a table instead of a value is refused in one line that names the key, and the command exits 1:
manfred build: scene.toml: sky.intensty: --set: the recipe has no value at `sky.intensty`. A missing=is a line it cannot parse, and exits 2. - The value takes the kind of the one it replaces. A string is the text as given, with no quotes, so a path needs none; a number, a boolean, an array or an inline table is read as TOML and must be of that same kind (an integer and a float are both numbers). A
"${params.…}"string may be replaced by a number. - Paths in a value are relative to the recipe, as in the file; a caller in another folder passes absolute ones.
- Order: the variant first, then each
--setin the order given, then the usual checks. The recipe file is never written: the override lives only in that run (manfred_recipe::apply_set).
The sidecar table
Section titled “The sidecar table”[sidecar.motion]cup = [-0.2, -0.07, 0.12]radius = 0.02box_lo = [-0.3, -0.2, 0.0]box_hi = [0.3, 0.2, 0.2]density = 1.2[sidecar] holds keys the recipe has no table for, written into the sidecar as they are. It is a passthrough rather than a typed table on purpose: such a block is an app’s own contract with its loader, its keys change with the app’s code, and a typed table here would make every new key a Manfred release. manfred::runtime reads motion (a steam source, its box and density) from it. The numbers are in the recipe’s units and axes, the same ones the sidecar uses: nothing is converted.
- It deep-merges into what the writer wrote, after what generators returned, so it can add a key to
sunand a key of its own replaces a key the writer wrote. - Tables and arrays keep their shape; TOML integers stay integers. Like every Manfred sidecar, the keys come out sorted.
- A recipe whose
sidecar_dialectisnoneand which has a[sidecar]is a problem: there is no file for it.
[look] is Manfred’s own table (see “Spelling shared with scene.toml”). It reaches the facts, and the live renderer’s post chain reads the same keys. view is "graded" (the default) or "standard". exposure is above 0. grade takes vignette (0), saturation (1), warmth (0), bloom (0) and haze (0), an amount from 0 to 1. Above 0, the live renderer draws the room’s haze from it through the engine’s RoomHaze::from_amount.
[trace] holds the tracer’s own settings, which manfred render and a traced manfred clip pass to the engine’s Detail, and the scene’s [trace] carries; the recipe’s other tools ignore them.
transmissive_shadows(false by default): true passes the sun’s, the sky’s and the local lights’ shadow rays through glass and liquid, tinted as live tints them (Detail::transmissive_shadows).clamp_indirect(0, off): 0 to 10000, the luminance (Rec. 709 weights) that caps each light contribution reached after the first bounce, scaled down to it keeping its hue, as Cycles’ Clamp Indirect (Detail::clamp_indirect). Light at the first hit is never clamped.filter_glossy(0, off): 0 to 1, the least roughness of every vertex after a diffuse bounce, as Cycles’ Filter Glossy (Detail::filter_glossy), so a sharp lobe reached through a diffuse bounce stops throwing fireflies under a small sun while the chrome and glass the camera sees stay crisp. pfx suggests0.25, withclamp_indirectas the backstop (pfx’sdocs/trace.md, “Fireflies”).- A value outside its range is a problem at its line. Both off, every render keeps its bytes.
spp,thresholdandmin_samples(unset):manfred render’s adaptive sampling for this recipe, the cap (at least 1), the noise under which a pixel stops (0 or more; 0 runs every pixel tospp) and the samples every pixel takes first (at least 2). Each one unset takes the command’s default (64, 0.01, 16), and--spp,--thresholdand--min-samplesoverride them;--draftignores them. With the denoiser after the trace, a scene whose sunlit surfaces never reach 0.01 sets its own, such asthreshold = 0.05withspp = 128. A tracedmanfred clipand the window’s C3 keep their fixed samples.
The view. graded finishes a frame with the graded chain: exposure, bloom, vignette, saturation, warmth and the ACES tone curve, then sRGB. standard finishes it as Blender’s Standard view does, so a Manfred PNG can stand beside a Cycles one: linear radiance times exposure, clamped to [0, 1], then the sRGB curve, and nothing else (no tone curve, bloom, vignette, saturation, warmth or haze). It applies to manfred shot, the window and manfred render’s PNG alike; EXR and HDR stay raw.
- The grade is refused, not ignored. Under
standard, agradekey off its neutral value (vignette,warmth,bloomandhazeabove 0, asaturationother than 1) is a problem at that key, so a recipe never carries a grade that does nothing;grade = {}or the neutral values are fine, andview = "graded"takes them as before. - The facts carry
look.exposureandlook.grade;viewstays in the recipe.
[bake]anchors = [8.0, 12.0, 16.0, 20.0]samples = 16[[bake.volume]]name = "desk"min = [-0.5, 0.0, -0.5]max = [0.5, 0.5, 0.5]spacing = 0.25[bake] holds the keys the bake recipe needs and the rest of the recipe can’t fill. It is required when [output] bake is set, and refused without it.
anchors(required): the hours to bake, rising and distinct, from 0 to 24.samples(required): above 0.product(required): the name of the app the bake is for, any lowercase slug of letters, digits and-that starts with a letter or digit, as the engine names apps. Manfred keeps no list of apps, andpfx baketakes any slug; it names the bake and changes nothing in the light.[[bake.volume]]: at least one, each with anameof ASCII letters, digits,-and_, unique, plusmin,maxandspacing.max − minmust be a whole number of spacings on every axis.
[bake.plates]size = [3840, 2160]overscan = 0.1scale = 1.25anchors = [12.0, 16.0, 19.0]layers = ["color", "depth", "normal", "id", "sun", "transfer"]key = "desk-v3"dir = "bakes/desk/plates/desk"casters = "proxies"
[[bake.plates.view]]name = "desk"camera = "main"[bake.plates] asks pfx bake for baked plates: the static part of the scene path-traced once per anchor at the camera’s rest pose, with depth, normal and id layers, which the live renderer composites under what moves (pfx’s docs/bake.md, “Plates”, and docs/plates.md). It needs [output] scene, since the plate traces the scene file manfred build writes.
size(required):[width, height], the live frame in pixels, each 16 to 16384.overscan(0.1): 0 to 1, the margin the plate adds round the view for drift.scale(1): 0.25 to 4 plate texels per live pixel.anchors(the bake’sanchors): rising, distinct hours from 0 to 24; the scene’s sun is set to each.layers(all six): some of"color","depth","normal","id","sun"and"transfer", each once.coloranddepthare required, andtransferneedssunandnormal.threshold(0.01, above 0),min_samples(16, at least 2),max_samples(1024, at leastmin_samplesand at most 65536) andgrowth(2, above 1): the tracer’s adaptive sampling.seed(the bake’s seed): a whole number.key: Manfred’s own plate key, any string; pfx records it and hashes it into its key, so a new key re-bakes.clamp_indirectandfilter_glossy: the[trace]keys of the same names and ranges, for the plates’ colour passes alone. Either one given here replaces the scene’s (0 turns it off for the plates); left out, the plates take the scene’s.dir: the plate folderpfx bakewrites (<output>/plates/<view>), relative to the recipe. With it, the scene carries[plates]and a proxies file.casters("meshes"):"proxies"or"none", what shadows the moving parts in the static objects’ place; it needsdir.[[bake.plates.view]]: the views to bake, each aname(up to 64 ASCII letters, digits,-and_, unique) and any of the scene camera’sat,look_at,up,fov(orfocalwith an optionalsensor, not both),shift,nearandfar.cameranames a perspective[[camera]]that fills the keys the view leaves out, converted as the scene’s camera is, withnearandfarfitted to the scene’s bounds. Without a view, pfx bakes one namedmainfrom the scene’s camera, which must then be perspective.
The bake recipe manfred build writes:
- Top-level keys:
scene, the GLB;app, the recipe’sproduct;materials = "bake-materials.toml"when the GLB names a material;anchors,samples, andseed, the recipe’s seed cut to 32 bits. A daylight[sun]’sday,latitudeandheadingare written when it is set, andshadow_only_nodeslists the objects withshadow = "only". - The materials:
bake-materials.toml, beside the bake file, is the engine’s material library (pfx_materials::Library) of every material the GLB’s shown parts name, each from the app’s registered catalogue. A name the catalogue lacks is an error namingmaterials.<name>, so pfx never bakes a part in its default grey. - The sky and the sun:
[sky]comes from the recipe’s sky in the engine’s bake keys of v0.19:kindisdaylightfor an analytic sky,hdrorroom. A recipe’s[sky.prepare]ofcap = 60.0andmean = 0.3is written as it stands with itsbalancein the ordercap,balance,mean, the chain the stills prepare the HDR with, and one ofbalancealone is a[sky.prepare]ofbalance.[sky.shade]gives each anchor its interpolated tint, and[sky.room]is written only for a room sky.[sun]ismodel = "authored"with the sun’stoward,colorandirradiance; the bake has no sun radius. Without an authored[sun]the bake takes the solar sun of the recipe’s daylight[sun], as the stills do. - The hourly ambient:
ambient = "authored"scales each anchor’s sky by the authored sun’s ambient, as the stills scale it. It is written for an HDR sky under a[sun], and for a room sky withambient = "authored", which then needs a[sun]. - Volumes and emitters:
[[volumes]]come from[[bake.volume]], and each[[light]]becomes a sphere[[emitter]]with its position, radius, colour and intensity. - Plates:
[bake.plates]is written last as[plates]in pfx’s keys and order, withscene, the scene file, and every other key only where it differs from pfx’s default (anchorsandseedwhere they differ from the bake’s own,clamp_indirectandfilter_glossywhenever set). Each view is a[[plates.view]]; a view with acamerawrites that camera’sat,look_at,up,fov, ashiftother than zero,nearandfar, and its own keys win.dir,castersand the proxies are the scene’s, not the bake’s. - Paths: every path is relative to the bake file’s folder, and pfx reads nothing outside it. So the bake file goes in a folder that holds the GLB, the HDR and the plates’ scene:
assets/bake.tomlbesidescene/toybox.glbandsky/window.hdr, not inassets/scene/. A path outside the folder is an error namingoutput.glb,sky.pathoroutput.scene. - Refused: what the bake schema can’t express is an error that names the key:
- a
mixsky (sky.kind). A mix is never baked: the bake’s emitters are glTF nodes or spheres, not lamps at infinity, so bake underhdrorroom; - an analytic sky with an
intensityother than 1 or arotation_degother than 0; - a
[[light]]with radius 0; - an object with
shadow = "none"; - a recipe’s own
[materials.<name>], and a material the app’s catalogue lacks (materials.<name>): the bake’s library holds the app’s catalogue alone; - a room sky with
ambient = "authored"and no[sun](sky.ambient): the stills then scale it by Manfred’s default authored sun under the daylight sun, which the bake can’t express.
- a
The scene
Section titled “The scene”The recipe is Manfred’s authoring layer; scene.toml is the engine’s runtime data, which a viewer opens with pfx_load::scene::Scene::open, stages live and traced, and watches (the engine’s docs/scenes.md). [output] scene writes it beside the GLB, with the materials in a library named after it: toybox.scene.toml names toybox.materials.toml. It is scene format 1 by default (“Format 1” below); scene_format = 0 writes the engine’s older format, which the engine reads through pfx-scene’s migration. The bullets below say what both formats hold, in format 0’s words; “Format 1” says how format 1 writes the same. In format 0 every path is relative to the scene file, and stays inside the scene’s project root (the nearest folder up from the scene that holds a project.toml, else the scene’s own folder), since pfx-scene refuses a path that leaves it; Manfred refuses one naming its key. The same recipe writes the same bytes. Manfred’s own table and toybox scenes have goldens in both formats: format 1 in crates/build/tests/scene/format1/, format 0 in crates/build/tests/scene/.
- Meshes and objects: each node of the GLB is a
[mesh.<node>]withfileandnode, and an[[object]]of the same name.idis the recipe object’s place plus one, the pick id Manfred’s window uses, shared by a generator’s pieces. Manfred bakes an object’s transform into its mesh, so the object writesat,rotateandscaleonly forkeep_transform: the recipe’s own, or with aparentthe composed placement (a shear is refused atobject[<name>].parent). Under[output] hierarchy = truea node’s descendants are hidden in its mesh, since they are objects of their own. An object of one material writesmaterial,shadowwhen not"cast",two_sidedwhen set, and a mover’s part itsclipandclip2asclip. Hidden objects are not in the GLB, so not in the scene. - Materials: every material the GLB’s shown parts name, from the app’s catalogue, then the recipe’s
[materials]and the generators’ (as the scene draws them). A name in none is an error namingmaterials.<name>. - Cards: a card’s image is a
[content.<card>], and the card’s material is a copy named<material>+content<slot>with the engine’s content layer on that slot, laidOver, one slot per image, four at most. A slot card keeps its plain material: the app paints its live content. - Text: a
textobject with one span is a[text.<name>]: the span’s words, its font (the embedded mono, written beside the scene asJetBrainsMonoNerdFont-Regular.ttfor-Bold.ttfby weight, or the span’s own file), its size times the placement’s uniform scale, its colour with alpha 1, andatandrotatefrom its placement,onincluded. - The sun: an authored
[sun]ismodel = "authored"withtoward,color,irradianceandhour = 16.0, the sun as the still draws it at its own hour.[daylight]ismodel = "daylight"withhour,day,latitude,headingandreference_hour = 16.0, Manfred’s reference. With neither, the daylight of Manfred’s default hour, 16, at the engine’s day, latitude and heading.radiusis the recipe’sradius_degfor an authored sun, 0 to 10 (refused atsun.radius_degoutside it), and 0.27 for a daylight sun. An authored sun also writesreference_hour = 16.0, so it is exactly as authored at hour 16 and follows the hour as the still’s does. - The sky:
analyticwrites the sky’sintensityas the analytic sky’sambient(times the daylight’s ambient under[daylight]); itsrotation_degis not used, as live.hdrwritespath,rotation_deg, and[sky.prepare]as the bake does (cap 60, the balance, mean 0.3).roomwritesrotation_degand every[sky.room]and lamp key. Forhdrandroom,intensityis the recipe’s times what the still multiplies the texels by at its hour: the[sky.shade]tint and the hourly ambient (under[daylight]for an HDR, and for a room withambient = "authored"). Without[sky], an analytic sky, as the still draws. Amixwriteskind = "mix"with two[[sky.layer]]: the prepared HDR (path,rotation_deg,intensityas forhdr, and its[sky.layer.prepare]) and the room’s lamps alone, aroomlayer withfloor,wallandceilingblack, itswidththe HDR’s, the samerotation_degandintensity, and aweightoffill(0.25 unless set) times the prepared HDR’s mean luminance over the lamps’ own, the gain the still’s mix applies, worked out from the file when the scene is written. The engine bakes the layers into one HDR at the HDR’s width: Manfred’s own mix. A mix with no lamps writesweight = 0.0, the HDR alone. - Lights: each
[[light]]with every key:rangeresolved (sqrt(intensity / 0.01)when 0) andshadowwritten, since the scene’s defaults differ. - The camera:
projection,at,look_at,up,fov(vertical, fromfocal_mmat the plate’s aspect, or at 1586 × 992 without a plate) orheight,nearandfaras the still fits them to the scene’s bounds, andshiftwhen it isn’t[0, 0]. A perspective camera withfstopabove 0 writesfocal,sensor(the vertical height, so the engine’s fov is the camera’s),fstopand, when set,focus(focus_m, the distance from the eye along the view, which is the engine’sfocus) in place offov; afocus_mat or below the focal length is refused atcamera[<name>].focus_m. - The haze:
look.grade.hazeabove 0, underview = "graded", is[haze]withlo,hi(Manfred’s room),amount,phase = 0.35andback = 0.7: the engine’s room haze, the same one the still draws. The tracer draws none. - The finish:
[look]as the still’s chain, in the still’s own order:exposurealone forview = "standard"; forgraded(and without[look]) one[[finish.pass]]each forexposure, a boxbloomwith the still’s shape, a powervignette(2.4, 0.8, 1.28),saturation, linearwarmthand the ACEStone. The engine orders inline keys by name, which puts the warmth after the tone where the still has it before, so the passes are listed in[[finish.pass]], which the engine applies in the order written. - Trace:
[trace]is written when one of its keys is on:transmissive_shadows = true, andclamp_indirectandfilter_glossywhen above 0. Off is the engine’s default and is not written, so a recipe without them writes no[trace]. - Dynamic: an object writes
dynamicwith the recipe’s own value when it sets one. Without it, a mover’s part and the child of a dynamic object writedynamic = true, since the scene carries neither the recipe’s movers nor its parents; a card that faces the camera writes nothing, as pfx derives it, and every other object is static. A text writesdynamic = truewhen it is dynamic, its own key or its carrier’s. - Plates: with
[bake.plates] dir,[plates]follows[trace]withdir(relative to the scene),proxiesandcasterswhen not"meshes". The proxies file is written beside the scene, named after it (desk.scene.tomlnamesdesk.proxies.toml): one[[proxy]]per static object of the scene, in its order, withobject, the scene object’s name. Aboxis the object’s world bounds,attheir centre andsizetheir extent, at least 1 mm on each axis, with norotate; ahullis the convex hull of the object’s world vertices,pointsandtrianglescounter-clockwise from outside, and an object whose hull holds no volume is refused atobject[<name>].proxy. Proxies are never drawn: they shadow the moving parts undercasters = "proxies"and answer CPU picks with the object’s pick id. - Objects: a card with an image writes
alpha_cutoff(the engine’s cutout, as the still cuts: the texels of the image below it are dropped); a card that faces the camera writesface_camera = true. The engine turns the card about its object’s origin, with its+Yalong the camera’s up, where the still turns it about its own centre and keeps the recipe’s up, so a card that faces the camera needskeep_transform = true(the card’s own origin is its centre) and is refused atobject[<name>].keep_transformwithout it; a card that is not upright to the camera’s up turns differently in the two. - Left on the authoring side, and listed in one note of
manfred build: movers, which say where a part can go in the app’s own axes and units with no kind, unit or motion, so the scene’s preview[[mover]]has nothing to take (their clip planes are written on their objects); variants,[sidecar]and its motion, camera paths, hidden objects (built and not exported, so the GLB has no node for them, and writing them would draw them in the apps), and slot cards’ live content. - Format 1 (the default,
[output] scene_format = 1) writes the same scene as pfx-scene’s format 1 (pfx-scene’sdocs/format.md, at the rev the rootCargo.tomlpins), through its types. The engine’sScene::open,SceneWatchandStagedread it through pfx-scene’sProject::scene, and so does Manfred’s window.format = 1heads the scene and its material library, and each key is in the order pfx-scene’s tables list them: meshes by name, then objects, contents and texts, then the sun, sky, haze, camera, finish and trace. Materials write every key, as format 0 does.pickis the object’s pick id, where format 0 wroteid.- Every mesh, object, content, text and material has an
id:Id::deriveof"<recipe>\n<kind>\n<name>", the recipe’s project-relative path, the entry’s table key (mesh,object,content,textormaterials) and its name. An entry made from an object with its ownidderives from"<id>\n<kind>\n<n>"instead, wherencounts the object’s parts from 0, so its ids keep across a rename of the object. An id already taken gets"\n1","\n2", … appended to its key, as pfx-scene’s migration does. The same recipe writes the same ids. The first save in the window gives an object without anidits own, the object entry’s derived id, so the object entry and its pick keep across the save; the object’s mesh, content and text entries then derive from thatidand change once, and keep across every rename after it. [authoring]holdsrecipe, the recipe’s path from the project root, andvariantwhen the build applied one, in the scene, its library and its proxies file.- The plate keys are pfx-scene’s, written through its types: an object’s
dynamicand a text’sdynamicafter their other keys, and[plates]after every other table but[authoring], withdirandproxiesrelative to the root. The proxies file starts withformat = 1, and each proxy has anid, derived as the object’s other entries’ are, under the kindproxy. - Every path (the library, the GLB, an HDR, a card’s image, a font) is relative to the project root: the nearest folder up from the scene that holds a
project.toml, else the scene’s own folder. A path that would leave the root is refused, naming the key (output.glb,sky.path,object[<name>].image,object[<name>].spans) and the file. The recipe’s own path may leave it: it is provenance, not a path the scene reads.
- Refused, naming the key: a
[sky.shade]coloured at the drawn hour (sky.shade); more than four lights (light); a card that faces the camera withoutkeep_transform(keep_transform), shows a JPEG or a fifth image (image); overlay text (space); text of more than one span (spans) or of an uneven scale (scale).
Extras
Section titled “Extras”The GLB carries what a loader needs about movers and shadows in glTF extras on its nodes, so a loader can read them without the sidecar. The extras are additive: the sidecar is written exactly as before, and a GLB without extras loads as it always did.
{"name": "mv_knob", "extras": {"manfred": {"axis": [0.0, 0.0, -1.0], "group": "knob", "pivot": [0.2682219419924338, 0.15170239596469104, 0.008], "travel": 0.003}}}{"name": "mv_knob__cap", "extras": {"manfred": {"group": "knob"}}}{"name": "shade", "extras": {"manfred": {"shadow": "only"}}}- Where: only on nodes, and only inside one object named
manfred, so an app’s own extras keys never collide with them. A node with nothing to say has noextrasat all. Meshes and materials carry none. Keys are written sorted, numbers as the sidecar writes them. group: on every part of a mover, the mover’sgroup. A part is an object the sidecar rule counts:mv_<group>,mv_<group>__<part>, or one listed in the mover’sparts; every piece of a generator object counts. An object two movers claim goes to the first mover in the recipe.- A mover’s root: the first visible part of the mover, in the recipe’s file order, also carries
pivot,axisandtravel, andclipandclip2when the mover has them. The root is a convention and not a parent node, even when[output] hierarchy = truenests the GLB; it is the one node per group that haspivot. The numbers are the onesmovers.<group>gets in the facts, in the recipe’s own axes and units: nothing is converted. A mover whose parts are all hidden writes nothing. shadow:"only"or"none"when the object’sshadowis not the defaultcast, on each of its nodes. The GLB has no other place for it.two_sidedis not written, because every Manfred material is already double sided in the GLB;hiddenobjects are not in the GLB at all;keep_transformis the node’s own matrix.- Not written: anything that is only the builder’s: seeds, generator names and params, UV and bevel settings,
parent. - Dialects: the extras are written whatever the dialect,
noneincluded. manfred diff: compares extras key by key, with nested keys joined by dots, and marks a difference under the node’s name:extras manfred.travel 0.003 -> 0.004, orextras manfred.group only in a. A node that has extras on one side and none on the other lists every key as only on the side that has it. When one file carries no extras at all, as a Blender export doesn’t, extras are left out of the diff.- The runtime library:
manfred_runtimereads onlyextras.manfred, and a GLB a node of which still carries the older keyextras.manfis anError::Glbthat names the new key; rebuild it withmanfred build. It takes the movers from nodes whosemanfredhas apivot. When the sidecar has movers too, the two must agree group for group, number for number, and a group that differs or is on one side only is anError::Sidecarnaming it. With no sidecar movers the extras are used alone; with no extras the sidecar is. A root without agroup, or two roots of one group, is anError::Glb.Scene::groupskeeps each object’sextras.manfred.groupbesideScene::objects, andScene::group_of(object)gives that group, ormover_group(name)when the GLB has none for the object, soScene::mover_offinds a part the recipe lists in a mover’spartswithout themv_prefix; a GLB without extras finds parts by their names as before.
Defaults and requirements
Section titled “Defaults and requirements”Where a key is absent, the recipe crate takes these, and its tests pin them:
- Outline:
frontandbackare required;holesis empty.segmentsis 8 on rect and points outlines and 64 on circles and superellipses; a superellipse’scentreis the origin. - Use:
fileis required;prefixis empty,atthe origin,rotatenone,scale1, and noparent.profile.kindisround, with 4 segments for round and 1 for chamfer. - Pocket:
rimandfloortake{ width, segments = 4 }. - Bevel: box and cylinder only;
segmentsis 1. - Disc:
segmentsis 64. - Sweep:
capsis true; exactly one ofradiusandradii. - Card: exactly one of
imageandslot. - Scale:
scalemay be one number, for a uniform scale. - Materials:
materialormaterials, never both. - Material tables: every
[materials.<name>], the recipe’s own and its includes’, is read as the engine’sMaterial, which refuses unknown fields:manfred checknames the bad field, asmanfred shotandmanfred renderdo for a material a part uses (materials.<name>.emissive: unknown field \emissive`, expected one of …) at its file and line, even for a material no part uses, soemission, notemissive`, passes. - Authored sun:
towardandirradianceare required,radius_degis 0.27 andcolorwhite. - Daylight sun:
hour,latitudeandheadingare required;dayis 172 andreference_hour16.0. - Light:
positionandintensityare required;coloris white,radiusandrange0, andshadowfalse. - Text: a span’s
sizeis required and above 0, itscoloris white, itsweightfrom 1 to 1000;wrapis above 0;anchoris one of the nine names;onnames another object, and not withparent. - Sky:
[sky.prepare]takescapandmeantogether or neither, and needs one of its keys. An HDRpathneeds asourceline, andkind = "hdr"andkind = "mix"need apath.kind = "room"andkind = "mix"need[sky.room]; every room and lamp key has a default. - Bake:
productis required. - Plates:
sizeis required;overscan0.1,scale1, all sixlayers,threshold0.01,min_samples16,max_samples1024,growth2,casters"meshes", andanchorsandseedthe bake’s. A view needsname. - Proxy:
"box".
A material name is checked by manfred check, not by validate: an object with no material (which draws as "default"), or one naming a material that neither the recipe’s [materials] (with its includes), a generator’s returned materials nor the run’s registered catalogue defines, is a problem at the object’s file and line (object[<name>].material), in the words manfred shot and manfred render refuse it with, naming the material and the names the recipe defines. Plain manfred check has no catalogue, so a name the recipe doesn’t define is a problem there; an app’s build binary, which registers its catalogue, passes the names that catalogue defines. Pockets, text, hidden objects, and generator and mesh objects that name no material are exempt, since they draw no piece of their own or carry their own materials. The engine holds no presets since v0.19.0.
What is not in the recipe
Section titled “What is not in the recipe”- The window’s orbit camera, selection, panel layout and undo stack: tool state, never saved. Only the recent list is kept, under
$XDG_STATE_HOME/manfred. - Live content: a page, a print, a simulation. Apps paint those at runtime, into the slots
cardobjects name. - Behaviour: poses, motion, simulation. Movers describe where a part can go, never where it is now.
- An app’s material library: a name resolves through the app’s registered catalogue, then the recipe’s own
[materials.<name>]in the engine’s material TOML. The app decides whether a recipe’s material joins its catalogue.