Skip to content

Commands

m4bwav edited this page Oct 12, 2026 · 1 revision

Every pw.py command with output from a real run of 0.1.2 on Windows 11 with Blender 5.2.1 (the verification program and its saved output). Each command prints one JSON object. Timings are replaced with "<s>". The examples build on each other: place makes park/, which the later commands read.

Exit codes: 0 when the command did what it was asked, 1 when it failed or a check failed, 2 for a usage error (a bad argument, an unknown target). ok: false always comes with an error or a list of problems.

new

Scaffolds an asset folder with refs/, work/, out/, renders/, a brief.md and manifest.json. It refuses to overwrite an existing manifest without --force.

python pw.py new fountain --name Fountain --kind imagined --route generate --targets game-roblox,print-fdm --real-size-m 1.0
{
 "ok": true,
 "dir": "fountain",
 "manifest": "fountain\\manifest.json",
 "layout": {
  "refs/": "reference images, blueprints, textures (each listed in sources)",
  "work/": ".blend and intermediates",
  "out/": "deliverables, one per target",
  "renders/": "review sheets"
 }
}

Exit code 0.

place

Builds a real place from OpenStreetMap and elevation. Without --dem flat it asks the USGS 3DEP service (inside the US) or the terrain tiles on AWS. This run reads a small invented extract placed in park/data/osm.json beforehand, so no server is asked (notes says "OSM from cache"); a real run fetches that file from Overpass.

python pw.py place --bbox 40.0000,-75.0000,40.0010,-74.9987 --dem flat --res 2 --out park --print-mm 60
{
 "ok": true,
 "out": "park",
 "scene": "park\\scene.glb",
 "bbox": [
  40.0,
  -75.0,
  40.001,
  -74.9987
 ],
 "center": [
  40.0005,
  -74.99935
 ],
 "size_m": [
  111.0,
  111.0
 ],
 "grid": [
  56,
  56
 ],
 "res_m": 2.0,
 "dem": "flat",
 "relief_m": 0.0,
 "elevation_min_m": 0.0,
 "counts": {
  "water_points_flattened": 0,
  "buildings": 1,
  "building_height_source": {
   "osm height": 1
  },
  "road_segments": 1,
  "trees": 1,
  "ground_cells": {
   "default": 869,
   "park": 2156
  }
 },
 "frame": "glTF: metres, +Y up, -Z north, +X east, origin at the bbox centre, y=0 at the lowest ground",
 "credits": [
  "Map data (c) OpenStreetMap contributors, ODbL 1.0 (openstreetmap.org/copyright)"
 ],
 "notes": [
  "OSM from cache"
 ],
 "print": {
  "parts_stl": "park\\print_parts.stl",
  "tris": 6720,
  "size_mm": [
   60.0,
   60.0,
   7.9
  ],
  "scale": "1:1851",
  "exaggerate": 1.5,
  "base_mm": 3.0,
  "buildings_dropped_under_min_feature": 0,
  "note": "overlapping closed shells; slicers union them, but run union for one manifold solid",
  "union": {
   "ok": true,
   "solver": "MANIFOLD",
   "watertight": true,
   "tris": 6742,
   "error": null,
   "non_manifold_edges": 0,
   "boundary_edges": 0
  },
  "print_stl": "park\\print.stl"
 },
 "next": "render scene.glb (pw.py render, --color material) and compare with an aerial photo; hand-detail landmarks; credits go in the game's or print's notes"
}

Exit code 0.

scene.glb is in metres, +Y up, -Z north, with the origin at the box centre. print.stl is millimetres. credits holds the lines to ship with anything made from the data.

inspect

