From da88e1e2c38966b68d8ec4d0e717ac12b98b4cdb Mon Sep 17 00:00:00 2001 From: Danyal Sarfraz Date: Thu, 29 Oct 2026 07:49:56 +0900 Subject: [PATCH 1/2] Split the sidebar into three parts and rename the add-on CADshot (1.13.0) Three parts - A Part switcher at the top of the sidebar picks Clay, Patches or Variants; every panel polls on it, so Clay never shows a texture control and Patches never shows a camera one - Top-level panels per part: Clay 8, Patches 6, Variants 11, in place of one stack of 18 that showed everything at once - The part writes through to render_mode, pool_variants and pool_patches rather than replacing them, so the renderer, the command line, the manifest and provenance need not know parts exist; it fires only on a change, so Clay can still be set to Full or Both from its own Output panel - Lighting, the ground and the base material poll on needs_lighting() rather than on the part: clay carries Workbench's own studio light and the ground is hidden for any non-Full render, so they show only when something in the run is lit by the scene - Products holds the render list, and in Variants each product's own material choice, which is what decides whether it takes a draw - Camera protocols, annotations, drawings, randomization and the manifest moved into one collapsed Dataset & research section - The guide is written per part Material patches as a part of their own - Render All plans patch jobs with no product, no camera and no stage, so patches gained the progress bar, the time left, Esc, Skip Existing and manifest rows the standalone operator lacked - Texture Size is hidden for patches, which show one repeat filling the frame whatever size the real part would be - part = PATCHES runs from a job file with no inputs at all Pre-flight - The Patches part is no longer asked about products, cameras, views, naming clashes or lighting - New error: patches with Workbench as the engine and the Cycles add-on off, which used to raise mid-run when the stand-in swap to Cycles had nothing to swap to Rename - bl_info, the sidebar tab, panel labels, the status bar, console prefixes, the manifest's tool field and the citation say CADshot - The add-on folder, the MV_ collections, the mv. operator names and the lighting preset folder keep theirs, so blend files and job files written before the rename open unchanged Documentation - README: a Three parts section, Workflow reordered to follow the panels (Products, Standardize, Views, Stage, Texture Pool, Output), troubleshooting for a hidden panel and a missing Stage control, and a sentence in Orient Products that had lost its start Tests - test_parts.py: the write-through, which panels each part polls in, every draw() run through a layout shim, patches rendered with no product in the scene, mode-aware pre-flight, and a patches-only job file through the command line --- CITATION.cff | 18 +- README.md | 524 ++++++----- multiview_product_renderer/__init__.py | 1139 +++++++++++++++--------- multiview_product_renderer/cli.py | 8 +- tests/test_parts.py | 256 ++++++ 5 files changed, 1325 insertions(+), 620 deletions(-) create mode 100644 tests/test_parts.py diff --git a/CITATION.cff b/CITATION.cff index fad6979..4981b14 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,24 +1,28 @@ cff-version: 1.2.0 message: "If you use MultiView Product Renderer in your research, please cite it as below." -title: "MultiView Product Renderer" +title: "CADshot" type: software authors: - given-names: Danyal family-names: Sarfraz -version: 1.12.0 +version: 1.13.0 license: MIT repository-code: "https://github.com/Danyals-code/Addon-for-MultiView-Rendering-AMVR" abstract: >- - A Blender add-on that turns CAD (STEP, IGES) and mesh files into standardized - multi-view image sets and datasets: canonical camera rigs or published - protocols, studio lighting, clay, line-art, depth, normal and part-ID passes, - camera poses, COCO and BOP annotations, vector engineering drawings, seeded - randomization, and a provenance manifest with Croissant metadata. + A Blender add-on that turns CAD (STEP, IGES) and mesh files into three kinds of + standardized image set: multiple clay views of a product's form, patches of the + materials in a texture pool, and products rendered across every combination of + view and texture. Canonical camera rigs or published protocols, studio lighting, + clay, line-art, depth, normal and part-ID passes, camera poses, COCO and BOP + annotations, vector engineering drawings, seeded randomization, and a provenance + manifest with Croissant metadata. Formerly MultiView Product Renderer. keywords: - Blender - CAD - STEP - multi-view rendering + - material patches + - CMF - synthetic data - dataset generation - engineering drawings diff --git a/README.md b/README.md index 194b05e..ed3187c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# MultiView Product Renderer +# CADshot A Blender add-on for turning CAD products into consistent, batch-rendered image sets. @@ -7,9 +7,50 @@ orthographic and perspective cameras plus studio lighting, then render every pro every view in one click, with predictable file names like `AA_01_Widget_Front_clay.png`. - **Blender:** 4.0 and newer -- **Panel location:** `3D Viewport → Sidebar (N key) → MultiView` -- **Version:** 1.12.0 +- **Panel location:** `3D Viewport → Sidebar (N key) → CADshot` +- **Version:** 1.13.0 - **Cite:** see [`CITATION.cff`](CITATION.cff) +- Formerly *MultiView Product Renderer*. The add-on folder, the collections it makes + (`MV_Products`, `MV_Cameras`, ...) and its operator names still carry the `MV` prefix, so + files and job files written before the rename open unchanged. + +--- + +## Three parts + +CADshot makes three kinds of image set, and the sidebar shows one at a time. The **Part** +switcher at the top of the panel picks which, and every panel below it appears only if that +part uses it — so you never scroll past a texture control while rendering clay, or a camera +control while rendering swatches. + +| Part | What it writes | About | +| --- | --- | --- | +| **Clay** | Every product from every view, as clay: `AA_01_Widget_Front_clay.png` | Form | +| **Patches** | One image per pool material: a flat swatch beside a lit ball, in `materials/` | CMF | +| **Variants** | Every product from every view, several times over, each in a different draw of textures: `AA_01_Widget_Front_v01.png` | CMF × Form | + +Each part needs a different half of the tool, which is what the switcher is for: + +``` +Clay Products · Standardize · Views · Stage · Output +Patches Texture Pool · Patch · Output +Variants Products · Standardize · Views · Stage · + Texture Pool · Variants · Output +``` + +Patches need no product, no camera and no lighting: they are rendered in a scene of their +own and yours is left exactly as it was. Clay needs no lighting either, because Workbench +draws it with its own studio light and the ground is left out of it — so the Stage panel +shows only the background until something in the run is actually lit by the scene. + +Picking a part sets the render mode and the variant count to match. It does not lock them: +Clay's **Output** panel still offers `Clay / Full / Both`, so a form view in the product's +own CAD colours is a tick away. + +Everything a dataset needs and a picture does not — camera poses, depth, normals, part IDs, +COCO, BOP, vector drawings, seeded randomization, the provenance manifest and Croissant +metadata — lives in one collapsed **Dataset & research** section at the bottom, in every +part. See [Annotations for research](#annotations-for-research). --- @@ -23,8 +64,8 @@ every view in one click, with predictable file names like `AA_01_Widget_Front_cl - Blender **4.2+**: `Install from Disk...` (in the top-right dropdown menu) - Blender **4.0 / 4.1**: `Install...` 3. Pick the zip you downloaded. **Do not unzip it first.** -4. Tick the checkbox next to **MultiView Product Renderer** to enable it. -5. Press `N` in the 3D viewport and open the **MultiView** tab. +4. Tick the checkbox next to **CADshot** to enable it. +5. Press `N` in the 3D viewport and open the **CADshot** tab. ### Option B: clone the repo @@ -62,10 +103,9 @@ batch render and file naming. | IGES (`.iges`, `.igs`) | FreeCAD | | | STL, PLY | Blender | Read as Z-up, which is what CAD writes | | OBJ, glTF / GLB, FBX | Blender | Up axis converted the way the format defines it. Cameras and lights in the file are dropped | -| 3MF | MultiView's own reader | Meshes, components, build transforms and base-material colours. Blender has no 3MF importer | +| 3MF | CADshot's own reader | Meshes, components, build transforms and base-material colours. Blender has no 3MF importer | -Open `Edit → Preferences → Add-ons → MultiView Product Renderer` and expand the -preferences: +Open `Edit → Preferences → Add-ons → CADshot` and expand the preferences: | Setting | What it does | | --- | --- | @@ -91,175 +131,22 @@ way the CAD file names it (`Housing`, `Lid`, `Screw`...), all inside the one pro collection. Colours set in the CAD file become materials (`MV_Color_RRGGBB`), including faces coloured differently from the rest of their part, and show in Full renders unless a material preset replaces them. FreeCAD's console mode cannot read STEP colours itself, so -MultiView reads them from the file and passes them along. It understands the usual ways CAD +CADshot reads them from the file and passes them along. It understands the usual ways CAD programs write colour; a file that colours parts some other way imports in grey, never fails. -**STEPper route**: if you own the STEPper add-on, install and enable it; MultiView detects +**STEPper route**: if you own the STEPper add-on, install and enable it; CADshot detects and uses its import operator automatically. --- ## Workflow -The sidebar is laid out in the order you should use it. - -### 1. Scene Setup - -Pick a lighting preset, a background and a ground, then **Apply Scene Setup**. This builds -an `MV_Lighting` collection, an `MV_Stage` collection for the ground, and switches the scene -to the add-on's own `MV_World`. - -Your existing world is left intact. It is only deselected, not edited, so an HDRI setup -you built yourself survives and can be picked again from World properties. - -| Lighting preset | Look | -| --- | --- | -| **Studio Soft** | Large soft area lights, even fill. Default. | -| **Studio Hard** | Smaller lights, crisper shadows and highlights. | -| **Dramatic** | Strong key, deep falloff, minimal fill. | -| **High Key** | Bright, near-shadowless, white-on-white product look. | -| **No Lights (HDRI only)** | No lamps at all; the HDRI does the lighting. | - -**Intensity** multiplies the preset's light energies. **Lighting Only** and **Background -Only** re-run either half on its own. - -#### Lights Follow Camera - -On by default. The rig is laid out for the Front view, and with this off the lights stay -where they are while the cameras go round, so Back is lit mostly by the rim light and Bottom -barely at all. With it on, the whole rig turns with each camera during a render, so every -view is lit the way Front is: key from the same side, fill from the other, rim behind. The -lights go back to their Front position when the batch finishes. +The sections below follow the panels down the sidebar. Which of them you see depends on the +**Part** switcher at the top: Clay shows 1 to 4 and 6, Patches shows only 5 and 6, Variants +shows all of them. See [Three parts](#three-parts). -The rig hangs off an empty called `MV_LightRig` at the centre of the batch. A file whose -lighting was built before 1.7 has no rig yet: press **Lighting Only** once. - -#### Your own lighting presets - -Move the lights, change their colour or strength, or add your own lights to `MV_Lighting`, -then press **+** next to the preset list and give the setup a name. It is saved in -Blender's user presets folder (`scripts/presets/multiview_lighting/`), so it is available in -every file, and appears in the list after the built-in presets. **−** deletes the selected -saved preset; built-in ones cannot be deleted. - -Presets are saved relative to the product: positions and sizes as multiples of its size, -laid out against the Front view. Loaded onto a product of a different size, or with a -different **Front Axis**, the rig scales and turns to match. - -#### Background - -- **Color**: a flat background in **Background Color**. -- **HDRI**: pick an `.hdr` or `.exr` environment. It lights the product and shows in its - reflections. **Strength** and **Rotation** adjust it. With **Show HDRI** off (the default) - the camera still sees the flat Background Color, which is usually what a product shot - wants; tick it to see the environment itself. - -**Transparent Background** renders alpha instead of either. - -#### Ground - -Something for the product to stand on, in Full renders only. Clay and the extra passes -are about the product's shape, so the ground is left out of them. - -- **Shadow Catcher**: an invisible floor that only shows the product's shadow, on the - background colour or on transparency. Cycles only; EEVEE renders it as a plain floor, - and the panel warns. -- **Floor**: a visible floor in **Floor Color**. Lower **Roughness** makes it reflect the - product. - -The ground is moved under each product as it is rendered, so short and tall products all -stand on it. It is hidden for any view looking up from below it, so **Bottom** still shows -the product. - -#### Materials - -The **Materials** sub-panel replaces the products' materials in Full renders with one of: -Matte Plastic, Glossy Plastic, Rubber, Brushed Metal, Polished Metal or Glass, in a colour -you pick. **Keep Own** (the default) leaves materials alone. - -Set it for the whole batch, or select a product in **Products to render** and give it its -own preset and colour. **Batch Default** follows the batch setting; **Keep Own** keeps that -product's materials even when the batch has a preset. - -The products' own materials are never edited. The preset is applied by a temporary -Geometry Nodes modifier, `MV_Material`, added while the product renders and removed -afterwards, so it also works on STL imports, which arrive with no materials at all. - -#### Material Pool - -The **Material Pool** sub-panel turns a folder of PBR texture sets into materials for -your products. Point **Texture Folder** at a folder holding one sub-folder per material, as -downloaded from [ambientCG](https://ambientcg.com) or [Poly Haven](https://polyhaven.com), -and click the refresh button. Every folder with a colour map becomes a pool material: - -``` -textures/ - Bricks_A/ Bricks_A_1K-PNG_Color.png, _Roughness, _NormalGL, _Displacement - oak_veneer/ oak_veneer_diff_1k.png, _rough_1k, _arm_1k, _nor_gl_1k - Metal_Plate/ Metal_Plate_Color.jpg, Metal_Plate_Metalness.png -``` - -Maps are recognised by their names: -- colour: `Color`, `BaseColor`, `Albedo`, `Diffuse`, `diff` -- roughness: `Roughness`, `rough` -- metallic: `Metalness`, `Metallic`, `metal` -- packed AO/rough/metal: `arm` -- height: `Displacement`, `Height`, `disp`, `bump` -- opacity: `Opacity`, `Alpha` - -A folder holding a single image uses it as the colour map, unless its name ends in a data -map's word (`_rough`, `_Metalness`, ...). A material is named after its folder. A folder -called `textures`, `maps` or `2k` takes the name of the one above it, since Poly Haven -downloads keep their maps in `/textures`. Two folders with the same name are told -apart by their path (`wood_Oak`, `metal_Oak`). A material keeps its name when you scan -again, so a library that later gains a second `Oak` names only the new one by its path. - -The list shows the maps found for each material as letters (**C** colour, **R** roughness, -**M** metallic, **A** packed, **H** height, **N** normal, **O** opacity). Untick a material -to leave it out. - -The list keeps where each texture file is. If the files move (a moved library, or the -`.blend` opened on another machine), the folder is scanned again before rendering. If the -textures a material uses still cannot be found, pre-flight stops the batch. - -CAD files have no UV maps, so the textures are box projected in each part's own -coordinates. They stay put when the product turns and need no unwrapping. **Texture Size** -is how much of the real part one repeat of a texture covers, in millimetres. A 100 mm wood -texture looks the same size on a 30 mm knob and a 2 m cabinet, whatever scale Fit on Import -gave them. A product built by hand and never standardized is taken to be in metres. - -Normal maps are listed but not used, because they need the UVs these meshes do not have. -Opacity maps are listed but not used either. Masks, part IDs and COCO/BOP annotations are -made once per view, so a material that cut holes in the product would leave them wrong for -that image. - -**Product variants.** Set **Variants** above 0 and every Full render is made that many -times per view, each with a different draw from the pool. With **Per Part** on, every part -of the product draws its own material; off, the whole product shares one. A product set to -**Keep Own** in **Products to render** is left out and renders once, in its own materials. - -The files get a `_v01`, `_v02`, ... suffix (or use `{variant}` in the naming templates), and -each variant gets its own contact sheet. Clay renders, line art, masks, depth, normals and -part IDs do not depend on the material, so they are rendered once per view. The manifest's -`variant` and `materials` columns record what each image wears, for example -`Block=Bricks_A; Peg=oak_veneer`. - -Draws come from the **Seed**, the product's label, the variant number and the part, so -re-running gives the same variants. Adding variants, products or parts never changes the -other draws. Adding or unticking a pool material changes only the draws it wins or held. -The pool's contents are recorded in the manifest's provenance block. - -**Material patches.** **Render Patches** writes one image per ticked material into -`materials/` in the output folder. It shows a flat swatch, one repeat seen straight on, next -to a ball under studio lights, with the material's name above. Tick **With Render All** to -make them as part of every batch, listed in the manifest. In a sharded command-line run the -first shard makes them, and rendering just the active product skips them. Patches render in -a scene of their own with the scene's colour management, and the pool materials are deleted -again after each run, so your scene is untouched. They use Cycles when your engine is -Workbench, which cannot show textures. - -### 2. Import Products +### 1. Products Add files to the list with **+**, or a whole folder with the folder button, then click **Import Files**. **Add Folder** picks up every supported file in the folder (tick **Include @@ -268,7 +155,7 @@ are already in the list. Each file becomes its own collection under `MV_Products grouping is what the batch renderer iterates over. You can mix formats freely in one list; each file is routed by its extension, and a file that fails does not stop the ones after it. -Import runs in the background: the status bar shows `MultiView: 3/12 housing.step +Import runs in the background: the status bar shows `CADshot: 3/12 housing.step (converting)`, Blender stays responsive, and **Esc** cancels after the file in flight. Files that fail are skipped rather than stopping the batch, and FreeCAD's own error message is reported instead of a generic failure -- run `Window > Toggle System Console` @@ -285,9 +172,9 @@ against each other with **Keep Proportions**. STEP and IGES come out of FreeCAD millimetres, and 3MF files state their own unit. You can also click **Add Empty Product Collection** and drag existing meshes into it, which -is how you use MultiView with geometry that didn't come from a CAD file. +is how you use CADshot with geometry that didn't come from a CAD file. -### 3. Standardize +### 2. Standardize Everything that makes products comparable to each other lives in this panel. @@ -335,7 +222,7 @@ on the world origin, so the product rotates about its own centre and the N panel Geometry does not move during that step. Objects on multi-user or linked data cannot be edited by Blender, so those keep their transform and are counted in the status message. -### 4. Cameras / Views +### 3. Views Choose a view set and click **Build Cameras**. Cameras land in `MV_Cameras`, named `MV_Cam_`. @@ -375,13 +262,13 @@ a render set can match what a benchmark or model expects: | Protocol | Views | Field of view | Distance | Source | | --- | --- | --- | --- | --- | -| **MVCNN, 12 views** | 12 around the product, 30 degrees apart, 30 degrees up | MultiView's | Shared framing | Su et al., ICCV 2015 | -| **SyncDreamer, 16 views** | 16 around, 22.5 degrees apart, 30 degrees up | MultiView's | Shared framing | Liu et al., ICLR 2024 | +| **MVCNN, 12 views** | 12 around the product, 30 degrees apart, 30 degrees up | CADshot's | Shared framing | Su et al., ICCV 2015 | +| **SyncDreamer, 16 views** | 16 around, 22.5 degrees apart, 30 degrees up | CADshot's | Shared framing | Liu et al., ICLR 2024 | | **Zero123++ v1.2, 6 views** | Azimuths 30, 90, ..., 330; elevations alternating 20 and -10 | 30 degrees | Shared framing | Shi et al., v1.2 README | | **MVDream, 4 views** | Front, right, back, left at 15 degrees up | 37.5 degrees | Shared framing | Shi et al., ICLR 2024 (middle of its random ranges) | | **Zero-1-to-3, 12 random views** | Uniform on the sphere, seeded | 49.1 degrees | 1.5-2.2 object sizes, seeded | Liu et al., ICCV 2023 | | **TRELLIS, 150 views** | Hammersley sequence on the sphere, seeded offset | 40 degrees | 2 object sizes | Xiang et al., CVPR 2025 | -| **Catalogue, 15 degree plunge** | Front at 0 and 15 degrees; front-right, right, back, left, front-left at 15 | MultiView's | Shared framing | After the GS1 Product Image Specification's preferred plunge (names are MultiView's, not GS1 codes) | +| **Catalogue, 15 degree plunge** | Front at 0 and 15 degrees; front-right, right, back, left, front-left at 15 | CADshot's | Shared framing | After the GS1 Product Image Specification's preferred plunge (names are CADshot's, not GS1 codes) | Azimuth 0 is the Front view, so protocols follow **Front Axis** like the built-in views. Distances given in object sizes assume the product is normalized into a unit cube, as these @@ -401,7 +288,7 @@ on. Rotating or stepping hides the others, so a product is never obscured by the batch while you are trying to orient it. The checkbox applies the moment you tick it, and unticking it brings everything back. -That is the same isolation **Render All** uses, so what you see is what that product's +That is the same isolation a batch render uses, so what you see is what that product's render will contain. It is viewport state only: a batch render saves it, isolates each product in turn, and puts it back afterwards, so leaving it switched on never changes what gets rendered. @@ -431,14 +318,201 @@ it, 90 degrees by default, so four presses of the same button return to where yo **Reset** turns the product back to the way it faced when it was imported, undoing every Auto Orient and arrow press since; **All** resets the whole batch. A product you built by -hand gets its starting orientation recorded the first time you turn it., and framing is measured across the whole batch. -With **Reframe Before Rendering** on (the default) you don't need to do anything about that: -Render All rebuilds the cameras before the first image. If you have switched it off, click -**Build Cameras** again once you are done orienting. +hand gets its starting orientation recorded the first time you turn it. + +Rotating a product changes the bounding box its framing was measured from, and framing is +measured across the whole batch. With **Reframe Before Rendering** on (the default) you +don't need to do anything about that: a render rebuilds the cameras before the first image. +If you have switched it off, click **Build Cameras** again once you are done orienting. + +### 4. Stage -### 5. Batch Render +Lighting, the ground and **Base Material** only appear when something in the run is lit by +the scene — a Full render, or a Full-render turntable. Clay comes with Workbench's own studio +light and leaves the ground out of the frame, so in the Clay part this panel shows the +background alone. Switch Clay's **Shading** to Full or Both and the rest appears. -Set an **Output Directory**, choose a **Render Mode**, and hit **Render All**. +Pick a lighting preset, a background and a ground, then **Apply Scene Setup**. This builds +an `MV_Lighting` collection, an `MV_Stage` collection for the ground, and switches the scene +to the add-on's own `MV_World`. + +Your existing world is left intact. It is only deselected, not edited, so an HDRI setup +you built yourself survives and can be picked again from World properties. + +| Lighting preset | Look | +| --- | --- | +| **Studio Soft** | Large soft area lights, even fill. Default. | +| **Studio Hard** | Smaller lights, crisper shadows and highlights. | +| **Dramatic** | Strong key, deep falloff, minimal fill. | +| **High Key** | Bright, near-shadowless, white-on-white product look. | +| **No Lights (HDRI only)** | No lamps at all; the HDRI does the lighting. | + +**Intensity** multiplies the preset's light energies. **Lighting Only** and **Background +Only** re-run either half on its own. + +#### Lights Follow Camera + +On by default. The rig is laid out for the Front view, and with this off the lights stay +where they are while the cameras go round, so Back is lit mostly by the rim light and Bottom +barely at all. With it on, the whole rig turns with each camera during a render, so every +view is lit the way Front is: key from the same side, fill from the other, rim behind. The +lights go back to their Front position when the batch finishes. + +The rig hangs off an empty called `MV_LightRig` at the centre of the batch. A file whose +lighting was built before 1.7 has no rig yet: press **Lighting Only** once. + +#### Your own lighting presets + +Move the lights, change their colour or strength, or add your own lights to `MV_Lighting`, +then press **+** next to the preset list and give the setup a name. It is saved in +Blender's user presets folder (`scripts/presets/multiview_lighting/`), so it is available in +every file, and appears in the list after the built-in presets. **−** deletes the selected +saved preset; built-in ones cannot be deleted. + +Presets are saved relative to the product: positions and sizes as multiples of its size, +laid out against the Front view. Loaded onto a product of a different size, or with a +different **Front Axis**, the rig scales and turns to match. + +#### Background + +- **Color**: a flat background in **Background Color**. +- **HDRI**: pick an `.hdr` or `.exr` environment. It lights the product and shows in its + reflections. **Strength** and **Rotation** adjust it. With **Show HDRI** off (the default) + the camera still sees the flat Background Color, which is usually what a product shot + wants; tick it to see the environment itself. + +**Transparent Background** renders alpha instead of either. + +#### Ground + +Something for the product to stand on, in Full renders only. Clay and the extra passes +are about the product's shape, so the ground is left out of them. + +- **Shadow Catcher**: an invisible floor that only shows the product's shadow, on the + background colour or on transparency. Cycles only; EEVEE renders it as a plain floor, + and the panel warns. +- **Floor**: a visible floor in **Floor Color**. Lower **Roughness** makes it reflect the + product. + +The ground is moved under each product as it is rendered, so short and tall products all +stand on it. It is hidden for any view looking up from below it, so **Bottom** still shows +the product. + +#### Base Material + +The **Base Material** sub-panel replaces the products' materials in Full renders with one of: +Matte Plastic, Glossy Plastic, Rubber, Brushed Metal, Polished Metal or Glass, in a colour +you pick. **Keep Own** (the default) leaves materials alone. + +Set it for the whole batch, or select a product in **Products to render** and give it its +own preset and colour. **Batch Default** follows the batch setting; **Keep Own** keeps that +product's materials even when the batch has a preset. + +The products' own materials are never edited. The preset is applied by a temporary +Geometry Nodes modifier, `MV_Material`, added while the product renders and removed +afterwards, so it also works on STL imports, which arrive with no materials at all. + +### 5. Texture Pool + +Shown for the **Patches** and **Variants** parts, and the only panel Patches needs besides +Output. + +The **Texture Pool** panel turns a folder of PBR texture sets into materials for +your products. Point **Texture Folder** at a folder holding one sub-folder per material, as +downloaded from [ambientCG](https://ambientcg.com) or [Poly Haven](https://polyhaven.com), +and click the refresh button. Every folder with a colour map becomes a pool material: + +``` +textures/ + Bricks_A/ Bricks_A_1K-PNG_Color.png, _Roughness, _NormalGL, _Displacement + oak_veneer/ oak_veneer_diff_1k.png, _rough_1k, _arm_1k, _nor_gl_1k + Metal_Plate/ Metal_Plate_Color.jpg, Metal_Plate_Metalness.png +``` + +Maps are recognised by their names: +- colour: `Color`, `BaseColor`, `Albedo`, `Diffuse`, `diff` +- roughness: `Roughness`, `rough` +- metallic: `Metalness`, `Metallic`, `metal` +- packed AO/rough/metal: `arm` +- height: `Displacement`, `Height`, `disp`, `bump` +- opacity: `Opacity`, `Alpha` + +A folder holding a single image uses it as the colour map, unless its name ends in a data +map's word (`_rough`, `_Metalness`, ...). A material is named after its folder. A folder +called `textures`, `maps` or `2k` takes the name of the one above it, since Poly Haven +downloads keep their maps in `/textures`. Two folders with the same name are told +apart by their path (`wood_Oak`, `metal_Oak`). A material keeps its name when you scan +again, so a library that later gains a second `Oak` names only the new one by its path. + +The list shows the maps found for each material as letters (**C** colour, **R** roughness, +**M** metallic, **A** packed, **H** height, **N** normal, **O** opacity). Untick a material +to leave it out. + +The list keeps where each texture file is. If the files move (a moved library, or the +`.blend` opened on another machine), the folder is scanned again before rendering. If the +textures a material uses still cannot be found, pre-flight stops the batch. + +CAD files have no UV maps, so the textures are box projected in each part's own +coordinates. They stay put when the product turns and need no unwrapping. **Texture Size** +is how much of the real part one repeat of a texture covers, in millimetres. A 100 mm wood +texture looks the same size on a 30 mm knob and a 2 m cabinet, whatever scale Fit on Import +gave them. A product built by hand and never standardized is taken to be in metres. + +Texture Size belongs to the **Variants** part, which is why it only appears there: a patch +shows one repeat filling its frame whatever size the real part would be. + +Normal maps are listed but not used, because they need the UVs these meshes do not have. +Opacity maps are listed but not used either. Masks, part IDs and COCO/BOP annotations are +made once per view, so a material that cut holes in the product would leave them wrong for +that image. + +**Product variants.** Set **Variants** above 0 and every Full render is made that many +times per view, each with a different draw from the pool. With **Per Part** on, every part +of the product draws its own material; off, the whole product shares one. A product set to +**Keep Own** in **Products to render** is left out and renders once, in its own materials. + +The files get a `_v01`, `_v02`, ... suffix (or use `{variant}` in the naming templates), and +each variant gets its own contact sheet. Clay renders, line art, masks, depth, normals and +part IDs do not depend on the material, so they are rendered once per view. The manifest's +`variant` and `materials` columns record what each image wears, for example +`Block=Bricks_A; Peg=oak_veneer`. + +Draws come from the **Seed**, the product's label, the variant number and the part, so +re-running gives the same variants. Adding variants, products or parts never changes the +other draws. Adding or unticking a pool material changes only the draws it wins or held. +The pool's contents are recorded in the manifest's provenance block. + +**Material patches.** Switch the **Part** to **Patches** and press **Render Material +Patches** to write one image per ticked material into `materials/` in the output folder. +Each shows a flat swatch, one repeat seen straight on, next to a ball under studio lights, +with the material's name above. **Patch Size** is the pixel size of each half. + +Patches go through the same job queue as every other output, so they get the progress bar, +the time left, Esc, Skip Existing and manifest rows of their own (`kind` is `patch`). + +In the **Variants** part, **Render patches too** adds them to a variants run, so a texture +dataset can ship its swatches beside its renders. In a sharded command-line run the first +shard makes them, and rendering just the active product skips them. + +Patches render in a scene of their own with the scene's colour management, and the pool +materials are deleted again after each run, so your scene is untouched. They use Cycles when +your engine is Workbench, which cannot show textures — pre-flight stops the run if the +Cycles add-on is switched off and the engine is Workbench, since there would be nothing to +stand in with. + +### 6. Output + +Set an **Output Directory** and press the render button, which is named after the part you +are in: **Render Clay Views**, **Render Material Patches** or **Render Texture Variants**. +**Active** beside it renders only the product selected in the Products list, whether it is +ticked or not; the Patches part has no Active button, since patches belong to the pool +rather than to any product. + +Whichever part is running, the batch is one job queue: the progress bar, the time left, Esc, +Skip Existing and the manifest all behave the same way, patches included. + +The **Clay** part also chooses its shading here, because a form view in the product's own +CAD colours is the same kind of image set: | Render mode | Output | | --- | --- | @@ -446,6 +520,9 @@ Set an **Output Directory**, choose a **Render Mode**, and hit **Render All**. | **Clay (Workbench)** | Fast single-colour clay pass with cavity shading. Default. | | **Both** | Writes a `_render` and a `_clay` image per view. | +The **Variants** part is always a Full render, since textures are the point of it, and +**Patches** has no shading choice at all. + Every product is isolated in turn (all others excluded from the view layer), so nothing bleeds between shots. @@ -466,9 +543,11 @@ and does not slow your Full render down. #### Pre-flight check -The **Pre-flight** section under Batch Render looks for anything that would waste a render, +The **Pre-flight** section under Output looks for anything that would waste a render, before any render time is spent. **Check Batch** runs it on demand; with **On Render** ticked -(the default) it also runs when you press Render All. +(the default) it also runs when you press the render button. What it checks depends on the +part: the Patches part is not asked about products, cameras, views or lighting, because it +uses none of them. | Severity | Found | One-click fix | | --- | --- | --- | @@ -478,6 +557,8 @@ before any render time is spent. **Check Batch** runs it on demand; with **On Re | Error | No views selected, or no cameras with Reframe off | Build cameras | | Error | The file name template would give two images the same name | | | Error | AO pass on, but the Cycles add-on is off | | +| Error | The texture pool is empty, or textures it uses have moved | | +| Error | Patches with Workbench as the engine and the Cycles add-on off, so nothing can stand in for it | | | Warning | Output folder is `//` relative but the `.blend` was never saved, so `//` points nowhere useful | Save the file | | Warning | Output folder does not exist yet | Create it | | Warning | Cameras are out of date (Reframe off): a product was turned, scaled or added, or the views, padding or resolution changed since Build Cameras | Rebuild cameras | @@ -490,8 +571,8 @@ before any render time is spent. **Check Batch** runs it on demand; with **On Re | Warning | Shadow Catcher with an engine other than Cycles; HDRI file missing | | Each issue is listed in the panel with the product it concerns, and a wrench button where -there is a fix. Errors stop Render All with a message saying why. Warnings bring up a -dialog listing them, with **Render Anyway** and **Cancel**. Called from a script, Render All +there is a fix. Errors stop the render with a message saying why. Warnings bring up a +dialog listing them, with **Render Anyway** and **Cancel**. Called from a script, a render stops on errors the same way but never asks; warnings are left for the panel. Normals are checked by looking at every edge: two faces sharing an edge should run along it @@ -505,7 +586,7 @@ The batch runs one image at a time with Blender still responsive. The status bar far it has got, which image is next, and roughly how long is left: ``` -MultiView: 12/90 AA_03_Bracket Top ~4m 10s left - Esc to cancel +CADshot: 12/90 AA_03_Bracket Top ~4m 10s left - Esc to cancel ``` **Esc** stops the batch after the image currently rendering. The time left is an average of @@ -514,12 +595,15 @@ system console as it is written. #### Choosing what to render -The **Products to render** list shows every product under `MV_Products` with the label its -files will get. Untick a product to leave it out. The two buttons beside the list tick or -untick all of them, and the main button changes to **Render N Ticked** when some are off. +The **Render these** list in the **Products** panel shows every product under `MV_Products` +with the label its files will get. Untick a product to leave it out. The two buttons beside +the list tick or untick all of them, and the render button changes to **Render N Ticked** +when some are off. -**Render Active** renders only the product selected in the list, whether it is ticked or -not. Clicking a row also makes it the product **Orient Products** works on. +**Active**, beside the render button, renders only the product selected in that list, +whether it is ticked or not. Clicking a row also makes it the product **Orient Products** +works on, and in the Variants part its own material choice appears under the list — +**Keep Own** there leaves a product out of the texture draw. Leaving a product out never renumbers the rest. Labels come from each product's place in the whole list, so `AA_03_Knob` is still `AA_03` when `AA_02` is unticked, and re-rendering one @@ -567,7 +651,7 @@ Objects you have already switched off for rendering are not listed, so a deliber or staging rig does not keep setting the warning off. **Render engine, sample count, resolution, and file format stay in Blender's own Output and -Render Properties.** MultiView only drives cameras, visibility, and file paths. It never +Render Properties.** CADshot only drives cameras, visibility, and file paths. It never overwrites your render settings, and the clay pass restores your engine when it finishes. #### File naming @@ -611,7 +695,7 @@ change it with tokens: | `{product}` | `Widget` | The product collection's name | | `{view}` | `Front` | The camera view, or `Sheet` / `Turntable` | | `{mode}` | `clay` | `clay`, `render` (Both mode), or empty for Full Render | -| `{variant}` | `v01` | The Material Pool variant, empty for everything rendered once | +| `{variant}` | `v01` | The Texture Pool variant, empty for everything rendered once | The defaults are `{label}_{product}` for the folder and `{label}_{product}_{view}_{mode}` for the file. A token that comes out empty takes one separator next to it along with it, so @@ -624,7 +708,7 @@ the file. A token that comes out empty takes one separator next to it along with fields (the first image of the batch, as it will be written) instead of silently vanishing. - If the template would give two images the same name (no `{view}`, or no `{mode}` in Both mode), the render stops before it starts and says so. -- With Material Pool variants on, a file name template without `{variant}` gets `_{variant}` +- With Texture Pool variants on, a file name template without `{variant}` gets `_{variant}` added at the end, unless the folder template has `{variant}`, e.g. `{label}_{product}/{variant}`, which gives each variant a folder of its own. @@ -691,7 +775,7 @@ output directory, listing every output with: `label`, `product`, `kind` (render, processed, sheet, turntable, patch, ...), `view`, `mode`, `path` (relative to the output directory), `width`, `height`, `source_file`, `source_size`, -`source_units`, `source_sha256`, `variant` and `materials` (see Material Pool). +`source_units`, `source_sha256`, `variant` and `materials` (see Texture Pool). `source_file`, `source_size` and `source_units` are recorded when a file is imported: `source_size` is the product's bounding box before Fit on Import rescaled it, in @@ -705,7 +789,7 @@ since been deleted are dropped. Every row also carries `source_sha256`, the SHA-256 hash of the file it was rendered from. `manifest.json` opens with a `provenance` block holding enough to make the output again: -- MultiView's version, repository and citation +- CADshot's version, repository and citation - the Blender version and the `.blend` file - render engine, samples, resolution, file format and view transform - start and finish times @@ -717,7 +801,7 @@ description of the output as a dataset, which the NeurIPS datasets track asks fo - The manifest CSV is its record set, one field per column, with its checksum. - Each file type in the output (PNG, EXR, SVG, ...) is a file set. - Name, description, licence, URL and citation come from the fields under the checkbox. - Without a citation, the dataset cites MultiView itself. + Without a citation, the dataset cites CADshot itself. - The Responsible AI fields are filled with what the tool knows for certain: synthetic renders of the listed files, no personal data. Intended use and limitations are left for you to add. @@ -729,7 +813,7 @@ command-line run, `--merge` writes `croissant.json` for the merged whole. #### Annotations for research -The **Annotations** section under Batch Render turns a render into a dataset. Everything +The **Annotations** section under Dataset & research turns a render into a dataset. Everything here is computed from the same fixed camera rig as the images, so it lines up with them to the pixel. The test suite checks the camera numbers against Blender's own projection and the depth against ray casts. @@ -759,7 +843,7 @@ on a contact sheet. instead, since no K describes them. - `width`, `height`, `clip`, and the relative paths of every image of that view. - `model_to_world`: the 4x4 matrix from the product's **source file coordinates** (in - `source_units`) to the scene. MultiView records every move it makes to a product (fitting, + `source_units`) to the scene. CADshot records every move it makes to a product (fitting, recentring, turning, Auto Orient), so a pixel in any render can be traced back to a point in the original CAD file. Moves you make by hand in the viewport are not seen. @@ -782,7 +866,7 @@ separate SVGs (`AA_01_Widget_Front_drawing.svg`, ...), and all three on one shee (`AA_01_Widget_Drawing.svg`). - **True scale.** One SVG unit is one millimetre of the original part: a 120 mm housing - is drawn 120 mm wide at 1:1, whatever Target Size it was rendered at. MultiView takes back + is drawn 120 mm wide at 1:1, whatever Target Size it was rendered at. CADshot takes back out every scale it applied. - **Hidden lines dashed.** Lines are the mesh's feature edges, found geometrically: creases sharper than **Crease Angle**, silhouettes and open boundaries. Each is then tested @@ -810,7 +894,7 @@ a faithful outline, not a finished drawing. | **Camera Jitter**, **Distance Jitter** | Each view's camera swings up to this far off its canonical direction, and moves nearer or farther | | **Lights** | The rig turns about the vertical by up to **Light Turn**, and each light changes strength by up to **Light Strength** | | **HDRI** | Each image is lit and backed by a random HDRI from **HDRI Folder** (empty: the studio HDRIs that ship with Blender), turned and scaled at random | -| **Materials** | The product gets a random material preset and colour per image, in Full renders (not when Material Pool variants are on, which choose the materials instead) | +| **Materials** | The product gets a random material preset and colour per image, in Full renders (not when Texture Pool variants are on, which choose the materials instead) | | **Distractors** | Random shapes in random colours, placed behind the product as each camera sees it, in Full renders | Every draw comes from the **Seed**, the product's label and the view, so the same settings @@ -868,6 +952,14 @@ in. A job is JSON or TOML, and relative paths in it are relative to the job file | `render` | `engine`, `samples`, `device`, `resolution`, `percentage`, `file_format` | | `save_blend` | Save the set-up scene afterwards, to open and inspect in Blender | +`part` is one of those settings: `"part": "PATCHES"` renders the material patches of the +pool and nothing else, needing no `inputs` at all. Setting a part also sets `render_mode`, +`pool_variants` and `pool_patches` to match, so if a job names both, put `part` first: + +```json +{ "output": "swatches/", "settings": { "part": "PATCHES", "pool_folder": "textures/" } } +``` + Other modes: - `--check` imports and sets up, runs the pre-flight check, prints its findings and renders @@ -904,22 +996,34 @@ products, and any other world in the file, are never touched. ## Troubleshooting -**"No cameras. Build cameras first."**: run *Build Cameras* in the Cameras / Views panel, or +**"No cameras. Build cameras first."**: run *Build Cameras* in the Views panel, or switch **Reframe Before Rendering** back on. **"No views selected."**: the view set is **Custom** with nothing ticked (and no enabled custom views). Tick at least one view. -**"No products are ticked for rendering."**: tick products in the **Products to render** -list, or use **Render Active** for the selected one. +**"No products are ticked for rendering."**: tick products in the **Render these** list in +the Products panel, or use **Active** for the selected one. + +**A panel I am looking for is not in the sidebar**: check the **Part** switcher at the top. +Each part hides the panels it cannot use — Patches has no Products, Standardize, Views or +Stage, and Clay has no Texture Pool or Variants. See [Three parts](#three-parts). + +**"The material pool is empty: scan a texture folder."**: the Patches and Variants parts +need a **Texture Folder** with at least one sub-folder holding a colour map. Point it at the +library and press the refresh button beside it. + +**Lighting and Ground are missing from Stage**: nothing in the run is lit by the scene. +Clay uses Workbench's own studio light and leaves the ground out. Set Clay's **Shading** to +Full or Both, or switch to the Variants part, and they come back. **A cancelled or crashed batch**: tick **Skip Existing** and render again. Only the images that are missing get rendered. -**Renders come back blank, or with something unexpected in them**: check the warning above -**Render All**. Anything outside `MV_Products` is rendered along with every product, and a -large object near the origin can hide the product completely. Delete it, move it into a -product collection, or turn off its render visibility. +**Renders come back blank, or with something unexpected in them**: check the warning at the +bottom of the Products panel. Anything outside `MV_Products` is rendered along with every +product, and a large object near the origin can hide the product completely. Delete it, move +it into a product collection, or turn off its render visibility. **"No product collections under 'MV_Products'."**: import a STEP or STL file, or add an empty product collection and put your meshes inside it. Meshes sitting loose in the scene @@ -932,12 +1036,12 @@ to see the subprocess output. **Products are all different sizes in the renders**: run *Standardize Products* in the Standardize panel with **Rescale** enabled. -**The "Front" view isn't the front**: change **Front Axis** in Cameras / Views and rebuild +**The "Front" view isn't the front**: change **Front Axis** in Views and rebuild the cameras. If only some products are wrong, Front Axis will not help, because it moves the whole batch. Use *Orient Products* to turn the offenders individually, then rebuild. **Only one product is visible in the viewport**: **Isolate Active Product** is on, which is -the default while orienting. Click **Show All** in Cameras / Views, or untick the box. +the default while orienting. Click **Show All** under Views > Orient Products, or untick the box. Renders are unaffected either way. **Renders are cropped after rotating products**: rotating changes the bounding box the diff --git a/multiview_product_renderer/__init__.py b/multiview_product_renderer/__init__.py index f1da4cb..5d4cba7 100644 --- a/multiview_product_renderer/__init__.py +++ b/multiview_product_renderer/__init__.py @@ -1,10 +1,10 @@ bl_info = { - "name": "MultiView Product Renderer", + "name": "CADshot", "author": "Danyal Sarfraz", - "version": (1, 12, 0), + "version": (1, 13, 0), "blender": (4, 0, 0), - "location": "View3D > Sidebar > MultiView", - "description": "Import CAD and mesh files, set up cameras and studio lighting, batch render products across views.", + "location": "View3D > Sidebar > CADshot", + "description": "Three image sets from CAD: clay views of the form, patches of the materials, and products rendered in every texture.", "category": "Render", } @@ -125,6 +125,8 @@ # are not here; neither are empties, which only carry a transform. GEOMETRY_TYPES = {'MESH', 'CURVE', 'SURFACE', 'META', 'FONT'} +TOOL_NAME = "CADshot" + CAMERA_COLL = "MV_Cameras" LIGHT_COLL = "MV_Lighting" PRODUCT_COLL = "MV_Products" @@ -133,41 +135,82 @@ WORLD_NAME = "MV_World" GROUND_NAME = "MV_Ground" -GUIDE_LINES = [ - "1. Scene Setup: pick lighting (or save your own with +), a colour or", - " HDRI background and a ground, then Apply. Materials sets preset", - " materials for Full renders, for the batch or one product.", - "2. Import: add files or a whole folder, then click Import. STEP and", - " IGES need FreeCAD (STEP can use STEPper); STL, OBJ, PLY, glTF,", - " FBX and 3MF are meshes and import directly. Each file becomes a", - " collection under MV_Products; STEP parts keep names and colours.", - " You can also create empty product collections and drop meshes in.", - "3. Standardize: centre and scale every product, to one size each or", - " keeping their real proportions, and fix the faceted shading a CAD", - " tessellation arrives with. Fit on Import does it as each file lands.", - "4. Cameras: pick a view set (or Custom and tick views), front axis,", - " click Build. Then use Orient Products to walk the batch one at a", - " time: Auto Orient lays products flat, Reset undoes any turning.", - "5. Render: set output folder, mode (full/clay/both), starting index,", - " tick the products you want, then Render All or Render Active.", - " Pre-flight checks the batch first and offers one-click fixes.", - " Esc stops after the current image; Skip Existing resumes.", - " Cameras are reframed first. Files are named PREFIX_NN_Product_View,", - " or however the Naming templates say.", - " Optional extras under Batch Render: contact sheets, turntables", - " (MP4/GIF), post-processed copies, and a CSV/JSON manifest.", - " Annotations adds cameras.json/transforms.json, depth, normals,", - " part IDs, COCO and BOP for building datasets. Drawings writes", - " 1:1 SVG three-views with hidden lines; Randomize varies images", - " for training data, seeded. Cameras > Protocol matches published rigs.", +# One short guide per part, since each uses a different half of the tool and +# a single list of steps would describe panels the active part is not showing. +PART_GUIDES = { + 'CLAY': [ + "Clay views: each product's form, from every view, with", + "no materials and nothing to read around it.", + "", + "1. Products: add files or a folder, click Import. STEP and", + " IGES need FreeCAD (STEP can use STEPper); STL, OBJ, PLY,", + " glTF, FBX and 3MF import directly. Each file becomes a", + " collection under MV_Products, parts keeping CAD names.", + " Or add an empty product collection and drop meshes in.", + "2. Standardize: centre and scale every product, to one size", + " each or keeping their real proportions, and fix the", + " faceted shading a CAD tessellation arrives with.", + "3. Views: pick a view set and front axis, click Build.", + " Orient Products walks the batch one at a time: Auto", + " Orient lays each flat, Reset undoes any turning.", + "4. Output: set the folder, tick the products you want, then", + " Render Clay Views. Esc stops after the current image;", + " Skip Existing resumes an interrupted run.", + "", + "Workbench draws clay with its own studio light, and the", + "ground is left out of it, so neither needs setting up.", + ], + 'PATCHES': [ + "Material patches: one image per material in the pool, a", + "flat swatch beside the same material on a lit ball.", + "", + "1. Texture Pool: point Texture Folder at a library with one", + " sub-folder per material, as downloaded from ambientCG or", + " Poly Haven, and click the refresh button. Every folder", + " with a colour map becomes a material. Untick any you do", + " not want. Texture Size is how much of the real part one", + " repeat covers, in millimetres.", + "2. Patch: set the size of each half in pixels.", + "3. Output: set the folder, then Render Material Patches.", + " They are written to materials/ inside it.", + "", + "This part needs no product, no camera and no lighting:", + "patches are rendered in a scene of their own, and yours is", + "left exactly as it was.", + ], + 'VARIANTS': [ + "Texture variants: every product rendered several times", + "over, each with a different draw from the pool.", + "", + "1. Products: import as for clay views. A product set to", + " Keep Own is left out and renders once, in its own", + " materials.", + "2. Standardize and 3. Views: as for clay views.", + "4. Stage: lighting, background and ground all show here,", + " because these are Full renders.", + "5. Texture Pool: scan a texture library, as for patches.", + "6. Variants: how many draws per product, the seed, and", + " whether each part draws its own material or the whole", + " product shares one.", + "7. Output: Render Texture Variants. Files get a _v01, _v02", + " suffix, and the manifest records what each one wears.", + "", + "Draws come from the seed, the product and the part, so a", + "re-run gives the same variants. Adding products, variants", + "or parts never changes the other draws.", + ], +} + +GUIDE_FOOTER = [ "", "Headless: blender -b --python multiview_product_renderer/cli.py", " -- --config job.json (see README, Command line)", - " Only products are hidden between shots, so anything outside", - " MV_Products lands in every image. The panel warns you first.", "", - "Render engine, samples, resolution and file format stay under your", - "control in Blender's Output and Render properties.", + "Only products are hidden between shots, so anything outside", + "MV_Products lands in every image. The panel warns you first.", + "", + "Render engine, samples, resolution and file format stay under", + "your control in Blender's Output and Render properties.", ] @@ -257,7 +300,7 @@ def _uniform_sphere(n, rng): 'GS1_15': { "label": "Catalogue, 15 degree plunge", "source": "After the GS1 Product Image Specification's preferred 15 degree plunge for " - "3D images; view names are MultiView's, not GS1 file codes", + "3D images; the view names are this add-on's, not GS1 file codes", "make": lambda rng: [("Front_0", sphere_dir(0, 0), None), ("Front_15", sphere_dir(0, 15), None), ("FrontRight_15", sphere_dir(45, 15), None), @@ -461,7 +504,75 @@ def _update_view_set(self, context): self.views_enabled = [v[0] in names for v in VIEWS_ALL] +# -- the three parts --------------------------------------------------------- +# +# The add-on makes three kinds of image set, and each needs a different half +# of the tool. Clay views need products and a camera rig and ignore materials +# altogether. Material patches need the texture pool and nothing else -- no +# product, no camera, no lighting. Texture variants need both halves at once. +# The sidebar shows one part at a time: every panel polls on the active part, +# so a mode only ever shows what it can actually use. +# +# The part is not where the render state lives. It writes through to +# render_mode, pool_variants and pool_patches, which stay the properties the +# renderer, the command line, the manifest and the provenance snapshot read, +# so nothing downstream has to learn that parts exist. + +PART_ITEMS = [ + ('CLAY', "Clay", "Multiple clay views of each product: its form on its own, " + "with no materials and no lighting to read around"), + ('PATCHES', "Patches", "One image per pool material: a flat swatch beside a lit " + "ball. Needs only the texture pool"), + ('VARIANTS', "Variants", "Every product rendered several times over, each with a " + "different draw of materials from the texture pool"), +] +# What each part is a dataset of, in the vocabulary of the paper. +PART_FOCUS = {'CLAY': "Form", 'PATCHES': "CMF", 'VARIANTS': "CMF x Form"} +# The render button's wording, and what the part is called in a message. +PART_LABELS = {'CLAY': "Clay Views", 'PATCHES': "Material Patches", + 'VARIANTS': "Texture Variants"} +DEFAULT_VARIANTS = 4 + + +def _update_part(self, context): + """Point the render state at the part just picked. + + This runs only when the part changes, so it sets a sensible starting + point rather than holding the mode's settings hostage: Clay can still be + switched to Full or Both afterwards from its own Output panel. What it + does guarantee is that a clay run cannot inherit a variant count left + behind by an earlier texture run, or the other way about. + + A job file may set these properties directly and never name a part. If it + sets both, the part should come first, since this writes over what it + names -- except the variant count, which is only filled in when nothing + has asked for one. + """ + if self.part == 'CLAY': + self.render_mode = 'CLAY' + self.pool_variants = 0 + self.pool_patches = False + elif self.part == 'PATCHES': + self.pool_patches = True + self.pool_variants = 0 + else: + self.render_mode = 'FULL' + self.pool_patches = False + if self.pool_variants < 1: + self.pool_variants = DEFAULT_VARIANTS + + class MV_Settings(PropertyGroup): + part: EnumProperty( + name="Part", + description="Which of the three image sets to work on. The sidebar shows only " + "the panels that part uses, and picking one sets the render mode " + "and variant count to match", + items=PART_ITEMS, + default='CLAY', + update=_update_part, + ) + step_files: CollectionProperty(type=MV_StepFileItem) step_files_index: IntProperty(default=0) @@ -814,7 +925,7 @@ class MV_Settings(PropertyGroup): name="License", description="Licence of the rendered dataset, e.g. CC-BY-4.0 or a URL") dataset_url: StringProperty(name="URL", description="Where the dataset is published") dataset_cite: StringProperty( - name="Cite As", description="BibTeX for the dataset. Empty cites MultiView itself") + name="Cite As", description="BibTeX for the dataset. Empty cites the tool itself") shard_suffix: StringProperty( description="Added to manifest, COCO and BOP model-info file names by the " "command line, so shards rendered in parallel do not overwrite " @@ -3913,7 +4024,7 @@ def _advance(self, context, blocking): def _status(self): name = os.path.basename(self._job_path) if self._job_path else "" waiting = " (converting)" if self._job is not None else "" - return f"MultiView: {self._done}/{self._total} {name}{waiting} - Esc to cancel" + return f"{TOOL_NAME}: {self._done}/{self._total} {name}{waiting} - Esc to cancel" def _report_result(self): if self._rescale_batch: @@ -3936,7 +4047,7 @@ def _report_result(self): if len(self._problems) > 2: msg += f" | +{len(self._problems) - 2} more (see system console)" for problem in self._problems: - print("[MultiView] " + problem) + print(f"[{TOOL_NAME}] " + problem) self.report(level, msg) return {'FINISHED'} if self._imported else {'CANCELLED'} @@ -4109,7 +4220,7 @@ def execute(self, context): written += 1 except (RuntimeError, OSError) as exc: failed.append(f"{name}: {exc}") - print(f"[MultiView] patch FAILED {name}: {exc}") + print(f"[{TOOL_NAME}] patch FAILED {name}: {exc}") remove_pool_materials() if failed: self.report({'WARNING'}, f"Wrote {written} patch(es); {len(failed)} failed, first: " @@ -5047,7 +5158,7 @@ def path(lines, dx, dy): out = [f'', f"{title}", - f'Scale 1:1, units {unit}. Generated by MultiView Product Renderer.', + f'Scale 1:1, units {unit}. Generated by {TOOL_NAME}.', f''] for lines, dx, dy in placed: if show_hidden and lines["hidden"]: @@ -5804,9 +5915,9 @@ def tool_version(): def tool_citation(): - return ("@software{sarfraz_multiview_product_renderer, author = {Sarfraz, Danyal}, " - "title = {MultiView Product Renderer}, version = {%s}, url = {%s}, " - "license = {MIT}}" % (tool_version(), REPOSITORY_URL)) + return ("@software{sarfraz_cadshot, author = {Sarfraz, Danyal}, " + "title = {%s}, version = {%s}, url = {%s}, " + "license = {MIT}}" % (TOOL_NAME, tool_version(), REPOSITORY_URL)) def file_sha256(path): @@ -5849,7 +5960,7 @@ def provenance(context, started): elif hasattr(scene, "eevee"): samples = getattr(scene.eevee, "taa_render_samples", None) return { - "tool": "MultiView Product Renderer", "version": tool_version(), + "tool": TOOL_NAME, "version": tool_version(), "repository": REPOSITORY_URL, "citeAs": tool_citation(), "blender": bpy.app.version_string, "blend_file": bpy.data.filepath or None, "engine": engine, "samples": samples, @@ -5920,7 +6031,7 @@ def write_croissant(out_root, manifest_name, meta): "@context": CROISSANT_CONTEXT, "@type": "sc:Dataset", "name": meta.get("name") or os.path.basename(os.path.normpath(out_root)), "description": meta.get("description") or - "Multi-view renders made with MultiView Product Renderer.", + f"Multi-view renders made with {TOOL_NAME}.", "conformsTo": "http://mlcommons.org/croissant/1.0", "citeAs": meta.get("citeAs") or tool_citation(), "datePublished": datetime.now().date().isoformat(), @@ -5929,8 +6040,8 @@ def write_croissant(out_root, manifest_name, meta): "recordSet": [{"@type": "cr:RecordSet", "@id": "outputs", "name": "outputs", "description": "One row per file written, from the manifest.", "field": fields}], - "rai:dataCollection": "Synthetic images rendered in Blender by MultiView Product " - "Renderer from the CAD and mesh files named in source_file; " + "rai:dataCollection": f"Synthetic images rendered in Blender by {TOOL_NAME} " + "from the CAD and mesh files named in source_file; " "the settings used are in the manifest's provenance.", "rai:dataCollectionType": "Synthetic data", "rai:personalSensitiveInformation": "None: renders of manufactured products.", @@ -6126,6 +6237,18 @@ def _random_colour(rng): return colorsys.hsv_to_rgb(rng.random(), rng.uniform(0.1, 0.9), rng.uniform(0.15, 0.95)) +def needs_lighting(settings): + """True when something in this run is lit by the scene. + + Only a Full render reads the lamps, the world and the ground: clay comes + with Workbench's own studio light, and the line, mask, AO and data passes + each replace the lighting outright. The Stage panel asks this before + showing lighting controls, and pre-flight before warning about them. + """ + return (settings.render_mode in {'FULL', 'BOTH'} + or (settings.turntable_enabled and settings.turntable_style == 'FULL')) + + def render_modes(settings): """(mode, render function, extra arguments) for every image each view gets.""" s = settings @@ -6262,12 +6385,18 @@ def preflight(context, only_active=False): s = scene.mv_settings issues = [] products = get_products() + # The Patches part renders the pool into a scene of its own, so the + # product, camera, view, naming and lighting checks below have nothing to + # say about it. Only the pool and the output folder matter. + patches_only = s.part == 'PATCHES' # What will be rendered. - if not products: + if patches_only: + targets = [] + elif not products: _issue(issues, 'ERROR', f"No products under {PRODUCT_COLL}") return issues - if only_active: + elif only_active: coll, idx = active_product(context) targets = [(label_index(idx, coll), coll)] else: @@ -6278,7 +6407,8 @@ def preflight(context, only_active=False): # Output. image = scene.render.image_settings - if image.file_format == 'FFMPEG' or getattr(image, "media_type", 'IMAGE') == 'VIDEO': + if not patches_only and (image.file_format == 'FFMPEG' + or getattr(image, "media_type", 'IMAGE') == 'VIDEO'): _issue(issues, 'ERROR', "Output format is video, not image") raw = s.render_output_dir # Absolute before probing: in an unsaved file // resolves to a relative @@ -6300,34 +6430,35 @@ def preflight(context, only_active=False): elif probe != out and not os.path.isdir(out): _issue(issues, 'WARNING', "Output folder does not exist yet", fix='FOLDER') - # Cameras and views. - if s.reframe_on_render: - if not [v for v in planned_views(s) if Vector(v[2]).length >= 1e-9]: - _issue(issues, 'ERROR', "No views selected") - else: - cam_coll = bpy.data.collections.get(CAMERA_COLL) - if not cam_coll or not [o for o in cam_coll.objects if o.type == 'CAMERA']: - _issue(issues, 'ERROR', "No cameras (build, or Reframe)", fix='CAMERAS') - elif cam_coll.get("mv_framing") != framing_signature(context): - _issue(issues, 'WARNING', "Cameras out of date: may crop", - fix='CAMERAS') - - # Names. - views = ([v[0] for v in planned_views(s)] if s.reframe_on_render else - [o.name.replace("MV_Cam_", "") for o in - getattr(bpy.data.collections.get(CAMERA_COLL), "objects", []) - if o.type == 'CAMERA']) - if _names_clash(s, targets, views + (["Sheet"] if s.sheet_enabled else []), - [m for m, _, _ in render_modes(s)] + [m for m, _, _ in data_modes(s)]): - _issue(issues, 'ERROR', "Template gives two images one name") - - # Engine-specific passes and staging. - if s.pass_ao and not hasattr(scene, "cycles"): - _issue(issues, 'ERROR', "AO pass needs the Cycles add-on") - if (data_modes(s) or s.export_drawings) and not hasattr(scene, "cycles"): - _issue(issues, 'ERROR', "Data passes and drawings need Cycles") - if s.random_enabled and s.random_hdri and not hdri_files(s): - _issue(issues, 'WARNING', "Randomize: no HDRIs in the HDRI folder") + if not patches_only: + # Cameras and views. + if s.reframe_on_render: + if not [v for v in planned_views(s) if Vector(v[2]).length >= 1e-9]: + _issue(issues, 'ERROR', "No views selected") + else: + cam_coll = bpy.data.collections.get(CAMERA_COLL) + if not cam_coll or not [o for o in cam_coll.objects if o.type == 'CAMERA']: + _issue(issues, 'ERROR', "No cameras (build, or Reframe)", fix='CAMERAS') + elif cam_coll.get("mv_framing") != framing_signature(context): + _issue(issues, 'WARNING', "Cameras out of date: may crop", + fix='CAMERAS') + + # Names. + views = ([v[0] for v in planned_views(s)] if s.reframe_on_render else + [o.name.replace("MV_Cam_", "") for o in + getattr(bpy.data.collections.get(CAMERA_COLL), "objects", []) + if o.type == 'CAMERA']) + if _names_clash(s, targets, views + (["Sheet"] if s.sheet_enabled else []), + [m for m, _, _ in render_modes(s)] + [m for m, _, _ in data_modes(s)]): + _issue(issues, 'ERROR', "Template gives two images one name") + + # Engine-specific passes and staging. + if s.pass_ao and not hasattr(scene, "cycles"): + _issue(issues, 'ERROR', "AO pass needs the Cycles add-on") + if (data_modes(s) or s.export_drawings) and not hasattr(scene, "cycles"): + _issue(issues, 'ERROR', "Data passes and drawings need Cycles") + if s.random_enabled and s.random_hdri and not hdri_files(s): + _issue(issues, 'WARNING', "Randomize: no HDRIs in the HDRI folder") if s.pool_variants or s.pool_patches: refresh_pool(s) missing = pool_missing(s) @@ -6342,8 +6473,18 @@ def preflight(context, only_active=False): _issue(issues, 'WARNING', "Variants only change Full renders") elif scene.render.engine == 'BLENDER_WORKBENCH': _issue(issues, 'WARNING', "Workbench barely shows textures") + # A patch stands in Cycles when the scene's engine is Workbench, which + # cannot show a texture. Without the Cycles add-on there is nothing to + # stand in with, and the swap would raise mid-run instead. + if s.pool_patches and scene.render.engine == 'BLENDER_WORKBENCH' \ + and not hasattr(scene, "cycles"): + _issue(issues, 'ERROR', "Patches need Cycles while the engine is Workbench") if s.export_croissant and not s.dataset_license: _issue(issues, 'WARNING', "Croissant: dataset has no licence set") + + if patches_only: + return issues + if s.export_bop: if s.reframe_on_render: persp = [v for v in planned_views(s) if v[1] == "persp"] @@ -6352,8 +6493,7 @@ def preflight(context, only_active=False): if o.type == 'CAMERA' and o.data.type == 'PERSP'] if not persp: _issue(issues, 'WARNING', "BOP uses perspective views; none selected") - full = any(fn is _do_full_render for _, fn, _ in render_modes(s)) or ( - s.turntable_enabled and s.turntable_style == 'FULL') + full = needs_lighting(s) if full and s.ground_mode == 'SHADOW' and scene.render.engine != 'CYCLES': _issue(issues, 'WARNING', "Shadow Catcher needs Cycles") if full and s.world_mode == 'HDRI' and not os.path.isfile(bpy.path.abspath(s.hdri_path)): @@ -6535,9 +6675,13 @@ class MV_OT_RenderAll(Operator): @classmethod def description(cls, context, properties): + part = context.scene.mv_settings.part + if part == 'PATCHES': + return "Render one patch for every ticked material in the pool" + what = PART_LABELS[part].lower() if properties.only_active: - return "Render only the active product, from every camera" - return "Render every ticked product, from every camera" + return f"Render {what} of the active product only, from every camera" + return f"Render {what} of every ticked product, from every camera" # -- state ------------------------------------------------------------ def _save_state(self, context, products): @@ -6931,8 +7075,10 @@ def _plan(self, context, targets, cameras): # Patches belong to the pool, not to a product: with shards, the first # shard makes them all and the others leave them alone, and rendering - # just the active product leaves them alone too. - if s.pool_patches and s.shard_suffix in {"", "_shard00"} and not self.only_active: + # just the active product leaves them alone too -- unless patches are + # the whole point of the run, which is what the Patches part asks for. + if s.pool_patches and s.shard_suffix in {"", "_shard00"} \ + and (self._patches_only or not self.only_active): for pool_name, maps in pool_entries(s): path = os.path.join(root, "materials", safe_name(pool_name) + "_patch.png") self._claim(path) @@ -7052,12 +7198,23 @@ def _setup(self, context): self._cancelled = False self._aborted = False + # The Patches part renders the pool, not the products: a run needs no + # product, no camera and no stage, and planning one is the whole job. + # Going through here rather than through mv.render_patches is what + # gives patches the progress bar, Esc, Skip Existing and the manifest + # rows every other output already had. + self._patches_only = s.part == 'PATCHES' + products = get_products() - if not products: + if self._patches_only: + targets = [] + if not pool_entries(s): + self.report({'ERROR'}, "The material pool is empty: scan a texture folder.") + return {'CANCELLED'} + elif not products: self.report({'ERROR'}, f"No product collections under '{PRODUCT_COLL}'.") return {'CANCELLED'} - - if self.only_active: + elif self.only_active: coll, idx = active_product(context) targets = [(label_index(idx, coll), coll)] else: @@ -7079,7 +7236,7 @@ def _setup(self, context): self.report({'ERROR'}, "Every product to render is empty.") return {'CANCELLED'} - if s.reframe_on_render and not planned_views(s): + if s.reframe_on_render and not self._patches_only and not planned_views(s): self.report({'ERROR'}, "No views selected. Tick at least one view.") return {'CANCELLED'} @@ -7100,35 +7257,39 @@ def _setup(self, context): remove_product_materials() context.view_layer.update() - # Rebuilt every run so it always matches the panel, and so Ground set - # to None clears one an earlier run left behind. - if s.ground_mode != 'NONE' or bpy.data.objects.get(GROUND_NAME): - ground = build_ground(context) - if ground is not None: - self._ground_home = ground.location.copy() - if s.ground_mode == 'SHADOW' and scene.render.engine != 'CYCLES': - self._notes.append("Shadow Catcher needs Cycles; other engines " - "show the ground as a plain floor.") - self._random_setup(context) - if s.lights_follow_camera and bpy.data.collections.get(LIGHT_COLL) \ - and bpy.data.objects.get(LIGHT_RIG) is None: - self._notes.append("Lights Follow Camera needs the lighting rebuilt once " - "(Scene Setup > Lighting Only).") - - if s.reframe_on_render: - # Framing covers the whole batch, ticked or not, so re-rendering a - # single product later lands at the same scale as its run. - skipped = build_cameras(context) - if skipped: - self._notes.append("Skipped custom view(s) with a zero direction: " - + ", ".join(skipped) + ".") + # Patches are rendered in a scene of their own, so none of the staging + # below reaches them. + cameras = [] + if not self._patches_only: + # Rebuilt every run so it always matches the panel, and so Ground set + # to None clears one an earlier run left behind. + if s.ground_mode != 'NONE' or bpy.data.objects.get(GROUND_NAME): + ground = build_ground(context) + if ground is not None: + self._ground_home = ground.location.copy() + if s.ground_mode == 'SHADOW' and scene.render.engine != 'CYCLES': + self._notes.append("Shadow Catcher needs Cycles; other engines " + "show the ground as a plain floor.") + self._random_setup(context) + if s.lights_follow_camera and bpy.data.collections.get(LIGHT_COLL) \ + and bpy.data.objects.get(LIGHT_RIG) is None: + self._notes.append("Lights Follow Camera needs the lighting rebuilt once " + "(Views > Stage > Lighting Only).") + + if s.reframe_on_render: + # Framing covers the whole batch, ticked or not, so re-rendering a + # single product later lands at the same scale as its run. + skipped = build_cameras(context) + if skipped: + self._notes.append("Skipped custom view(s) with a zero direction: " + + ", ".join(skipped) + ".") - cam_coll = bpy.data.collections.get(CAMERA_COLL) - cameras = [o for o in cam_coll.objects if o.type == 'CAMERA'] if cam_coll else [] - if not cameras: - self._restore(context) - self.report({'ERROR'}, "No cameras. Build cameras first.") - return {'CANCELLED'} + cam_coll = bpy.data.collections.get(CAMERA_COLL) + cameras = [o for o in cam_coll.objects if o.type == 'CAMERA'] if cam_coll else [] + if not cameras: + self._restore(context) + self.report({'ERROR'}, "No cameras. Build cameras first.") + return {'CANCELLED'} try: self._plan(context, targets, cameras) @@ -7166,7 +7327,7 @@ def _fail(self, path, exc): """ note = f"{os.path.basename(path)}: {exc}" self._failures.append(note) - print(f"[MultiView] FAILED {note}") + print(f"[{TOOL_NAME}] FAILED {note}") if not self._landed: self._aborted = True self._queue.clear() @@ -7195,7 +7356,7 @@ def _render_image(self, context, job, camera): self._render_time += time.monotonic() - started self._renders_done += 1 self._done += 1 - print(f"[MultiView] {self._processed()}/{self._total}: {scene.render.filepath}") + print(f"[{TOOL_NAME}] {self._processed()}/{self._total}: {scene.render.filepath}") def _job_render(self, context, job): self._render_image(context, job, job["camera"]) @@ -7258,7 +7419,7 @@ def rel(path): frames.append(frame) payload = { - "generator": f"MultiView Product Renderer {'.'.join(map(str, bl_info['version']))}", + "generator": f"{TOOL_NAME} {'.'.join(map(str, bl_info['version']))}", "blender": bpy.app.version_string, "label": meta["label"], "product": meta["product"], "source_file": meta.get("source_file", ""), @@ -7542,7 +7703,7 @@ def _job_coco(self, context, job): for i, ann in enumerate(annotations): ann["id"] = i + 1 self._write_json(job["path"], { - "info": {"description": "MultiView Product Renderer part instances", + "info": {"description": f"{TOOL_NAME} part instances", "date_created": datetime.now().isoformat(timespec="seconds")}, "images": images, "annotations": annotations, "categories": sorted(categories.values(), key=lambda c: c["id"]), @@ -7673,7 +7834,7 @@ def _processed(self): return self._total - len(self._queue) def _status(self): - text = f"MultiView: {self._processed()}/{self._total}" + text = f"{TOOL_NAME}: {self._processed()}/{self._total}" if self._queue: text += f" {self._queue[0]['status']}" if self._renders_done: @@ -7903,148 +8064,59 @@ def draw_item(self, context, layout, data, item, icon, active_data, active_propn sub.label(text=f"{label} {item.name}", icon='OUTLINER_COLLECTION') -class MV_PT_Guide(Panel): - bl_label = "Guide" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" - bl_options = {'DEFAULT_CLOSED'} +# -- panels ------------------------------------------------------------------- +# +# The sidebar shows one part at a time: every panel polls on the active part, +# so Clay never shows a texture control and Patches never shows a camera one. +# A panel whose poll fails takes its sub-panels with it, which is what gets +# the Patches screen down to three panels rather than eighteen. +# +# Lighting, the ground and the base material poll on needs_lighting() rather +# than on the part, because Clay can be switched to Full or Both from its own +# Output panel, and then they matter again. - def draw(self, context): - layout = self.layout - box = layout.box() - for line in GUIDE_LINES: - box.label(text=line) +PRODUCT_PARTS = {'CLAY', 'VARIANTS'} +POOL_PARTS = {'PATCHES', 'VARIANTS'} -class MV_PT_SceneSetup(Panel): - bl_label = "Scene Setup" +class _Sidebar: bl_space_type = 'VIEW_3D' bl_region_type = 'UI' - bl_category = "MultiView" + bl_category = "CADshot" - def draw(self, context): - layout = self.layout - s = context.scene.mv_settings - col = layout.column(align=True) - col.label(text="Lighting:") - row = col.row(align=True) - row.prop(s, "lighting_preset", text="") - row.operator("mv.save_lighting_preset", text="", icon='ADD') - row.operator("mv.delete_lighting_preset", text="", icon='REMOVE') - col.prop(s, "lighting_intensity", slider=True) - layout.prop(s, "lights_follow_camera") +class _ProductPanel(_Sidebar): + """Shown for the two parts that photograph products.""" - col = layout.column(align=True) - col.label(text="Background:") - col.row(align=True).prop(s, "world_mode", expand=True) - if s.world_mode == 'HDRI': - col.prop(s, "hdri_path", text="") - row = col.row(align=True) - row.prop(s, "hdri_strength") - row.prop(s, "hdri_rotation") - col.prop(s, "hdri_visible") - col.prop(s, "transparent_bg") - row = col.row() - # The colour still shows behind an HDRI the camera is not shown. - row.enabled = not s.transparent_bg and not (s.world_mode == 'HDRI' and s.hdri_visible) - row.prop(s, "bg_color", text="") - - col = layout.column(align=True) - col.label(text="Ground (Full renders):") - col.prop(s, "ground_mode", text="") - if s.ground_mode == 'FLOOR': - row = col.row(align=True) - row.prop(s, "ground_color", text="") - row.prop(s, "ground_roughness") - elif s.ground_mode == 'SHADOW' and context.scene.render.engine != 'CYCLES': - col.label(text="Shadow Catcher needs Cycles.", icon='ERROR') - - layout.separator() - layout.operator("mv.apply_scene_setup", icon='SCENE_DATA') - - row = layout.row(align=True) - row.operator("mv.build_lighting", text="Lighting Only", icon='LIGHT') - row.operator("mv.apply_world", text="Background Only", icon='WORLD') + @classmethod + def poll(cls, context): + return context.scene.mv_settings.part in PRODUCT_PARTS -class MV_PT_SceneMaterials(Panel): - bl_label = "Materials" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" - bl_parent_id = "MV_PT_SceneSetup" - bl_options = {'DEFAULT_CLOSED'} +class MV_PT_Mode(_Sidebar, Panel): + bl_label = "Part" def draw(self, context): layout = self.layout s = context.scene.mv_settings - layout.label(text="Whole batch:") - row = layout.row(align=True) - row.prop(s, "material_preset", text="") - sub = row.row(align=True) - sub.active = s.material_preset != 'KEEP' - sub.prop(s, "material_color", text="") - - coll, _ = active_product(context) - if coll is None: - return - layout.label(text=f"{coll.name}:", icon='OUTLINER_COLLECTION') - row = layout.row(align=True) - row.prop(coll, "mv_material", text="") - sub = row.row(align=True) - sub.active = coll.mv_material not in {'DEFAULT', 'KEEP'} - sub.prop(coll, "mv_material_color", text="") - layout.label(text="Full renders only. Pick a product in", icon='INFO') - layout.label(text="Products to render to set its own.") + layout.row(align=True).prop(s, "part", expand=True) + row = layout.row() + row.alignment = 'CENTER' + row.label(text=f"{PART_LABELS[s.part]} ({PART_FOCUS[s.part]})") -class MV_PT_ScenePool(Panel): - bl_label = "Material Pool" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" - bl_parent_id = "MV_PT_SceneSetup" +class MV_PT_Guide(_Sidebar, Panel): + bl_label = "Guide" bl_options = {'DEFAULT_CLOSED'} def draw(self, context): - layout = self.layout - s = context.scene.mv_settings - row = layout.row(align=True) - row.prop(s, "pool_folder", text="") - row.operator("mv.scan_pool", text="", icon='FILE_REFRESH') - if s.material_pool: - row = layout.row() - row.template_list("MV_UL_Pool", "", s, "material_pool", s, "material_pool_index", - rows=4) - col = row.column(align=True) - col.operator("mv.set_pool", text="", icon='CHECKBOX_HLT').state = True - col.operator("mv.set_pool", text="", icon='CHECKBOX_DEHLT').state = False - layout.label(text="C colour R rough M metal A packed H height N normal", - icon='INFO') - layout.prop(s, "pool_tile_mm") - - box = layout.box() - box.label(text="Product variants (Full renders):") - row = box.row(align=True) - row.prop(s, "pool_variants") - row.prop(s, "pool_seed") - box.prop(s, "pool_per_part") - - box = layout.box() - box.label(text="Material patches:") - row = box.row(align=True) - row.prop(s, "pool_patches", text="With Render All") - row.prop(s, "patch_size", text="Size") - box.operator("mv.render_patches", icon='MATSPHERE') + box = self.layout.box() + for line in PART_GUIDES[context.scene.mv_settings.part] + GUIDE_FOOTER: + box.label(text=line) -class MV_PT_Import(Panel): - bl_label = "Import Products" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" +class MV_PT_Products(_ProductPanel, Panel): + bl_label = "Products" def draw(self, context): layout = self.layout @@ -8052,7 +8124,7 @@ def draw(self, context): addon = context.preferences.addons.get(__name__) box = layout.box() - box.label(text="STEP / IGES Importer:") + box.label(text="STEP / IGES importer:") if addon is not None: prefs = addon.preferences box.prop(prefs, "step_import_mode", text="Mode") @@ -8071,29 +8143,58 @@ def draw(self, context): col.operator("mv.add_folder", text="", icon='FILE_FOLDER') col.operator("mv.clear_step_files", text="", icon='TRASH') layout.label(text="STEP IGES STL OBJ PLY glTF FBX 3MF", icon='FILE') - layout.operator("mv.import_all_steps", icon='IMPORT') layout.operator("mv.add_product_collection", icon='OUTLINER_COLLECTION') + # Read-only lookup: draw() must not create the root. + root = bpy.data.collections.get(PRODUCT_COLL) prods = get_products() - header = layout.row(align=True) - header.prop(s, "show_products_list", text="", - icon='TRIA_DOWN' if s.show_products_list else 'TRIA_RIGHT', emboss=False) - header.label(text=f"Products under '{PRODUCT_COLL}' ({len(prods)})") - if s.show_products_list: + if not prods: + layout.label(text=f"Nothing under {PRODUCT_COLL} yet.", icon='INFO') + return + + layout.separator() + layout.label(text="Render these:") + row = layout.row() + row.template_list("MV_UL_Products", "", root, "children", + s, "active_product_index", rows=4) + col = row.column(align=True) + col.operator("mv.set_products_render", text="", icon='CHECKBOX_HLT').state = True + col.operator("mv.set_products_render", text="", icon='CHECKBOX_DEHLT').state = False + + coll, _ = active_product(context) + if coll is not None: + col = layout.column(align=True) + col.label(text=f"{coll.name}, as imported:", icon='OUTLINER_COLLECTION') + col.label(text=format_real_size(coll) or "size not recorded yet") + if s.part == 'VARIANTS': + # Keep Own here is what leaves a product out of the draw, so it + # belongs beside the list rather than in a materials panel. + row = col.row(align=True) + row.prop(coll, "mv_material", text="") + sub = row.row(align=True) + sub.active = coll.mv_material not in {'DEFAULT', 'KEEP'} + sub.prop(coll, "mv_material_color", text="") + if coll.mv_material == 'KEEP': + col.label(text="Keep Own: rendered once, no variants.", icon='INFO') + + strays = stray_render_objects(context) + if strays: box = layout.box() - if not prods: - box.label(text="(none)", icon='INFO') - else: - for pc in prods: - box.label(text=f"{pc.name} ({len(pc.all_objects)} objs)", icon='OUTLINER_COLLECTION') + box.label(text=f"{len(strays)} object(s) outside {PRODUCT_COLL}", icon='ERROR') + box.label(text="appear in every render:") + col = box.column(align=True) + for obj in strays[:4]: + col.label(text=obj.name, icon='OBJECT_DATA') + if len(strays) > 4: + col.label(text=f"+{len(strays) - 4} more") + box.label(text="Delete them, move them into a") + box.label(text="product, or switch off their") + box.label(text="render visibility (camera icon).") -class MV_PT_Standardize(Panel): +class MV_PT_Standardize(_ProductPanel, Panel): bl_label = "Standardize" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" def draw(self, context): layout = self.layout @@ -8123,88 +8224,76 @@ def draw(self, context): layout.separator() layout.operator("mv.standardize", icon='FULLSCREEN_EXIT') - coll, _ = active_product(context) - if coll is not None: - real = format_real_size(coll) - col = layout.column(align=True) - col.label(text=f"{coll.name}, as imported:", icon='OUTLINER_COLLECTION') - col.label(text=real or "size not recorded yet") - -class MV_PT_Cameras(Panel): - bl_label = "Cameras / Views" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" +class MV_PT_Views(_ProductPanel, Panel): + bl_label = "Views" def draw(self, context): layout = self.layout s = context.scene.mv_settings - layout.prop(s, "view_protocol") spec = VIEW_PROTOCOLS.get(s.view_protocol) if spec: + # The protocol lives under Dataset & research, so say here why the + # view set has stopped answering. box = layout.box() - col = box.column(align=True) - col.label(text=f"{len(protocol_views(s))} perspective views" - + (f", {spec['fov']:g} deg FOV" if "fov" in spec else ""), icon='INFO') - if any(r for _, _, r in protocol_views(s)): - col.label(text="Distances in object sizes x Target Size.") - col.label(text="Published numbers: Fit Each, Target 1.0.") - if spec.get("random"): - box.prop(s, "protocol_seed") - - # A preset is drawn from VIEW_SETS, not from the checkboxes, because - # that is what build_cameras reads for it. Only Custom edits them. + box.label(text=f"Protocol: {spec['label']}", icon='LOCKED') + box.label(text="It picks the views. Change or clear") + box.label(text="it under Dataset & research.") + views_col = layout.column() views_col.active = spec is None - layout = views_col - layout.prop(s, "view_set") + views_col.prop(s, "view_set") custom = s.view_set == 'CUSTOM' preset = set() if custom else {v[0] for v in VIEW_SETS[s.view_set]} - ortho = layout.grid_flow(row_major=True, columns=3, even_columns=True, align=True) - persp = layout.row(align=True) + ortho = views_col.grid_flow(row_major=True, columns=3, even_columns=True, align=True) + persp = views_col.row(align=True) persp.label(text="Persp:") for i, (name, kind, _) in enumerate(VIEWS_ALL): # Perspective names are too long for a third of the sidebar, so # they get their own row and drop the shared prefix. cell = (ortho if kind == "ortho" else persp).row() text = name.replace("Persp_", "") - if custom: + if custom and spec is None: cell.prop(s, "views_enabled", index=i, text=text) else: cell.enabled = False cell.label(text=text, icon='CHECKBOX_HLT' if name in preset else 'CHECKBOX_DEHLT') - layout = self.layout layout.prop(s, "front_axis") layout.prop(s, "frame_padding_factor") col = layout.column() col.active = spec is None - col.label(text="Custom Views (direction from target):") + col.label(text="Custom views (direction from target):") row = col.row() - row.template_list("MV_UL_CustomViews", "", s, "custom_views", s, "custom_views_index", rows=2) + row.template_list("MV_UL_CustomViews", "", s, "custom_views", + s, "custom_views_index", rows=2) row.column(align=True).operator("mv.add_custom_view", text="", icon='ADD') layout.operator("mv.build_cameras", icon='CAMERA_DATA') - layout.separator() - box = layout.box() - box.label(text="Orient Products:") + +class MV_PT_ViewsOrient(_ProductPanel, Panel): + bl_label = "Orient Products" + bl_parent_id = "MV_PT_Views" + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings products = get_products() if not products: - box.label(text="No products to orient.", icon='INFO') + layout.label(text="No products to orient.", icon='INFO') return coll, idx = active_product(context) - box.label(text=f"{coll.name} ({idx + 1}/{len(products)})", - icon='OUTLINER_COLLECTION') - box.prop(s, "isolate_active") - box.prop(s, "rotate_step") + layout.label(text=f"{coll.name} ({idx + 1}/{len(products)})", + icon='OUTLINER_COLLECTION') + layout.prop(s, "isolate_active") + layout.prop(s, "rotate_step") - col = box.column(align=True) + col = layout.column(align=True) row = col.row(align=True) row.operator("mv.rotate_product", text="Left", icon='TRIA_LEFT').direction = 'LEFT' row.operator("mv.rotate_product", text="Right", icon='TRIA_RIGHT').direction = 'RIGHT' @@ -8212,105 +8301,288 @@ def draw(self, context): row.operator("mv.rotate_product", text="Up", icon='TRIA_UP').direction = 'UP' row.operator("mv.rotate_product", text="Down", icon='TRIA_DOWN').direction = 'DOWN' - col = box.column(align=True) + col = layout.column(align=True) col.prop(s, "orient_method", text="") row = col.row(align=True) - row.operator("mv.auto_orient", text="Auto Orient", icon='ORIENTATION_GLOBAL').all_products = False + row.operator("mv.auto_orient", text="Auto Orient", + icon='ORIENTATION_GLOBAL').all_products = False row.operator("mv.auto_orient", text="All").all_products = True row = col.row(align=True) row.operator("mv.reset_orientation", text="Reset", icon='LOOP_BACK').all_products = False row.operator("mv.reset_orientation", text="All").all_products = True - row = box.row(align=True) + row = layout.row(align=True) row.operator("mv.product_step", text="Previous", icon='FRAME_PREV').delta = -1 row.operator("mv.product_step", text="Next", icon='FRAME_NEXT').delta = 1 - box.operator("mv.show_all_products", icon='HIDE_OFF') + layout.operator("mv.show_all_products", icon='HIDE_OFF') if s.reframe_on_render: - box.label(text="Render reframes the cameras itself.", icon='INFO') + layout.label(text="Render reframes the cameras itself.", icon='INFO') else: - box.label(text="Rebuild cameras after rotating to reframe.", icon='INFO') + layout.label(text="Rebuild cameras after rotating.", icon='INFO') -class MV_PT_Render(Panel): - bl_label = "Batch Render" - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" +class MV_PT_Stage(_ProductPanel, Panel): + bl_label = "Stage" def draw(self, context): layout = self.layout s = context.scene.mv_settings - layout.prop(s, "render_output_dir") - layout.prop(s, "render_mode") - if s.render_mode in {'CLAY', 'BOTH'} or (s.pass_lines and s.lines_fill == 'CLAY'): - layout.prop(s, "clay_color") - layout.label(text="Extra passes:") - row = layout.row(align=True) - row.prop(s, "pass_lines", toggle=True) - row.prop(s, "pass_mask", toggle=True) - row.prop(s, "pass_ao", toggle=True) + lit = needs_lighting(s) + + if lit: + col = layout.column(align=True) + col.label(text="Lighting:") + row = col.row(align=True) + row.prop(s, "lighting_preset", text="") + row.operator("mv.save_lighting_preset", text="", icon='ADD') + row.operator("mv.delete_lighting_preset", text="", icon='REMOVE') + col.prop(s, "lighting_intensity", slider=True) + layout.prop(s, "lights_follow_camera") + col = layout.column(align=True) - col.prop(s, "skip_existing") - col.prop(s, "reframe_on_render") + col.label(text="Background:") + col.row(align=True).prop(s, "world_mode", expand=True) + if s.world_mode == 'HDRI': + col.prop(s, "hdri_path", text="") + row = col.row(align=True) + row.prop(s, "hdri_strength") + row.prop(s, "hdri_rotation") + col.prop(s, "hdri_visible") + col.prop(s, "transparent_bg") + row = col.row() + # The colour still shows behind an HDRI the camera is not shown. + row.enabled = not s.transparent_bg and not (s.world_mode == 'HDRI' and s.hdri_visible) + row.prop(s, "bg_color", text="") - # Read-only lookup: draw() must not create the root. - root = bpy.data.collections.get(PRODUCT_COLL) - prods = get_products() - if prods: - layout.label(text="Products to render:") + if lit: + col = layout.column(align=True) + col.label(text="Ground:") + col.prop(s, "ground_mode", text="") + if s.ground_mode == 'FLOOR': + row = col.row(align=True) + row.prop(s, "ground_color", text="") + row.prop(s, "ground_roughness") + elif s.ground_mode == 'SHADOW' and context.scene.render.engine != 'CYCLES': + col.label(text="Shadow Catcher needs Cycles.", icon='ERROR') + else: + layout.label(text="Clay brings its own studio light", icon='INFO') + layout.label(text="and leaves the ground out, so") + layout.label(text="neither needs setting up here.") + + layout.separator() + layout.operator("mv.apply_scene_setup", icon='SCENE_DATA') + row = layout.row(align=True) + if lit: + row.operator("mv.build_lighting", text="Lighting Only", icon='LIGHT') + row.operator("mv.apply_world", text="Background Only", icon='WORLD') + + +class MV_PT_StageMaterial(_ProductPanel, Panel): + bl_label = "Base Material" + bl_parent_id = "MV_PT_Stage" + bl_options = {'DEFAULT_CLOSED'} + + @classmethod + def poll(cls, context): + s = context.scene.mv_settings + return s.part in PRODUCT_PARTS and needs_lighting(s) + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + row = layout.row(align=True) + row.prop(s, "material_preset", text="") + sub = row.row(align=True) + sub.active = s.material_preset != 'KEEP' + sub.prop(s, "material_color", text="") + layout.label(text="Full renders only. A product can", icon='INFO') + layout.label(text="pick its own beside the Products list.") + if s.part == 'VARIANTS' and s.pool_variants: + layout.label(text="Pool variants override this.", icon='INFO') + + +class MV_PT_Pool(_Sidebar, Panel): + bl_label = "Texture Pool" + + @classmethod + def poll(cls, context): + return context.scene.mv_settings.part in POOL_PARTS + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + row = layout.row(align=True) + row.prop(s, "pool_folder", text="") + row.operator("mv.scan_pool", text="", icon='FILE_REFRESH') + if s.material_pool: row = layout.row() - row.template_list("MV_UL_Products", "", root, "children", - s, "active_product_index", rows=4) + row.template_list("MV_UL_Pool", "", s, "material_pool", + s, "material_pool_index", rows=5) col = row.column(align=True) - col.operator("mv.set_products_render", text="", icon='CHECKBOX_HLT').state = True - col.operator("mv.set_products_render", text="", icon='CHECKBOX_DEHLT').state = False + col.operator("mv.set_pool", text="", icon='CHECKBOX_HLT').state = True + col.operator("mv.set_pool", text="", icon='CHECKBOX_DEHLT').state = False + ticked = sum(1 for m in s.material_pool if m.enabled) + layout.label(text=f"{ticked} of {len(s.material_pool)} ticked") + layout.label(text="C colour R rough M metal A packed", icon='INFO') + layout.label(text="H height N normal O opacity") + else: + layout.label(text="Pick a folder and scan it. Every", icon='INFO') + layout.label(text="material needs a colour map.") + # A patch shows one repeat filling the frame whatever the real part + # would be, so the tile size says nothing about it. + if s.part == 'VARIANTS': + layout.prop(s, "pool_tile_mm") - strays = stray_render_objects(context) - if strays: - box = layout.box() - box.label(text=f"{len(strays)} object(s) outside {PRODUCT_COLL}", icon='ERROR') - box.label(text="appear in every render:") - col = box.column(align=True) - for obj in strays[:4]: - col.label(text=obj.name, icon='OBJECT_DATA') - if len(strays) > 4: - col.label(text=f"+{len(strays) - 4} more") - box.label(text="Delete them, move them into a") - box.label(text="product, or switch off their") - box.label(text="render visibility (camera icon).") - ticked = sum(1 for pc in prods if pc.mv_render) +class MV_PT_Patch(_Sidebar, Panel): + bl_label = "Patch" + + @classmethod + def poll(cls, context): + return context.scene.mv_settings.part in POOL_PARTS + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + layout.prop(s, "patch_size") + if s.part == 'VARIANTS': + layout.prop(s, "pool_patches", text="Render patches too") + layout.label(text="Patches are a part of their own;", icon='INFO') + layout.label(text="this adds them to a variants run.") + return + layout.label(text="A flat swatch and the same material", icon='INFO') + layout.label(text="on a lit ball, side by side, written") + layout.label(text="to materials/ in the output folder.") + if context.scene.render.engine == 'BLENDER_WORKBENCH': + layout.label(text="Workbench cannot show textures, so", icon='INFO') + layout.label(text="patches render in Cycles instead.") + + +class MV_PT_Variants(_Sidebar, Panel): + bl_label = "Variants" + + @classmethod + def poll(cls, context): + return context.scene.mv_settings.part == 'VARIANTS' + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings row = layout.row(align=True) - row.scale_y = 1.3 - row.operator("mv.render_all", icon='RENDER_STILL', - text="Render All" if ticked == len(prods) else f"Render {ticked} Ticked", - ).only_active = False - row.operator("mv.render_all", text="Render Active", - icon='RESTRICT_RENDER_OFF').only_active = True + row.prop(s, "pool_variants", text="Count") + row.prop(s, "pool_seed", text="Seed") + layout.prop(s, "pool_per_part") + entries = len(pool_entries(s)) + if not entries: + layout.label(text="The pool is empty: scan a folder.", icon='ERROR') + return + products = [pc for pc in get_products() if pc.mv_render] + taking = [pc for pc in products if takes_variants(pc)] + layout.label(text=f"{len(taking)} product(s) x {s.pool_variants} draws") + layout.label(text=f"from {entries} ticked material(s)") + keeping = len(products) - len(taking) + if keeping: + layout.label(text=f"{keeping} set to Keep Own: rendered", icon='INFO') + layout.label(text="once, in their own materials.") + layout.label(text="The same seed always gives the same", icon='INFO') + layout.label(text="draws, whatever else is in the batch.") + + +class MV_PT_Render(_Sidebar, Panel): + bl_label = "Output" + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + part = s.part + layout.prop(s, "render_output_dir") + + if part == 'CLAY': + # Clay is the point of this part, but a form view in the product's + # own CAD colours is the same dataset, so the choice stays here. + layout.prop(s, "render_mode", text="Shading") + if (s.render_mode in {'CLAY', 'BOTH'} and part == 'CLAY') or \ + (part in PRODUCT_PARTS and s.pass_lines and s.lines_fill == 'CLAY'): + layout.prop(s, "clay_color") + + if part in PRODUCT_PARTS: + layout.label(text="Extra passes:") + row = layout.row(align=True) + row.prop(s, "pass_lines", toggle=True) + row.prop(s, "pass_mask", toggle=True) + row.prop(s, "pass_ao", toggle=True) + + col = layout.column(align=True) + col.prop(s, "skip_existing") + if part in PRODUCT_PARTS: + col.prop(s, "reframe_on_render") + + layout.separator() + row = layout.row(align=True) + row.scale_y = 1.4 + if part == 'PATCHES': + row.operator("mv.render_all", icon='MATSPHERE', + text=f"Render {PART_LABELS[part]}").only_active = False + else: + prods = get_products() + ticked = sum(1 for pc in prods if pc.mv_render) + row.operator("mv.render_all", icon='RENDER_STILL', + text=(f"Render {PART_LABELS[part]}" if ticked == len(prods) + else f"Render {ticked} Ticked")).only_active = False + row.operator("mv.render_all", text="Active", + icon='RESTRICT_RENDER_OFF').only_active = True + + for line in self._estimate(context, s, part): + layout.label(text=line) + layout.label(text="Esc stops after the image in progress.", icon='INFO') + layout.label(text="Engine, samples, resolution and format", icon='INFO') + layout.label(text="stay in Blender's own properties.") + + @staticmethod + def _estimate(context, s, part): + """How many files the button is about to write, as lines of text.""" + if part == 'PATCHES': + n = len(pool_entries(s)) + return [f"{n} ticked material(s) = {n} patch(es)"] + + prods = get_products() + ticked = sum(1 for pc in prods if pc.mv_render) if s.reframe_on_render: - # What Render All is about to build, not what is in MV_Cameras now. - n_cams = sum(1 for v in planned_views(s) if Vector(v[2]).length >= 1e-9) + # What Render is about to build, not what is in MV_Cameras now. + views = sum(1 for v in planned_views(s) if Vector(v[2]).length >= 1e-9) else: cam_coll = bpy.data.collections.get(CAMERA_COLL) - n_cams = len([o for o in cam_coll.objects if o.type == 'CAMERA']) if cam_coll else 0 - mult = ((2 if s.render_mode == 'BOTH' else 1) + s.pass_lines + s.pass_mask + s.pass_ao - + len(data_modes(s))) - layout.label(text=f"{ticked} products x {n_cams} views x {mult} = {ticked * n_cams * mult} images") + views = len([o for o in cam_coll.objects if o.type == 'CAMERA']) if cam_coll else 0 + full = 2 if s.render_mode == 'BOTH' else 1 + rest = s.pass_lines + s.pass_mask + s.pass_ao + len(data_modes(s)) + + lines = [] + if part == 'VARIANTS' and s.pool_variants and pool_entries(s): + # Only the Full render repeats per variant; the other passes do not + # depend on the material and are rendered once per view. + each = full * s.pool_variants + rest + lines.append(f"{ticked} x {views} views x {each} = {ticked * views * each} images") + lines.append(f"({s.pool_variants} variants; the rest rendered once)") + else: + each = full + rest + lines.append(f"{ticked} x {views} views x {each} = {ticked * views * each} images") if s.turntable_enabled: - layout.label(text=f"+ {ticked} turntable(s) x {s.turntable_frames} frames") - layout.label(text="Esc stops after the image in progress.", icon='INFO') - layout.label(text="Engine/samples/resolution/format: Blender Output props", icon='INFO') + lines.append(f"+ {ticked} turntable(s) x {s.turntable_frames} frames") + return lines -class _RenderSubPanel: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "MultiView" +class _RenderSubPanel(_Sidebar): bl_parent_id = "MV_PT_Render" bl_options = {'DEFAULT_CLOSED'} +class _RenderProductSubPanel(_RenderSubPanel): + @classmethod + def poll(cls, context): + return context.scene.mv_settings.part in PRODUCT_PARTS + + class MV_PT_RenderPreflight(_RenderSubPanel, Panel): bl_label = "Pre-flight" bl_options = set() @@ -8349,6 +8621,13 @@ class MV_PT_RenderNaming(_RenderSubPanel, Panel): def draw(self, context): layout = self.layout s = context.scene.mv_settings + if s.part == 'PATCHES': + layout.label(text="A patch is named after its material,", icon='INFO') + layout.label(text="in materials/ inside the output") + layout.label(text="folder. The templates below belong") + layout.label(text="to the other two parts.") + return + row = layout.row(align=True) row.prop(s, "start_prefix") row.prop(s, "start_number") @@ -8363,8 +8642,9 @@ def draw(self, context): prods = get_products() views = planned_views(s) mode = {'FULL': "", 'CLAY': "clay", 'BOTH': "render"}[s.render_mode] + variant = "v01" if (s.part == 'VARIANTS' and s.pool_variants) else "" values = name_values(s, 0, prods[0].name if prods else "Product", - views[0][0] if views else "Front", mode) + views[0][0] if views else "Front", mode, variant) folder = output_folder(s, values).replace(os.sep, "/") col = layout.column(align=True) col.label(text=f"{folder}/" if folder else "(output directory)", icon='FILE_FOLDER') @@ -8372,7 +8652,34 @@ def draw(self, context): icon='FILE_IMAGE') -class MV_PT_RenderSheet(_RenderSubPanel, Panel): +class MV_PT_RenderPasses(_RenderProductSubPanel, Panel): + bl_label = "Pass Settings" + + @classmethod + def poll(cls, context): + s = context.scene.mv_settings + return s.part in PRODUCT_PARTS and (s.pass_lines or s.pass_ao) + + def draw(self, context): + layout = self.layout + s = context.scene.mv_settings + if s.pass_lines: + layout.label(text="Line art:") + layout.prop(s, "lines_angle") + row = layout.row(align=True) + row.prop(s, "lines_width") + row.prop(s, "lines_color", text="") + layout.prop(s, "lines_fill") + if s.pass_ao: + layout.label(text="Ambient occlusion:") + row = layout.row(align=True) + row.prop(s, "ao_samples", text="Samples") + row.prop(s, "ao_distance", text="Distance") + if not hasattr(context.scene, "cycles"): + layout.label(text="Needs the Cycles add-on.", icon='ERROR') + + +class MV_PT_RenderSheet(_RenderProductSubPanel, Panel): bl_label = "Contact Sheet" def draw_header(self, context): @@ -8394,9 +8701,11 @@ def draw(self, context): layout.label(text="Labels need a newer Blender.", icon='ERROR') if s.post_enabled: layout.label(text="Built from the processed copies.", icon='INFO') + if s.part == 'VARIANTS' and s.pool_variants: + layout.label(text="One sheet per variant.", icon='INFO') -class MV_PT_RenderTurntable(_RenderSubPanel, Panel): +class MV_PT_RenderTurntable(_RenderProductSubPanel, Panel): bl_label = "Turntable" def draw_header(self, context): @@ -8450,36 +8759,56 @@ def draw(self, context): layout.prop(s, "post_max_size") layout.label(text="Extra sizes, e.g. 1024, 256:") layout.prop(s, "post_extra_sizes", text="") + if s.part == 'PATCHES': + layout.label(text="Patches are not post-processed.", icon='INFO') + + +class MV_PT_Dataset(_Sidebar, Panel): + bl_label = "Dataset & Research" + bl_options = {'DEFAULT_CLOSED'} + + def draw(self, context): + layout = self.layout + layout.label(text="What a dataset needs and a picture", icon='INFO') + layout.label(text="does not: camera poses, depth, masks,") + layout.label(text="drawings, randomization, provenance.") + +class _DatasetSubPanel(_Sidebar): + bl_parent_id = "MV_PT_Dataset" + bl_options = {'DEFAULT_CLOSED'} -class MV_PT_RenderPasses(_RenderSubPanel, Panel): - bl_label = "Extra Passes" +class _DatasetProductSubPanel(_DatasetSubPanel): @classmethod def poll(cls, context): - s = context.scene.mv_settings - return s.pass_lines or s.pass_ao + return context.scene.mv_settings.part in PRODUCT_PARTS + + +class MV_PT_DatasetProtocols(_DatasetProductSubPanel, Panel): + bl_label = "Camera Protocols" def draw(self, context): layout = self.layout s = context.scene.mv_settings - if s.pass_lines: - layout.label(text="Line Art:") - layout.prop(s, "lines_angle") - row = layout.row(align=True) - row.prop(s, "lines_width") - row.prop(s, "lines_color", text="") - layout.prop(s, "lines_fill") - if s.pass_ao: - layout.label(text="Ambient Occlusion:") - row = layout.row(align=True) - row.prop(s, "ao_samples", text="Samples") - row.prop(s, "ao_distance", text="Distance") - if not hasattr(context.scene, "cycles"): - layout.label(text="Needs the Cycles add-on.", icon='ERROR') + layout.prop(s, "view_protocol", text="") + spec = VIEW_PROTOCOLS.get(s.view_protocol) + if not spec: + layout.label(text="Views come from the Views panel.", icon='INFO') + return + views = protocol_views(s) + col = layout.column(align=True) + col.label(text=f"{len(views)} perspective views" + + (f", {spec['fov']:g} deg FOV" if "fov" in spec else ""), icon='INFO') + col.label(text="This replaces the view set entirely.") + if any(r for _, _, r in views): + col.label(text="Distances in object sizes x Target Size.") + col.label(text="Published numbers: Fit Each, Target 1.0.") + if spec.get("random"): + layout.prop(s, "protocol_seed") -class MV_PT_RenderAnnotations(_RenderSubPanel, Panel): +class MV_PT_DatasetAnnotations(_DatasetProductSubPanel, Panel): bl_label = "Annotations" def draw(self, context): @@ -8506,7 +8835,7 @@ def draw(self, context): layout.label(text="Needs the Cycles add-on.", icon='ERROR') -class MV_PT_RenderDrawings(_RenderSubPanel, Panel): +class MV_PT_DatasetDrawings(_DatasetProductSubPanel, Panel): bl_label = "Drawings" def draw_header(self, context): @@ -8525,7 +8854,7 @@ def draw(self, context): layout.label(text="SVG at 1:1 in source units.", icon='INFO') -class MV_PT_RenderRandomize(_RenderSubPanel, Panel): +class MV_PT_DatasetRandomize(_DatasetProductSubPanel, Panel): bl_label = "Randomize" def draw_header(self, context): @@ -8554,9 +8883,12 @@ def draw(self, context): layout.prop(s, "random_material") layout.prop(s, "random_distractors") layout.label(text="Masks and annotations stay exact.", icon='INFO') + if s.part == 'VARIANTS' and s.pool_variants and s.random_material: + layout.label(text="Variants choose the materials, so", icon='INFO') + layout.label(text="random materials are left out.") -class MV_PT_RenderManifest(_RenderSubPanel, Panel): +class MV_PT_DatasetManifest(_DatasetSubPanel, Panel): bl_label = "Manifest" def draw(self, context): @@ -8614,25 +8946,32 @@ def draw(self, context): MV_UL_CustomViews, MV_UL_Products, MV_UL_Pool, - # Panels draw in the sidebar in the order they are registered. + # Panels draw in the sidebar in the order they are registered, and a + # sub-panel's parent has to be registered before it. + MV_PT_Mode, MV_PT_Guide, - MV_PT_SceneSetup, - MV_PT_SceneMaterials, - MV_PT_ScenePool, - MV_PT_Import, + MV_PT_Products, MV_PT_Standardize, - MV_PT_Cameras, + MV_PT_Views, + MV_PT_ViewsOrient, + MV_PT_Stage, + MV_PT_StageMaterial, + MV_PT_Pool, + MV_PT_Patch, + MV_PT_Variants, MV_PT_Render, MV_PT_RenderPreflight, MV_PT_RenderNaming, + MV_PT_RenderPasses, MV_PT_RenderSheet, MV_PT_RenderTurntable, - MV_PT_RenderPasses, MV_PT_RenderPost, - MV_PT_RenderAnnotations, - MV_PT_RenderDrawings, - MV_PT_RenderRandomize, - MV_PT_RenderManifest, + MV_PT_Dataset, + MV_PT_DatasetProtocols, + MV_PT_DatasetAnnotations, + MV_PT_DatasetDrawings, + MV_PT_DatasetRandomize, + MV_PT_DatasetManifest, ) diff --git a/multiview_product_renderer/cli.py b/multiview_product_renderer/cli.py index 3c99d8a..99d8907 100644 --- a/multiview_product_renderer/cli.py +++ b/multiview_product_renderer/cli.py @@ -220,12 +220,14 @@ def run_job(mv, args): for i, pc in enumerate(mv.get_products()): pc.mv_label_index = i pc.mv_render = pc.mv_render and i % count == index - if not mv.get_products(): + # The Patches part renders the texture pool, not the products, so a job + # that asks for it needs no inputs at all. + if not mv.get_products() and s.part != 'PATCHES': raise JobError("no products to render") - if job.get("standardize"): + if job.get("standardize") and mv.get_products(): bpy.ops.mv.standardize() - if job.get("auto_orient"): + if job.get("auto_orient") and mv.get_products(): method = job["auto_orient"] if isinstance(method, str): s.orient_method = method.upper() diff --git a/tests/test_parts.py b/tests/test_parts.py new file mode 100644 index 0000000..0df6080 --- /dev/null +++ b/tests/test_parts.py @@ -0,0 +1,256 @@ +"""The three parts: the Part switcher's write-through, which panels each part +shows, and rendering the material patches on their own -- with no product, no +camera and no stage in the scene at all.""" +import os +import sys +import json +import shutil +import subprocess +import tempfile +import traceback + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _harness as h # noqa: E402 + +import bpy # noqa: E402 +import numpy as np # noqa: E402 +import multiview_product_renderer as mv # noqa: E402 + +check = h.check +CLI = os.path.join(h.REPO, "multiview_product_renderer", "cli.py") + + +def make_pool(root): + """Two minimal texture sets: a colour map each is all a material needs.""" + for folder, colour in (("Bricks_A", (0.9, 0.2, 0.2)), ("oak_veneer", (0.2, 0.7, 0.3))): + os.makedirs(os.path.join(root, folder)) + px = np.zeros((8, 8, 4), np.float32) + px[..., :3] = colour + px[..., 3] = 1.0 + mv.save_pixels(px, os.path.join(root, folder, folder + "_Color.png"), 'PNG') + + +class Shim: + """Stands in for a UILayout: every call returns another one and every + attribute set is accepted, so draw() runs to completion off-screen.""" + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + return self + + def __call__(self, *args, **kwargs): + return Shim() + + def __setattr__(self, name, value): + pass + + +class FakeSelf: + """A Panel subclass cannot be built from Python, so draw() is called + unbound with this as self: a layout shim, falling back to the class for + anything else the method reaches for.""" + + def __init__(self, cls): + object.__setattr__(self, "_cls", cls) + object.__setattr__(self, "layout", Shim()) + + def __getattr__(self, name): + return getattr(object.__getattribute__(self, "_cls"), name) + + +def panels(): + return [c for c in mv.classes if issubclass(c, bpy.types.Panel)] + + +def draw_all(part): + """Poll and draw every panel for one part. (shown, failures).""" + bpy.context.scene.mv_settings.part = part + shown, failed = [], [] + for cls in panels(): + try: + if hasattr(cls, "poll") and not cls.poll(bpy.context): + continue + except Exception: + failed.append(f"{cls.__name__}.poll: {traceback.format_exc(0)}") + continue + shown.append(cls.__name__) + for method in ("draw_header", "draw"): + fn = cls.__dict__.get(method) + if fn is None: + continue + try: + fn(FakeSelf(cls), bpy.context) + except Exception: + failed.append(f"{cls.__name__}.{method}: {traceback.format_exc(0)}") + return shown, failed + + +def blender(*args, timeout=600): + cmd = [bpy.app.binary_path, "-b", "--factory-startup", "--python", CLI, "--", *args] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, + encoding="utf-8", errors="replace") + return proc.returncode, proc.stdout + proc.stderr + + +def body(): + mv.register() + out = tempfile.mkdtemp(prefix="mv_parts_") + try: + scene = bpy.context.scene + s = scene.mv_settings + h.fresh_scene() + + # ---- the switcher writes through to what the renderer reads + check("default part is Clay", s.part == 'CLAY', s.part) + s.pool_variants = 7 + s.part = 'VARIANTS' + check("Variants forces a Full render", s.render_mode == 'FULL', s.render_mode) + check("Variants keeps a count already asked for", s.pool_variants == 7, s.pool_variants) + s.part = 'CLAY' + check("Clay clears variants and patches", + s.render_mode == 'CLAY' and s.pool_variants == 0 and not s.pool_patches, + (s.render_mode, s.pool_variants, s.pool_patches)) + s.part = 'VARIANTS' + check("Variants fills in a default count", s.pool_variants == mv.DEFAULT_VARIANTS, + s.pool_variants) + s.part = 'PATCHES' + check("Patches turns patches on and variants off", + s.pool_patches and s.pool_variants == 0, (s.pool_patches, s.pool_variants)) + + # Changing a part is a starting point, not a lock. + s.part = 'CLAY' + s.render_mode = 'BOTH' + check("Clay can still be switched to Both", s.render_mode == 'BOTH') + + # ---- lighting follows the render mode, not the part + check("Clay + Both is lit", mv.needs_lighting(s)) + s.render_mode = 'CLAY' + check("plain clay is not lit", not mv.needs_lighting(s)) + s.turntable_enabled = True + s.turntable_style = 'FULL' + check("a Full turntable is lit", mv.needs_lighting(s)) + s.turntable_enabled = False + + # ---- panels: every part draws, and each hides what it cannot use + pool_dir = os.path.join(out, "textures") + make_pool(pool_dir) + s.pool_folder = pool_dir + bpy.ops.mv.scan_pool() + check("pool scanned", len(s.material_pool) == 2, len(s.material_pool)) + + coll, _ = h.add_product("Widget", lambda: bpy.ops.mesh.primitive_cube_add(size=2)) + shown, failed = {}, [] + for part in ('CLAY', 'PATCHES', 'VARIANTS'): + shown[part], bad = draw_all(part) + failed += bad + check("every panel draws in every part", not failed, failed[:3]) + check("Patches hides products, views and stage", + not {"MV_PT_Products", "MV_PT_Standardize", "MV_PT_Views", "MV_PT_ViewsOrient", + "MV_PT_Stage"} & set(shown['PATCHES']), shown['PATCHES']) + check("Clay hides the pool, patch and variants panels", + not {"MV_PT_Pool", "MV_PT_Patch", "MV_PT_Variants"} & set(shown['CLAY']), + shown['CLAY']) + check("Variants shows both halves", + {"MV_PT_Products", "MV_PT_Views", "MV_PT_Pool", "MV_PT_Variants"} + <= set(shown['VARIANTS']), shown['VARIANTS']) + check("Patches is the shortest screen, Variants the longest", + len(shown['PATCHES']) < len(shown['CLAY']) < len(shown['VARIANTS']), + {k: len(v) for k, v in shown.items()}) + check("every sub-panel's parent is registered before it", + all(order.index(c.bl_parent_id) < order.index(c.__name__) + for order in [[p.__name__ for p in panels()]] + for c in panels() if getattr(c, "bl_parent_id", ""))) + check("one sidebar tab", {c.bl_category for c in panels()} == {"CADshot"}, + {c.bl_category for c in panels()}) + + # Stage drops lighting and ground when nothing in the run is lit. + s.part = 'CLAY' + s.render_mode = 'CLAY' + check("Base Material is hidden for plain clay", + not mv.MV_PT_StageMaterial.poll(bpy.context)) + s.render_mode = 'FULL' + check("Base Material returns for a Full render", + mv.MV_PT_StageMaterial.poll(bpy.context)) + s.render_mode = 'CLAY' + + # ---- pre-flight knows what each part needs + s.part = 'PATCHES' + s.render_output_dir = os.path.join(out, "patches") + os.sep + messages = [i["message"] for i in mv.preflight(bpy.context)] + check("Patches pre-flight asks for no cameras or views", + not [m for m in messages if "camera" in m.lower() or "view" in m.lower()], + messages) + for item in s.material_pool: + item.enabled = False + errors = [i["message"] for i in mv.preflight(bpy.context) if i["severity"] == 'ERROR'] + check("Patches pre-flight still wants a pool", + any("pool" in m.lower() for m in errors), errors) + for item in s.material_pool: + item.enabled = True + + # ---- patches render on their own, through the one job queue + scene.render.resolution_x = scene.render.resolution_y = 32 + s.patch_size = 64 + r = h.run(bpy.ops.mv.render_all) + root = os.path.join(out, "patches") + made = sorted(os.listdir(os.path.join(root, "materials"))) + check("Patches part renders every ticked material", + r == {'FINISHED'} and made == ["Bricks_A_patch.png", "oak_veneer_patch.png"], + (r, made)) + check("it writes nothing else", sorted(os.listdir(root)) + == ["manifest.csv", "manifest.json", "materials"], sorted(os.listdir(root))) + rows = json.load(open(os.path.join(root, "manifest.json")))["images"] + check("patches reach the manifest", + len(rows) == 2 and {x["kind"] for x in rows} == {"patch"} + and all(x["width"] and x["height"] for x in rows), rows) + check("the scene is left as it was", + not any(m.name.startswith("MV_Pool_") for m in bpy.data.materials) + and not any(sc.name.startswith("MV_Patch") for sc in bpy.data.scenes) + and len(mv.get_products()) == 1, + [m.name for m in bpy.data.materials]) + + # Skip Existing covers them like any other output. + s.skip_existing = True + before = {n: os.path.getmtime(os.path.join(root, "materials", n)) for n in made} + r = h.run(bpy.ops.mv.render_all) + after = {n: os.path.getmtime(os.path.join(root, "materials", n)) for n in made} + check("Skip Existing leaves finished patches alone", before == after, r) + s.skip_existing = False + + # A product in the scene makes no difference: patches ignore it. + check("a product in the scene is not rendered by the Patches part", + not any(d.startswith("AA_") for d in os.listdir(root)), os.listdir(root)) + + # ---- and with no product in the scene at all + bpy.data.collections.remove(coll) + s.render_output_dir = os.path.join(out, "bare") + os.sep + r = h.run(bpy.ops.mv.render_all) + check("Patches needs no product at all", + r == {'FINISHED'} + and len(os.listdir(os.path.join(out, "bare", "materials"))) == 2, r) + + # Clay with no product is still an error. + s.part = 'CLAY' + r = h.run(bpy.ops.mv.render_all) + check("Clay with no product is still refused", r == {'CANCELLED'}, r) + + # ---- a patches-only job file, with no inputs + job = os.path.join(out, "patches.json") + with open(job, "w") as f: + json.dump({"output": "cli_out", + "settings": {"part": "PATCHES", "pool_folder": pool_dir, + "patch_size": 64, "manifest_format": "JSON"}, + "render": {"resolution": [32, 32]}}, f) + code, log = blender("--config", job) + cli_out = os.path.join(out, "cli_out", "materials") + check("a job with no inputs renders the pool", + code == 0 and os.path.isdir(cli_out) and len(os.listdir(cli_out)) == 2, + (code, log[-500:])) + + mv.unregister() + finally: + shutil.rmtree(out, ignore_errors=True) + + +h.guard(body) From a89db999c0bee0ab0d34150bf7eadee417d60800 Mon Sep 17 00:00:00 2001 From: Danyal Sarfraz Date: Thu, 29 Oct 2026 08:53:21 +0900 Subject: [PATCH 2/2] Build patches from several shapes and lighting conditions A patch is now a grid rather than two fixed tiles: the shapes ticked in the Patch panel run across it, the lighting conditions ticked there run down it, and the whole thing is one image per material in materials/ as before. Shapes - Flat swatch, sphere, cube and cylinder, ticked individually; the default pair reproduces the patch earlier versions rendered - The solids are built to sit inside one bounding sphere and share one three-quarter camera, so each fills as much of its tile as its own form allows and the texture reads at one scale across a row. Framing solves asin(r/d) against the sphere, not the tangent against a bounding box, which left the subject at a fifth of its tile - The cylinder lies on its side. Upright, every wall normal is horizontal while a preset's lights are all above, so the lower half turned away from all of them at once and the base went black - Only the flat swatch takes the straight-on orthographic camera, so one repeat fills its tile exactly Lighting conditions - Taken from the lighting presets the Stage panel uses, built-in and saved, ticked in a list of their own and refreshed on demand. Switching to the Patches part fills the list, so it is never stale from another file or machine - No Lights is left out: a patch scene has no HDRI and the swatch would come out black. With nothing ticked, one row under a built-in studio rig - Presets are stored relative to the subject's size, so they rebuild around a patch shape unchanged: a swatch under Studio Soft is lit the way a product at a Target Size of 1.0 is - Pre-flight warns when a ticked preset can no longer be read, which would otherwise drop a row without saying so Grid - Column headings, row labels and the material's name, drawn with the same blf path the contact sheets use, and switchable off - Tiles render on transparent film and composite onto the grid's white background - Tile Size is now the size of one tile rather than of one half Variants - The Patch panel is gone from the Variants part, which is about textures on products. A job file can still set pool_patches for a run that wants both Tests - test_parts.py covers the shape meshes, which camera each asks for, the lighting list and its ticks surviving a refresh, grid growth in both axes, and how much of its tile each shape fills - test_materials.py reads tiles at computed offsets with labels off, rather than splitting the image in half --- README.md | 60 ++- multiview_product_renderer/__init__.py | 556 +++++++++++++++++++++---- tests/test_materials.py | 49 ++- tests/test_parts.py | 99 +++++ 4 files changed, 644 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index ed3187c..806b45a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ control while rendering swatches. | Part | What it writes | About | | --- | --- | --- | | **Clay** | Every product from every view, as clay: `AA_01_Widget_Front_clay.png` | Form | -| **Patches** | One image per pool material: a flat swatch beside a lit ball, in `materials/` | CMF | +| **Patches** | One image per pool material: a grid of the material on each ticked shape, under each ticked lighting condition, in `materials/` | CMF | | **Variants** | Every product from every view, several times over, each in a different draw of textures: `AA_01_Widget_Front_v01.png` | CMF × Form | Each part needs a different half of the tool, which is what the switcher is for: @@ -484,21 +484,58 @@ The pool's contents are recorded in the manifest's provenance block. **Material patches.** Switch the **Part** to **Patches** and press **Render Material Patches** to write one image per ticked material into `materials/` in the output folder. -Each shows a flat swatch, one repeat seen straight on, next to a ball under studio lights, -with the material's name above. **Patch Size** is the pixel size of each half. +Each image is a grid: the **shapes** you ticked across it, the **lighting conditions** you +ticked down it. + +``` + Flat Sphere Cube + Studio Soft [ tile ] [ tile ] [ tile ] + Studio Hard [ tile ] [ tile ] [ tile ] + Dramatic [ tile ] [ tile ] [ tile ] +``` + +Three shapes and three lighting conditions is nine tiles in one file per material. **Tile +Size** is the pixel size of one tile, and **Labels** prints the material's name across the +top, the shape names along it and the lighting names down the side. + +| Shape | What it shows | +| --- | --- | +| **Flat** | One repeat of the texture seen straight on, filling the tile: colour and pattern scale as authored. Orthographic, so there is no perspective to read around | +| **Sphere** | Continuous curvature, one highlight, and the falloff to the terminator | +| **Cube** | Three faces at once, so the box projection's seams show honestly rather than being hidden by a curve | +| **Cylinder** | A sweep in one axis only, which is what most machined parts are | + +The solid shapes are all built to sit inside one sphere and share one three-quarter camera, +so a shape fills as much of its tile as its own form allows and the texture reads at the same +scale right across a row. The flat swatch gets a straight-on orthographic camera of its own. + +**Lighting conditions** are the same presets the **Stage** panel uses — the built-in four, +plus any you saved there with **+**. Tick as many as you want; each becomes a row. The +refresh button beside the list picks up presets you have saved since. *No Lights* is left +out, since a patch scene has no HDRI and the swatch would come out black. With nothing +ticked, one row is rendered under a built-in studio rig. + +A preset stores its positions and sizes as multiples of the subject's size, so it rebuilds +around a patch shape with no extra work — which means **a swatch under Studio Soft is lit +the way a product scaled to a Target Size of `1.0` is lit under Studio Soft**. The same +**Intensity** applies. That is what makes a patch worth comparing against a render. Patches go through the same job queue as every other output, so they get the progress bar, the time left, Esc, Skip Existing and manifest rows of their own (`kind` is `patch`). -In the **Variants** part, **Render patches too** adds them to a variants run, so a texture -dataset can ship its swatches beside its renders. In a sharded command-line run the first -shard makes them, and rendering just the active product skips them. +Patches render in a scene of their own with the scene's colour management, on transparent +film composited onto the grid's white background, and the pool materials are deleted again +after each run, so your scene is untouched. They use Cycles when your engine is Workbench, +which cannot show textures — pre-flight stops the run if the Cycles add-on is switched off +and the engine is Workbench, since there would be nothing to stand in with. + +Patches belong to the **Patches** part only; the Variants part is about textures on +products. A command-line job can still set `pool_patches` to get both from one run, which is +what a sharded dataset build wants: -Patches render in a scene of their own with the scene's colour management, and the pool -materials are deleted again after each run, so your scene is untouched. They use Cycles when -your engine is Workbench, which cannot show textures — pre-flight stops the run if the -Cycles add-on is switched off and the engine is Workbench, since there would be nothing to -stand in with. +```json +{ "settings": { "part": "VARIANTS", "pool_variants": 8, "pool_patches": true } } +``` ### 6. Output @@ -559,6 +596,7 @@ uses none of them. | Error | AO pass on, but the Cycles add-on is off | | | Error | The texture pool is empty, or textures it uses have moved | | | Error | Patches with Workbench as the engine and the Cycles add-on off, so nothing can stand in for it | | +| Warning | A ticked patch lighting condition whose saved preset can no longer be read, so that row is skipped | | | Warning | Output folder is `//` relative but the `.blend` was never saved, so `//` points nowhere useful | Save the file | | Warning | Output folder does not exist yet | Create it | | Warning | Cameras are out of date (Reframe off): a product was turned, scaled or added, or the views, padding or resolution changed since Build Cameras | Rebuild cameras | diff --git a/multiview_product_renderer/__init__.py b/multiview_product_renderer/__init__.py index 5d4cba7..c6f56b7 100644 --- a/multiview_product_renderer/__init__.py +++ b/multiview_product_renderer/__init__.py @@ -102,6 +102,49 @@ USER_PRESET_PREFIX = "USER:" LIGHT_RIG = "MV_LightRig" +# -- material patches ---------------------------------------------------------- +# +# A patch shows one material on several shapes, under several lighting +# conditions, composited into one grid: shapes across, lighting down. Each +# tile is rendered in a scene of its own, so nothing here touches the user's. +# +# The shapes are sized to sit inside a sphere of PATCH_EXTENT, and the lighting +# comes from the same presets the Stage panel uses. Presets store positions and +# sizes as multiples of the subject's size, so a one-unit patch subject takes +# them as they are: a swatch under Studio Soft is lit the way a product scaled +# to a Target Size of 1.0 is lit under Studio Soft. + +PATCH_SHAPES = [ + ('FLAT', "Flat", "One repeat of the texture seen straight on, filling the tile: " + "colour and pattern scale as authored"), + ('SPHERE', "Sphere", "Continuous curvature, one highlight and a falloff to the " + "terminator"), + ('CUBE', "Cube", "Three faces at once, so the box projection's seams show honestly"), + ('CYLINDER', "Cylinder", "A sweep in one axis only, as most machined parts are"), +] +PATCH_SHAPE_LABELS = {k: label for k, label, _ in PATCH_SHAPES} +# The diameter of the sphere every non-flat subject is built to sit inside, so +# one camera frames them all identically and they can be compared tile to tile. +PATCH_EXTENT = 0.9 +# Margin left around that sphere in its tile. +PATCH_PADDING = 1.1 + +# Where the patch camera stands for the solid shapes: a three-quarter view, so +# a cube shows three faces. The flat swatch gets a straight-on orthographic +# camera of its own instead. Looking from -Y matches the direction a lighting +# preset is laid out against, so a preset's key light lands where it should. +PATCH_VIEW_DIR = (0.62, -1.0, 0.52) +# Enough ambient that an unlit side is not pure black; the lamps do the work. +PATCH_WORLD_GREY = 0.18 + +# Stands in when no lighting condition is ticked, and reproduces the patch +# every version before this one rendered. +PATCH_STUDIO = [ + {"name": "Key", "type": 'AREA', "pos": (1.6, -1.6, 2.0), "size": 1.5, "energy": 250.0}, + {"name": "Fill", "type": 'AREA', "pos": (-2.0, -1.2, 0.8), "size": 1.5, "energy": 70.0}, + {"name": "Rim", "type": 'AREA', "pos": (0.0, 2.2, 1.8), "size": 1.5, "energy": 150.0}, +] + MATERIAL_PRESETS = { "MATTE_PLASTIC": {"label": "Matte Plastic", "inputs": {"Roughness": 0.55}}, "GLOSSY_PLASTIC": {"label": "Glossy Plastic", "inputs": {"Roughness": 0.12, "Coat Weight": 0.4}}, @@ -162,21 +205,29 @@ ], 'PATCHES': [ "Material patches: one image per material in the pool, a", - "flat swatch beside the same material on a lit ball.", + "grid of the shapes you tick across and the lighting", + "conditions you tick down.", "", "1. Texture Pool: point Texture Folder at a library with one", " sub-folder per material, as downloaded from ambientCG or", " Poly Haven, and click the refresh button. Every folder", " with a colour map becomes a material. Untick any you do", - " not want. Texture Size is how much of the real part one", - " repeat covers, in millimetres.", - "2. Patch: set the size of each half in pixels.", + " not want.", + "2. Patch: tick the shapes (flat swatch, sphere, cube,", + " cylinder) and the lighting conditions. The lighting list", + " is the same presets Stage uses, including any you saved", + " there; refresh it to pick up new ones. Three shapes and", + " three lightings is nine tiles in one image.", "3. Output: set the folder, then Render Material Patches.", " They are written to materials/ inside it.", "", - "This part needs no product, no camera and no lighting:", - "patches are rendered in a scene of their own, and yours is", - "left exactly as it was.", + "A preset is stored relative to the subject's size, so a", + "swatch under Studio Soft is lit the way a product at a", + "Target Size of 1.0 is lit under Studio Soft.", + "", + "This part needs no product and no camera: patches are", + "rendered in a scene of their own, and yours is left exactly", + "as it was.", ], 'VARIANTS': [ "Texture variants: every product rendered several times", @@ -466,6 +517,14 @@ class MV_PoolMaterial(PropertyGroup): options={'HIDDEN'}) +class MV_PatchLight(PropertyGroup): + key: StringProperty(description="Lighting preset key, built-in or saved", + options={'HIDDEN'}) + name: StringProperty(name="Name") + enabled: BoolProperty(name="Use", default=False, + description="Give the patch a row under this lighting") + + class MV_Issue(PropertyGroup): severity: StringProperty() message: StringProperty() @@ -555,6 +614,9 @@ def _update_part(self, context): elif self.part == 'PATCHES': self.pool_patches = True self.pool_variants = 0 + # The lighting list is built from presets on disk, so it is refreshed + # on the way in rather than left as another file or machine left it. + sync_patch_lighting(self) else: self.render_mode = 'FULL' self.pool_patches = False @@ -732,12 +794,26 @@ class MV_Settings(PropertyGroup): ) pool_patches: BoolProperty( name="Patches", - description="With Render All, also write one image per pool material: a flat " - "swatch and a material ball side by side, in materials/", + description="With Render All, also write one image per pool material: the " + "material on each ticked shape, under each ticked lighting " + "condition, in materials/", default=False, ) - patch_size: IntProperty(name="Patch Size", default=512, min=64, max=4096, - description="Pixels per side of each half of a patch") + patch_size: IntProperty(name="Tile Size", default=512, min=64, max=4096, + description="Pixels per side of each tile in a patch") + patch_shapes: BoolVectorProperty( + name="Shapes", + description="Which shapes the material is shown on, left to right across the patch", + size=len(PATCH_SHAPES), + default=tuple(k in {'FLAT', 'SPHERE'} for k, _, _ in PATCH_SHAPES), + ) + patch_lighting: CollectionProperty(type=MV_PatchLight) + patch_lighting_index: IntProperty(default=0) + patch_labels: BoolProperty( + name="Labels", default=True, + description="Print the material's name across the top, the shapes along it and " + "the lighting conditions down the side", + ) render_output_dir: StringProperty( name="Output Directory", subtype='DIR_PATH', default="//renders/", @@ -3593,14 +3669,217 @@ def apply_variant(settings, coll, assignment, entries): obj.update_tag() +def patch_shape_keys(settings): + """[(key, label)] for the ticked patch shapes, in PATCH_SHAPES order. + + Falls back to the flat swatch so a patch is never an empty grid; the + panel and pre-flight both say so before it comes to that. + """ + found = [(k, label) for (k, label, _), on + in zip(PATCH_SHAPES, settings.patch_shapes) if on] + return found or [PATCH_SHAPES[0][:2]] + + +def sync_patch_lighting(settings): + """Refresh the lighting list from the presets, keeping the ticks. + + Built-ins first and saved ones after, the order the Stage dropdown shows + them in. 'No Lights (HDRI only)' is left out: a patch scene has no HDRI, + so it would render every swatch black. + """ + ticks = {item.key: item.enabled for item in settings.patch_lighting} + settings.patch_lighting.clear() + available = [(k, v["label"]) for k, v in LIGHTING_PRESETS.items() if v["lights"]] + available += [(k, label) for k, (label, _) in sorted(user_lighting_presets().items())] + for key, label in available: + item = settings.patch_lighting.add() + item.key = key + item.name = label + item.enabled = ticks.get(key, False) + return len(settings.patch_lighting) + + +def patch_lightings(settings): + """[(label, specs)] for the ticked lighting conditions. + + With nothing ticked -- a fresh file, or a preset that has since been + deleted -- one row under the built-in studio rig, which is the patch + every version before lighting conditions existed rendered. + """ + found = [] + for item in settings.patch_lighting: + if not item.enabled: + continue + specs = lighting_specs(item.key) + if specs: + found.append((item.name, specs)) + return found or [("Studio", PATCH_STUDIO)] + + +def patch_subject(kind): + """(mesh, ortho scale or None) for one patch shape, centred on the origin. + + An ortho scale means the shape wants the straight-on camera: only the flat + swatch does, so that one repeat of the texture fills its tile exactly. + Everything else is sized to sit inside PATCH_EXTENT and shares the + three-quarter camera, so the tiles of a row are framed alike. + """ + import bmesh + if kind == 'FLAT': + mesh = bpy.data.meshes.new("MV_Patch_Flat") + mesh.from_pydata([(-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)], [], [(0, 1, 2, 3)]) + mesh.update() + return mesh, 1.0 + + radius = PATCH_EXTENT * 0.5 + bm = bmesh.new() + if kind == 'SPHERE': + bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=radius) + smooth = bm.faces + elif kind == 'CUBE': + # A cube's diagonal is what has to fit, not its side. + bmesh.ops.create_cube(bm, size=PATCH_EXTENT / math.sqrt(3.0)) + smooth = [] + else: + # Sized so its own corner-to-centre distance still lands inside the + # shared bounding sphere: sqrt(r^2 + (d/2)^2) <= radius. + bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=64, + radius1=radius * 0.66, radius2=radius * 0.66, + depth=PATCH_EXTENT * 0.72) + # The caps are flat and the wall is round: smoothing both together + # would bend the rim and lose the edge the highlight runs along. + # Chosen here, while the axis is still Z and the caps are the only + # faces pointing along it. + smooth = [f for f in bm.faces if abs(f.normal.z) < 0.9] + for face in smooth: + face.smooth = True + if kind == 'CYLINDER': + # Laid on its side. Standing up, every wall normal is horizontal while + # a preset's lights are all above, so the lower half of the wall turns + # away from all of them at once and the base goes black -- correct + # light, useless swatch. On its side the sweep runs under the key and + # the falloff reads as the form instead. + bmesh.ops.rotate(bm, verts=bm.verts, cent=(0.0, 0.0, 0.0), + matrix=Matrix.Rotation(math.radians(90.0), 3, 'Y')) + mesh = bpy.data.meshes.new(f"MV_Patch_{kind.title()}") + bm.to_mesh(mesh) + bm.free() + return mesh, None + + +def build_patch_lights(scene, specs, intensity=1.0): + """Light a patch scene from a lighting preset's specs. Returns what it made. + + The same arithmetic build_lighting does for a product, with the subject's + size taken as one unit and no front-axis turn, since the patch camera is + fixed. That is what makes a swatch under a preset match a product render + under the same preset at a Target Size of 1.0. + """ + made = [] + for spec in specs: + kind = spec.get("type", 'AREA') + light = bpy.data.lights.new(f"MV_Patch_{spec.get('name', 'Light')}", type=kind) + size = spec.get("size", 1.0) * 0.5 + if kind == 'AREA': + light.size = size + if "shape" in spec: + light.shape = spec["shape"] + light.size_y = spec.get("size_y", spec.get("size", 1.0)) * 0.5 + elif kind in {'POINT', 'SPOT'}: + light.shadow_soft_size = size + if kind == 'SPOT': + light.spot_size = spec.get("spot_size", light.spot_size) + light.spot_blend = spec.get("spot_blend", light.spot_blend) + if kind == 'SUN': + light.angle = spec.get("angle", light.angle) + light.energy = float(spec["energy"]) * intensity + light.color = spec.get("color", (1.0, 1.0, 1.0)) + + obj = bpy.data.objects.new(light.name, light) + scene.collection.objects.link(obj) + pos = Vector(spec["pos"]) + if "rot" in spec: + obj.matrix_world = (Matrix.Translation(pos) + @ Quaternion(spec["rot"]).to_matrix().to_4x4()) + else: + obj.matrix_world = look_at(pos, Vector((0.0, 0.0, 0.0))) + made += [obj, light] + return made + + +def patch_grid(tiles, rows, columns, title, size, labels, tmp_dir): + """Composite a material's tiles into one image: shapes across, lighting down. + + tiles is {(row label, column label): pixels}. Column headings run along + the top and row labels down the left, so a reader can tell which light and + which shape a tile is without counting. + """ + gap = max(6, size // 32) + caption = max(11, size // 18) + title_h = int(caption * 2.2) if labels else 0 + head_h = int(caption * 1.6) if labels else 0 + side_w = (max([int(blf_width(r, caption)) for r in rows] + [0]) + gap + if labels else 0) + + width = side_w + len(columns) * size + (len(columns) + 1) * gap + # The leading gap is counted once here and added to every row's offset + # below, so the margin above the first row matches the one below the last. + height = title_h + head_h + gap + len(rows) * (size + gap) + canvas = np.empty((height, width, 4), np.float32) + canvas[:] = (1.0, 1.0, 1.0, 1.0) + ink = np.array((0.1, 0.1, 0.1), np.float32) + + def draw_text(text, text_size, x, y_top, max_w): + mask = text_mask(text, text_size, tmp_dir) + if mask is None: + return + mask = mask[:, :max(int(max_w), 1)] + layer = np.empty(mask.shape + (4,), np.float32) + layer[..., :3] = ink + layer[..., 3] = mask + paste_over(canvas, layer, int(x), height - int(y_top) - mask.shape[0]) + + if labels: + draw_text(title, int(caption * 1.5), gap, int(caption * 0.4), width - 2 * gap) + for col, name in enumerate(columns): + draw_text(name, caption, side_w + gap + col * (size + gap), + title_h + int(caption * 0.2), size) + + for row, row_name in enumerate(rows): + y_top = title_h + head_h + gap + row * (size + gap) + if labels: + draw_text(row_name, caption, gap, y_top + size // 2 - caption // 2, side_w) + for col, col_name in enumerate(columns): + px = tiles.get((row_name, col_name)) + if px is None: + continue + x = side_w + gap + col * (size + gap) + paste_over(canvas, px, x, height - y_top - px.shape[0]) + return canvas + + +def blf_width(text, size): + """Width of text in the UI font, or a rough guess where blf cannot say.""" + try: + blf.size(0, size) + return blf.dimensions(0, text)[0] + except Exception: + return len(text) * size * 0.55 + + def render_patch(context, name, maps, path, size): - """One image per pool material: a flat swatch seen straight on, and a - ball under studio lights, side by side and labelled. + """One image per pool material: the material on each ticked shape, under + each ticked lighting condition, composited into a grid. Rendered in a scene of its own, so the user's scene is never touched. - Workbench cannot show textures properly, so Cycles stands in for it. + Workbench cannot show a texture properly, so Cycles stands in for it. """ main = context.scene + s = main.mv_settings + shapes = patch_shape_keys(s) + lightings = patch_lightings(s) + scene = bpy.data.scenes.new("MV_Patch") made = [] work = tempfile.mkdtemp(prefix="mv_patch_") @@ -3612,8 +3891,11 @@ def render_patch(context, name, maps, path, size): scene.cycles.device = main.cycles.device scene.render.resolution_x = scene.render.resolution_y = size scene.render.resolution_percentage = 100 - scene.render.film_transparent = False + # Tiles are composited onto the grid's own white background, so each + # one is just the lit subject with nothing behind it. + scene.render.film_transparent = True scene.render.image_settings.file_format = 'PNG' + scene.render.image_settings.color_mode = 'RGBA' # The same colour management as the product renders, so a swatch shows # the material as it looks in them. scene.display_settings.display_device = main.display_settings.display_device @@ -3630,74 +3912,83 @@ def render_patch(context, name, maps, path, size): bg = tree.nodes.new("ShaderNodeBackground") out = tree.nodes.new("ShaderNodeOutputWorld") tree.links.new(bg.outputs["Background"], out.inputs["Surface"]) + bg.inputs["Color"].default_value = (PATCH_WORLD_GREY,) * 3 + (1.0,) + bg.inputs["Strength"].default_value = 1.0 scene.world = world - def add(obj): - scene.collection.objects.link(obj) - made.append(obj) - if obj.data is not None: - made.append(obj.data) - return obj - - def shoot(label): - scene.render.filepath = os.path.join(work, label) - bpy.ops.render.render(write_still=True, scene=scene.name) - return load_pixels(written_path(scene, scene.render.filepath)) - - # Flat swatch: one repeat of the texture filling the frame, lit evenly - # by a plain white environment. - bg.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0) - bg.inputs["Strength"].default_value = 1.0 - plane_mesh = bpy.data.meshes.new("MV_Patch_Plane") - plane_mesh.from_pydata([(-0.5, -0.5, 0), (0.5, -0.5, 0), (0.5, 0.5, 0), (-0.5, 0.5, 0)], - [], [(0, 1, 2, 3)]) - plane_mesh.materials.append(mat) - plane = add(bpy.data.objects.new("MV_Patch_Plane", plane_mesh)) - plane[TILE_KEY] = 1.0 - top_data = bpy.data.cameras.new("MV_Patch_Top") - top_data.type = 'ORTHO' - top_data.ortho_scale = 1.0 - top = add(bpy.data.objects.new("MV_Patch_Top", top_data)) - top.matrix_world = look_at(Vector((0, 0, 2)), Vector((0, 0, 0))) - scene.camera = top - flat = shoot("flat") - scene.collection.objects.unlink(plane) - - # Ball: the same material on a sphere under a key, fill and rim light. - import bmesh - bg.inputs["Color"].default_value = (0.18, 0.18, 0.18, 1.0) - ball_mesh = bpy.data.meshes.new("MV_Patch_Ball") - bm = bmesh.new() - bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=0.45) - for face in bm.faces: - face.smooth = True - bm.to_mesh(ball_mesh) - bm.free() - ball_mesh.materials.append(mat) - ball = add(bpy.data.objects.new("MV_Patch_Ball", ball_mesh)) - ball[TILE_KEY] = 1.0 - for label, pos, energy in (("Key", (1.6, -1.6, 2.0), 250.0), - ("Fill", (-2.0, -1.2, 0.8), 70.0), - ("Rim", (0.0, 2.2, 1.8), 150.0)): - light = bpy.data.lights.new(f"MV_Patch_{label}", type='AREA') - light.size = 1.5 - light.energy = energy - obj = add(bpy.data.objects.new(f"MV_Patch_{label}", light)) - obj.matrix_world = look_at(Vector(pos), Vector((0, 0, 0))) - cam_data = bpy.data.cameras.new("MV_Patch_View") - cam_data.lens = 50.0 - view = add(bpy.data.objects.new("MV_Patch_View", cam_data)) - view.matrix_world = look_at(Vector((0, -2.3, 1.0)), Vector((0, 0, 0))) - scene.camera = view - sphere = shoot("ball") - - from types import SimpleNamespace - layout = SimpleNamespace(sheet_cell_width=size, sheet_columns=2, sheet_labels=True, - sheet_bg=(1.0, 1.0, 1.0, 1.0), sheet_text_color=(0.1, 0.1, 0.1)) - sheet = contact_sheet([("Flat", flat), ("Ball", sphere)], name, layout, work) + # One camera for the solid shapes, framed on the sphere of PATCH_EXTENT + # that every one of them is built to sit inside. Framing on the shared + # sphere rather than on each shape's own silhouette is what makes the + # tiles comparable: each shape fills as much of its tile as its form + # allows, and the texture reads at one scale across the row. + radius = PATCH_EXTENT * 0.5 + direction = Vector(PATCH_VIEW_DIR).normalized() + tan_x, tan_y = sensor_tangents(1.0, 1.0) + tan = min(tan_x, tan_y) + # A sphere touches the frame edge where the view angle is asin(r/d), + # not atan: using the tangent would leave it noticeably small. + distance = radius / (tan / math.sqrt(1.0 + tan * tan)) * PATCH_PADDING + + solid_data = bpy.data.cameras.new("MV_Patch_View") + solid_data.lens = PERSP_LENS + solid_data.clip_start = max(distance * 0.01, 1e-5) + solid_data.clip_end = distance * 100.0 + solid = bpy.data.objects.new("MV_Patch_View", solid_data) + solid.matrix_world = look_at(direction * distance, Vector((0.0, 0.0, 0.0))) + scene.collection.objects.link(solid) + made += [solid, solid_data] + + flat_data = bpy.data.cameras.new("MV_Patch_Top") + flat_data.type = 'ORTHO' + flat_data.ortho_scale = 1.0 + flat = bpy.data.objects.new("MV_Patch_Top", flat_data) + flat.matrix_world = look_at(Vector((0.0, 0.0, 2.0)), Vector((0.0, 0.0, 0.0))) + scene.collection.objects.link(flat) + made += [flat, flat_data] + + subjects = {} + for kind, _ in shapes: + mesh, ortho = patch_subject(kind) + mesh.materials.append(mat) + obj = bpy.data.objects.new(mesh.name, mesh) + # One repeat of the texture per unit, so the flat swatch shows + # exactly one and the solids show the pattern at the same scale. + obj[TILE_KEY] = 1.0 + subjects[kind] = (obj, ortho) + made += [obj, mesh] + + tiles = {} + for light_label, specs in lightings: + lights = build_patch_lights(scene, specs, main.mv_settings.lighting_intensity) + try: + for kind, shape_label in shapes: + obj, ortho = subjects[kind] + scene.camera = flat if ortho is not None else solid + scene.collection.objects.link(obj) + try: + scene.render.filepath = os.path.join( + work, f"{len(tiles):03d}") + bpy.ops.render.render(write_still=True, scene=scene.name) + tiles[(light_label, shape_label)] = load_pixels( + written_path(scene, scene.render.filepath)) + finally: + scene.collection.objects.unlink(obj) + finally: + for block in lights: + try: + if isinstance(block, bpy.types.Object): + bpy.data.objects.remove(block, do_unlink=True) + else: + bpy.data.lights.remove(block) + except (ReferenceError, RuntimeError): + pass + + grid = patch_grid(tiles, [label for label, _ in lightings], + [label for _, label in shapes], name, size, + s.patch_labels, work) os.makedirs(os.path.dirname(path), exist_ok=True) - save_pixels(sheet, path, 'PNG') - return sheet.shape[1], sheet.shape[0] + save_pixels(grid, path, 'PNG') + return grid.shape[1], grid.shape[0] finally: bpy.data.scenes.remove(scene) for block in made: @@ -4198,9 +4489,35 @@ def execute(self, context): return {'FINISHED'} +class MV_OT_SyncPatchLighting(Operator): + """Refresh the lighting conditions from the lighting presets, built-in and + saved, keeping whatever is already ticked""" + bl_idname = "mv.sync_patch_lighting" + bl_label = "Refresh Lighting List" + + def execute(self, context): + found = sync_patch_lighting(context.scene.mv_settings) + self.report({'INFO'}, f"{found} lighting preset(s) available.") + return {'FINISHED'} + + +class MV_OT_SetPatchLighting(Operator): + """Tick or untick every lighting condition""" + bl_idname = "mv.set_patch_lighting" + bl_label = "Set Lighting Conditions" + bl_options = {'REGISTER', 'UNDO'} + + state: BoolProperty(default=True) + + def execute(self, context): + for item in context.scene.mv_settings.patch_lighting: + item.enabled = self.state + return {'FINISHED'} + + class MV_OT_RenderPatches(Operator): - """Render a flat swatch and a material ball for every ticked pool material, - into the output folder's materials/ folder""" + """Render every ticked pool material on each ticked shape, under each + ticked lighting condition, into the output folder's materials/ folder""" bl_idname = "mv.render_patches" bl_label = "Render Patches" @@ -6479,6 +6796,15 @@ def preflight(context, only_active=False): if s.pool_patches and scene.render.engine == 'BLENDER_WORKBENCH' \ and not hasattr(scene, "cycles"): _issue(issues, 'ERROR', "Patches need Cycles while the engine is Workbench") + if s.pool_patches: + # A saved preset deleted since it was ticked would otherwise drop + # a row out of every patch without saying so. + gone = [item.name for item in s.patch_lighting + if item.enabled and not lighting_specs(item.key)] + if gone: + more = f" (+{len(gone) - 1} more)" if len(gone) > 1 else "" + _issue(issues, 'WARNING', + f"Patch lighting '{gone[0]}'{more} cannot be read; skipped") if s.export_croissant and not s.dataset_license: _issue(issues, 'WARNING', "Croissant: dataset has no licence set") @@ -8047,6 +8373,15 @@ def draw_item(self, context, layout, data, item, icon, active_data, active_propn sub.label(text="".join(sorted({MAP_LETTERS.get(k, "") for k in maps}))) +class MV_UL_PatchLighting(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): + row = layout.row(align=True) + row.prop(item, "enabled", text="") + sub = row.row(align=True) + sub.active = item.enabled + sub.label(text=item.name, icon='LIGHT') + + class MV_UL_Products(UIList): """MV_Products' children, each with its render tick and the label its files will carry. The label is worked out from the product's place in the @@ -8439,20 +8774,55 @@ class MV_PT_Patch(_Sidebar, Panel): @classmethod def poll(cls, context): - return context.scene.mv_settings.part in POOL_PARTS + # Patches only. The Variants part is about textures on products, and a + # patch control there was the one place the sidebar still mixed two + # parts together. A job file can still set pool_patches for a run that + # wants both. + return context.scene.mv_settings.part == 'PATCHES' def draw(self, context): layout = self.layout s = context.scene.mv_settings + + layout.label(text="Shapes, across:") + grid = layout.grid_flow(row_major=True, columns=2, even_columns=True, align=True) + for i, (_, label, _) in enumerate(PATCH_SHAPES): + grid.prop(s, "patch_shapes", index=i, text=label) + + row = layout.row(align=True) + row.label(text="Lighting, down:") + row.operator("mv.sync_patch_lighting", text="", icon='FILE_REFRESH') + if s.patch_lighting: + row = layout.row() + row.template_list("MV_UL_PatchLighting", "", s, "patch_lighting", + s, "patch_lighting_index", rows=4) + col = row.column(align=True) + col.operator("mv.set_patch_lighting", text="", icon='CHECKBOX_HLT').state = True + col.operator("mv.set_patch_lighting", text="", icon='CHECKBOX_DEHLT').state = False + else: + layout.label(text="Refresh to list the lighting presets.", icon='INFO') + layout.prop(s, "patch_size") - if s.part == 'VARIANTS': - layout.prop(s, "pool_patches", text="Render patches too") - layout.label(text="Patches are a part of their own;", icon='INFO') - layout.label(text="this adds them to a variants run.") - return - layout.label(text="A flat swatch and the same material", icon='INFO') - layout.label(text="on a lit ball, side by side, written") - layout.label(text="to materials/ in the output folder.") + layout.prop(s, "patch_labels") + + shapes = patch_shape_keys(s) + # Counted rather than resolved: patch_lightings() reads every saved + # preset off disk, and draw() runs on every redraw. + lights = max(1, sum(1 for item in s.patch_lighting if item.enabled)) + tiles = len(shapes) * lights + materials = len(pool_entries(s)) + layout.label(text=f"{len(shapes)} shapes x {lights} lighting = {tiles} tiles") + layout.label(text=f"{materials} material(s), one grid each " + f"({materials * tiles} renders)") + if not any(s.patch_shapes): + layout.label(text="No shape ticked, so the flat swatch", icon='ERROR') + layout.label(text="is used on its own.") + if not any(item.enabled for item in s.patch_lighting): + layout.label(text="No lighting ticked, so the built-in", icon='INFO') + layout.label(text="studio rig is used.") + layout.label(text="Saved lighting presets are listed here", icon='INFO') + layout.label(text="too: build a rig in a product scene,") + layout.label(text="save it under Stage, then refresh.") if context.scene.render.engine == 'BLENDER_WORKBENCH': layout.label(text="Workbench cannot show textures, so", icon='INFO') layout.label(text="patches render in Cycles instead.") @@ -8912,6 +9282,7 @@ def draw(self, context): MV_StepFileItem, MV_Issue, MV_PoolMaterial, + MV_PatchLight, MV_CustomView, MV_Settings, MV_OT_AddStepFile, @@ -8925,6 +9296,8 @@ def draw(self, context): MV_OT_ApplyWorld, MV_OT_ScanPool, MV_OT_SetPool, + MV_OT_SyncPatchLighting, + MV_OT_SetPatchLighting, MV_OT_RenderPatches, MV_OT_SaveLightingPreset, MV_OT_DeleteLightingPreset, @@ -8946,6 +9319,7 @@ def draw(self, context): MV_UL_CustomViews, MV_UL_Products, MV_UL_Pool, + MV_UL_PatchLighting, # Panels draw in the sidebar in the order they are registered, and a # sub-panel's parent has to be registered before it. MV_PT_Mode, diff --git a/tests/test_materials.py b/tests/test_materials.py index 889fdf1..7fce481 100644 --- a/tests/test_materials.py +++ b/tests/test_materials.py @@ -197,37 +197,50 @@ def body(): # ---- patches scene.render.engine = 'BLENDER_WORKBENCH' scenes, objects = len(bpy.data.scenes), len(bpy.data.objects) + # Labels off, so the grid has no title or row gutter and every tile + # sits at an offset the test can work out for itself. + s.patch_labels = False patch = os.path.join(out, "patch.png") w, hgt = mv.render_patch(bpy.context, "Bricks_A", bricks, patch, 96) check("patch written", os.path.isfile(patch)) px = mv.load_pixels(patch) - check("patch is two cells wide", px.shape[1] == w and w >= 2 * 96 and px.shape[0] == hgt, - px.shape) + gap = max(6, 96 // 32) + check("default patch is two tiles across and one lighting down", + px.shape[1] == w and px.shape[0] == hgt + and w == 2 * 96 + 3 * gap and hgt == 96 + 2 * gap, + (px.shape, w, hgt)) check("patch scene and objects cleaned up", len(bpy.data.scenes) == scenes and len(bpy.data.objects) == objects and scene.render.engine == 'BLENDER_WORKBENCH') - # Each half of the sheet, without its white title and label margins. - top = px[::-1] - cells = [] - for half in (top[:, :w // 2, :3], top[:, w // 2:, :3]): - inked = half.min(axis=2) < 0.85 - cells.append(half[inked]) - flat, ball = cells - flat_red = flat[:, 0] - flat[:, 2] + + def tile(image, col): + """One tile of the top row, rows running top-down.""" + top = image[::-1] + x = gap + col * (96 + gap) + return top[gap:gap + 96, x:x + 96, :3] + + flat, ball = tile(px, 0), tile(px, 1) + flat_red = flat[..., 0] - flat[..., 2] check("flat swatch shows the red checker", (flat_red > 0.1).mean() > 0.95, float((flat_red > 0.1).mean())) check("flat swatch shows both checker tones", - np.percentile(flat[:, 0], 90) - np.percentile(flat[:, 0], 10) > 0.15) - ball_red = ball[:, 0] - ball[:, 2] - check("ball is red on a grey ground", 0.1 < (ball_red > 0.15).mean() < 0.6 - and abs(np.median(ball_red)) < 0.05, float((ball_red > 0.15).mean())) + np.percentile(flat[..., 0], 90) - np.percentile(flat[..., 0], 10) > 0.15) + # The sphere is rendered on transparent film and composited onto the + # grid's white background, so the tile is a red ball surrounded by white. + subject = ball.min(axis=2) < 0.85 + check("the sphere tile holds a red sphere on white", + 0.3 < subject.mean() < 0.85 + and float((ball[..., 0] - ball[..., 2])[subject].mean()) > 0.1, + (float(subject.mean()), + float((ball[..., 0] - ball[..., 2])[subject].mean()))) # The scene's colour management carries over to the patch. scene.view_settings.exposure = -1.5 mv.render_patch(bpy.context, "Bricks_A", bricks, os.path.join(out, "dark.png"), 96) scene.view_settings.exposure = 0.0 - dark = mv.load_pixels(os.path.join(out, "dark.png"))[::-1] - dark_flat = dark[:, :w // 2, :3][dark[:, :w // 2, :3].min(axis=2) < 0.85] - check("patch follows the scene's exposure", dark_flat[:, 0].mean() < flat[:, 0].mean() - 0.1, - (float(dark_flat[:, 0].mean()), float(flat[:, 0].mean()))) + dark_flat = tile(mv.load_pixels(os.path.join(out, "dark.png")), 0) + check("patch follows the scene's exposure", + dark_flat[..., 0].mean() < flat[..., 0].mean() - 0.1, + (float(dark_flat[..., 0].mean()), float(flat[..., 0].mean()))) + s.patch_labels = True # ---- a two-part product coll, (block, peg) = h.add_product( diff --git a/tests/test_parts.py b/tests/test_parts.py index 0df6080..5e5b301 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -222,6 +222,105 @@ def body(): check("a product in the scene is not rendered by the Patches part", not any(d.startswith("AA_") for d in os.listdir(root)), os.listdir(root)) + # ---- shapes and lighting conditions + check("switching to Patches listed the lighting presets", + [i.key for i in s.patch_lighting] + == [k for k, v in mv.LIGHTING_PRESETS.items() if v["lights"]], + [i.key for i in s.patch_lighting]) + check("No Lights is left out, it would render black", + "NONE" not in {i.key for i in s.patch_lighting}) + check("nothing ticked falls back to the built-in studio rig", + [label for label, _ in mv.patch_lightings(s)] == ["Studio"], + mv.patch_lightings(s)) + s.patch_lighting[0].enabled = True + s.patch_lighting[1].enabled = True + mv.sync_patch_lighting(s) + check("a refresh keeps the ticks", + [i.enabled for i in s.patch_lighting][:2] == [True, True]) + check("ticked presets become the rows", + len(mv.patch_lightings(s)) == 2, mv.patch_lightings(s)) + + s.patch_shapes = [True] * len(mv.PATCH_SHAPES) + check("every shape builds a mesh", + all(mv.patch_subject(k)[0].polygons for k, _, _ in mv.PATCH_SHAPES)) + check("only the flat swatch asks for the straight-on camera", + [k for k, _, _ in mv.PATCH_SHAPES if mv.patch_subject(k)[1] is not None] + == ['FLAT']) + + TILE = 64 + + def patch_once(shape_keys, labels=False, tag=""): + """Render one material. (result, grid pixels, how many tiles).""" + s.patch_labels = labels + s.patch_size = TILE + s.patch_shapes = [k in shape_keys for k, _, _ in mv.PATCH_SHAPES] + folder = os.path.join(out, "shape_" + "_".join(sorted(shape_keys)) + tag) + s.render_output_dir = folder + os.sep + r = h.run(bpy.ops.mv.render_all) + grid = os.path.join(folder, "materials", "Bricks_A_patch.png") + rows = max(1, sum(1 for i in s.patch_lighting if i.enabled)) + px = mv.load_pixels(grid) if os.path.isfile(grid) else None + return r, px, len(shape_keys) * rows + + def filled(px, tiles): + """How much of the tiles' own area is not the white background. + + Measured against the tiles rather than the whole canvas, which + also carries gaps and, with labels on, a caption gutter. + """ + lit = (np.abs(px[..., :3] - 1.0).max(axis=-1) > 0.08).sum() + return float(lit) / (tiles * TILE * TILE) + + r, flat, flat_n = patch_once({'FLAT'}) + check("a flat-only patch renders", r == {'FINISHED'} and flat is not None, r) + r, sphere, sphere_n = patch_once({'SPHERE'}) + check("a sphere-only patch renders", r == {'FINISHED'} and sphere is not None, r) + if flat is not None and sphere is not None: + # One column, two lighting rows, so both grids are the same shape. + check("one shape and two lightings give a 1 x 2 grid", + flat.shape == sphere.shape and flat.shape[1] < flat.shape[0], + (flat.shape, sphere.shape)) + check("the flat swatch fills its tile", + filled(flat, flat_n) > 0.9, filled(flat, flat_n)) + # A sphere inscribed in its tile covers pi/4; the padding takes a + # little more off, so anything well inside that range is in frame. + check("the sphere is in frame and reads as a sphere", + 0.45 < filled(sphere, sphere_n) < 0.8, filled(sphere, sphere_n)) + + r, wide, wide_n = patch_once({'FLAT', 'SPHERE', 'CUBE', 'CYLINDER'}) + check("four shapes render", r == {'FINISHED'} and wide is not None, r) + if wide is not None and flat is not None: + check("more shapes make a wider grid, same height", + wide.shape[1] > flat.shape[1] and wide.shape[0] == flat.shape[0], + (wide.shape, flat.shape)) + check("every shape sits well inside its tile", + 0.4 < filled(wide, wide_n) < 0.95, filled(wide, wide_n)) + + s.patch_lighting[1].enabled = False + r, short, short_n = patch_once({'FLAT'}, tag="_one") + if short is not None and flat is not None: + check("one fewer lighting makes a shorter grid", + short.shape[0] < flat.shape[0], (short.shape, flat.shape)) + + r, labelled, _ = patch_once({'FLAT'}, labels=True, tag="_lab") + if labelled is not None and short is not None: + check("labels add a caption gutter and a title", + labelled.shape[0] > short.shape[0] + and labelled.shape[1] > short.shape[1], + (labelled.shape, short.shape)) + + check("no patch scene or light is left behind", + not any(sc.name.startswith("MV_Patch") for sc in bpy.data.scenes) + and not any(o.name.startswith("MV_Patch") for o in bpy.data.objects) + and not any(l.name.startswith("MV_Patch") for l in bpy.data.lights), + [sc.name for sc in bpy.data.scenes]) + + s.patch_labels = True + s.patch_shapes = [k in {'FLAT', 'SPHERE'} for k, _, _ in mv.PATCH_SHAPES] + for item in s.patch_lighting: + item.enabled = False + s.render_output_dir = os.path.join(out, "patches") + os.sep + # ---- and with no product in the scene at all bpy.data.collections.remove(coll) s.render_output_dir = os.path.join(out, "bare") + os.sep