Repository navigation
Commands
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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 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.
This wiki describes polywright 0.1.2 and was last updated on 2026-10-11. The plugin is MIT licensed. Report problems in the issues.
Using it
- Getting started
- The skills
- Commands
- How checks decide
- The asset contract
- Errors and edge cases
- Recipes
- FAQ
The releases
Contributing
Elsewhere