python pw.py inspect park/scene.glb --fast
{
 "file": "park\\scene.glb",
 "objects": 6,
 "tris": 6196,
 "verts": 3458,
 "bbox_min": [
  -55.50565,
  -55.51731,
  -0.3
 ],
 "bbox_max": [
  55.50565,
  55.51731,
  7.0
 ],
 "dims": [
  111.01131,
  111.03461,
  7.3
 ],
 "units_note": "Blender units; metres unless the source used other units (an STL in mm reads as 1000x too big)",
 "materials": [
  "buildings",
  "ground_default",
  "ground_park",
  "roads",
  "tree_trunks",
  "trees"
 ],
 "images": [],
 "max_image_px": 0,
 "uv_missing": [
  "ground_park",
  "ground_default",
  "roads",
  "buildings",
  "trees",
  "tree_trunks"
 ],
 "armatures": 0,
 "actions": 0,
 "cameras": 0,
 "lights": 0,
 "unapplied_scale": [],
 "seconds": "<s>",
 "object_rows": [
  {
   "name": "ground_park",
   "tris": 4312,
   "verts": 2250,
   "faces": 4312,
   "materials": [
    "ground_park"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  },
  {
   "name": "ground_default",
   "tris": 1738,
   "verts": 1072,
   "faces": 1738,
   "materials": [
    "ground_default"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  },
  {
   "name": "roads",
   "tris": 110,
   "verts": 112,
   "faces": 110,
   "materials": [
    "roads"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  },
  {
   "name": "buildings",
   "tris": 12,
   "verts": 8,
   "faces": 12,
   "materials": [
    "buildings"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  },
  {
   "name": "trees",
   "tris": 12,
   "verts": 8,
   "faces": 12,
   "materials": [
    "trees"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  },
  {
   "name": "tree_trunks",
   "tris": 12,
   "verts": 8,
   "faces": 12,
   "materials": [
    "tree_trunks"
   ],
   "uv_layers": [],
   "color_attributes": [],
   "modifiers": [],
   "scale": [
    1.0,
    1.0,
    1.0
   ],
   "shape_keys": 0
  }
 ],
 "ok": true,
 "action": "inspect",
 "blender": "5.2.1 LTS",
 "backend": "blender",
 "file_bytes": 155132
}

Exit code 0.

--fast skips the topology pass (boundary and non-manifold edges, parts, volume). Without it, edges are counted on welded positions, because glTF and STL store a vertex per face when normals are split, which would make every closed mesh look open.

check

python pw.py check park/scene.glb --target game-roblox --real-size-m 110
{
 "ok": false,
 "target": "game-roblox",
 "status": "fail",
 "results": [
  {
   "rule": "max_tris",
   "status": "pass",
   "detail": "6196 tris (limit 20000)"
  },
  {
   "rule": "max_tris_per_object",
   "status": "pass",
   "detail": "ground_park has 4312 tris (limit 20000)"
  },
  {
   "rule": "max_materials",
   "status": "warn",
   "detail": "6 materials (limit 1)"
  },
  {
   "rule": "max_image_px",
   "status": "pass",
   "detail": "largest texture 0 px (limit 1024)"
  },
  {
   "rule": "require_uvs",
   "status": "fail",
   "detail": "objects without UVs: ground_park, ground_default, roads, buildings, trees, tree_trunks"
  },
  {
   "rule": "real_scale",
   "status": "pass",
   "detail": "longest side 111 m vs expected 110 m (x1.01)"
  }
 ],
 "advice": [
  "20,000 triangles per mesh (create.roblox.com specifications, checked 2026-10-11; vendors quote 21,000, test the importer). Split bigger models into several MeshParts.",
  "One material per mesh; SurfaceAppearance takes colour, normal (OpenGL, Y+), roughness, metalness. Texture size limit unverified (historically 1024 px).",
  "1 stud = 0.28 m: a 1.8 m person is about 6.4 studs. Export in metres and scale in Studio, or scale by 1/0.28 before export.",
  "At most 4 bone influences per vertex, root joint at the origin, no zero-thickness surfaces. CollisionFidelity Box or Hull for props."
 ],
 "inspect": {
  "tris": 6196,
  "objects": 6,
  "dims": [
   111.01131,
   111.03461,
   7.3
  ],
  "watertight": false,
  "parts": 6,
  "materials": [
   "buildings",
   "ground_default",
   "ground_park",
   "roads",
   "tree_trunks",
   "trees"
  ],
  "max_image_px": 0,
  "backend": "blender",
  "blender": "5.2.1 LTS"
 }
}

Exit code 1.

The rules and where each number comes from: How checks decide.

convert

python pw.py convert park/scene.glb park/roblox.glb --join --uv --max-tris 5000
{
 "path": "park\\roblox.glb",
 "bytes": 127588,
 "tris": 5000,
 "dims": [
  111.011307,
  111.034615,
  7.3
 ],
 "notes": [
  "hierarchy flattened, transforms applied, non-mesh objects removed",
  "Smart UV Project on 1 object(s)"
 ],
 "ok": true,
 "action": "convert",
 "blender": "5.2.1 LTS",
 "seconds": "<s>"
}

Exit code 0.

--size-m X scales the longest side to X metres, --ground and --center move the model onto the origin, --draco compresses glTF, --export-scale 1000 writes an OBJ, STL or PLY in millimetres, --fbx-bake-axes bakes the axis change into an FBX for Unity. Output formats: glb, gltf, obj, fbx, stl, ply, usd, usda, usdc, usdz, abc, blend.

render

python pw.py render park/print.stl --out park/renders --views front,top,iso --size 256
{
 "shots": [
  {
   "view": "front",
   "path": "park\\renders\\view_front.png",
   "projection": "ortho",
   "metres_per_px": 0.253072
  },
  {
   "view": "top",
   "path": "park\\renders\\view_top.png",
   "projection": "ortho",
   "metres_per_px": 0.253125
  },
  {
   "view": "iso",
   "path": "park\\renders\\view_iso.png",
   "projection": "perspective"
  }
 ],
 "dims": [
  59.98741,
  60.0,
  7.86335
 ],
 "engine": "BLENDER_WORKBENCH",
 "sheet": {
  "path": "park\\renders\\sheet.png",
  "layout": "3 columns, row-major in the order of shots"
 },
 "ok": true,
 "action": "render",
 "blender": "5.2.1 LTS",
 "seconds": "<s>",
 "next": "Read sheet.png (one image) and judge silhouette, proportions, scale, missing parts, shading artifacts"
}

Exit code 0.

Orthographic views report metres_per_px (scene units per pixel), so a render can be laid over a blueprint. sheet.png holds every view in one image. --color material or texture and --engine eevee or cycles change the look.

lods

python pw.py lods park/roblox.glb --out park/lods --ratios 1,0.5,0.25
{
 "ok": true,
 "lods": [
  {
   "lod": 0,
   "ratio": 1.0,
   "path": "park\\lods\\roblox_LOD0.glb",
   "ok": true,
   "tris": 5000,
   "error": null
  },
  {
   "lod": 1,
   "ratio": 0.5,
   "path": "park\\lods\\roblox_LOD1.glb",
   "ok": true,
   "tris": 2499,
   "error": null
  },
  {
   "lod": 2,
   "ratio": 0.25,
   "path": "park\\lods\\roblox_LOD2.glb",
   "ok": true,
   "tris": 1250,
   "error": null
  }
 ],
 "note": "Unity: name LOD children <name>_LOD0.. for automatic LOD groups; Unreal: import with Auto Generate LODs off and add these; three.js: THREE.LOD"
}

Exit code 0.

printable

python pw.py printable park/roblox.glb park/solid.stl --size-mm 60 --wall-mm 1.2
python pw.py check park/solid.stl --target print-fdm
{
 "out": {
  "path": "park\\solid.stl",
  "bytes": 230584
 },
 "steps": [
  "scaled x0.5404 to 60.0 mm (longest)",
  "open surface: solidified 1.20 mm inward",
  "voxel remesh 0.120 mm",
  "flat base: clamped below 0.60 mm",
  "planar dissolve 0.5 deg: 391651 -> 4610 faces"
 ],
 "tris": 4610,
 "dims_mm": [
  59.987,
  60.0,
  4.304
 ],
 "boundary_edges": 0,
 "non_manifold_edges": 0,
 "parts": 1,
 "volume_cm3": 2.28,
 "watertight": true,
 "ok": true,
 "action": "printable",
 "blender": "5.2.1 LTS",
 "seconds": "<s>",
 "next": "render it (pw.py render) and compare with the source; then check --target print-fdm; slicer last"
}

Exit code 0.

and the check:

{
 "ok": true,
 "target": "print-fdm",
 "status": "pass",
 "results": [
  {
   "rule": "max_tris",
   "status": "pass",
   "detail": "4610 tris (limit 1000000)"
  },
  {
   "rule": "require_watertight",
   "status": "pass",
   "detail": "boundary edges 0, non-manifold edges 0"
  },
  {
   "rule": "single_part",
   "status": "pass",
   "detail": "1 separate parts (loose pieces print as separate islands or fall off)"
  },
  {
   "rule": "max_dims",
   "status": "pass",
   "detail": "size [59.987, 60.0, 4.304] mm (largest allowed [256, 256, 256] mm; rotation is allowed)"
  },
  {
   "rule": "min_feature",
   "status": "unchecked",
   "detail": "not measured here: render it and look for parts thinner than 0.9 mm, or run a wall-thickness check"
  }
 ],
 "advice": [
  "Walls at least 2 perimeters (about 0.9 mm), better 1.2 to 1.6 mm. Details under 0.5 mm vanish.",
  "Overhangs past about 45 degrees need support; chamfer rather than fillet bed-facing edges; 0.3 mm clearance for parts that move or slot together (Prusa guide).",
  "Too big for the bed: split with pins (3 mm, 0.2 mm clearance) or the slicer's Cut tool.",
  "Hand the slicer a 3MF when a printer-specific skill or the slicer CLI is available; the STL stays the verified artefact."
 ],
 "inspect": {
  "tris": 4610,
  "objects": 1,
  "dims": [
   59.98741,
   60.0,
   4.30415
  ],
  "watertight": true,
  "parts": 1,
  "materials": [],
  "max_image_px": 0,
  "backend": "blender",
  "blender": "5.2.1 LTS"
 }
}

Exit code 0.

3mf

Packs meshes in millimetres into one 3MF, one object per file, so a multi-colour printer can give each its own filament. Needs trimesh.

python pw.py 3mf park/print.stl park/solid.stl --out park/two-colours.3mf
{
 "ok": true,
 "out": "park\\two-colours.3mf",
 "objects": [
  {
   "name": "print",
   "tris": 6742,
   "watertight": true,
   "dims_mm": [
    59.987,
    60.0,
    7.863
   ]
  },
  {
   "name": "solid",
   "tris": 4610,
   "watertight": true,
   "dims_mm": [
    59.987,
    60.0,
    4.304
   ]
  }
 ],
 "units": "millimetre",
 "note": "geometry only: the slicer applies its defaults; set printer, filament and process there"
}

Exit code 0.

manifest

python pw.py manifest fountain --add-output park/solid.stl --target print-fdm
python pw.py manifest fountain --add-source https://example.com/ref.png --what "reference photo" --author "A. Person"
python pw.py manifest fountain --validate
{
 "ok": true,
 "manifest": "fountain\\manifest.json",
 "status": "brief",
 "outputs": 1,
 "sources": 0,
 "problems": []
}

Exit code 0.

Adding a source without --licence is recorded, and reported as a problem until it has one:

{
 "ok": false,
 "manifest": "fountain\\manifest.json",
 "status": "brief",
 "outputs": 1,
 "sources": 1,
 "problems": [
  "source without a licence: https://example.com/ref.png"
 ]
}

Exit code 1.

bpy

Runs your own Blender script headless and returns its output without Blender's log noise. Arguments after -- reach the script. hello_bpy.py:

import bpy, sys
args = sys.argv[sys.argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.mesh.primitive_cylinder_add(radius=0.3, depth=float(args[0]))
print("[pw] height", round(bpy.context.active_object.dimensions.z, 3))
python pw.py bpy hello_bpy.py -- 2.5
{
 "ok": true,
 "exit_code": 0,
 "seconds": "<s>",
 "blender": "5.2.1",
 "output": [
  "[pw] height 2.5"
 ],
 "errors": []
}

Exit code 0.

A failing script returns the traceback; see Errors and edge cases.

texture

python pw.py texture bricks.png --out tex --seamless
{
 "ok": true,
 "maps": {
  "albedo_seamless": "tex\\bricks_seamless.png",
  "height": "tex\\bricks_height.png",
  "normal": "tex\\bricks_normal_opengl.png",
  "roughness": "tex\\bricks_roughness.png"
 },
 "convention": "opengl",
 "note": "luminance-derived approximations (the classic CrazyBump trick), fine for props and terrain, wrong for measured materials; OpenGL = glTF, Blender, Unity; DirectX = Unreal"
}

Exit code 0.

gen

gen sends images to a ComfyUI host. The built-in graph is Hunyuan3D-2 (--image for one view, plus --left, --back, --right for multi-view); --workflow FILE runs any other workflow, in API format or as saved by ComfyUI's editor (a template file works as is). Every run writes <out>.gen.json with the host, model, seed and a licence note. The wiki's program cannot run it (it needs a GPU host); a run on 2026-10-11 is described in Recipes, and its failure output is on Errors and edge cases.

union and doctor

union IN OUT boolean-unions the closed parts of a file into one solid with Blender's Manifold solver, falling back to EXACT when Manifold drops a part; place --print-mm runs it for you (its union object above). doctor is described on Getting started.

Clone this wiki locally