diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1eb0c70 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Tests + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 90 + env: + # The version the suite is written against. Bump deliberately: Blender + # changes its Python API between releases. + BLENDER_VERSION: "5.0.0" + steps: + - uses: actions/checkout@v4 + + - name: Cache Blender + id: cache-blender + uses: actions/cache@v4 + with: + path: blender + key: blender-${{ env.BLENDER_VERSION }}-linux-x64 + + - name: Download Blender + if: steps.cache-blender.outputs.cache-hit != 'true' + run: | + series="${BLENDER_VERSION%.*}" + curl -fsSL -o blender.tar.xz \ + "https://download.blender.org/release/Blender${series}/blender-${BLENDER_VERSION}-linux-x64.tar.xz" + mkdir blender + tar -xf blender.tar.xz -C blender --strip-components=1 + + - name: Install system libraries + # Blender's Workbench and Cycles renders run headless through EGL; + # Mesa supplies a software GL on a runner without a GPU. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libegl1 libgl1 libglx-mesa0 libgl1-mesa-dri libxi6 libxxf86vm1 \ + libxfixes3 libxrender1 libxkbcommon0 libsm6 libice6 + + - name: Run tests + run: python3 tests/run_tests.py --blender blender/blender diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..4e1a3d6 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,24 @@ +cff-version: 1.2.0 +message: "If you use MultiView Product Renderer in your research, please cite it as below." +title: "MultiView Product Renderer" +type: software +authors: + - given-names: Danyal + family-names: Sarfraz +version: 1.11.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. +keywords: + - Blender + - CAD + - STEP + - multi-view rendering + - synthetic data + - dataset generation + - engineering drawings diff --git a/README.md b/README.md index aae6f45..fe8538e 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ A Blender add-on for turning CAD products into consistent, batch-rendered image sets. -Import STEP and STL files, standardize them at the world origin, build a rig of orthographic and -perspective cameras plus 3-point studio lighting, then render every product across every -view in one click, with predictable file names like `AA_01_Widget_Front_clay.png`. +Import STEP, IGES and mesh files, standardize them at the world origin, build a rig of +orthographic and perspective cameras plus studio lighting, then render every product across +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.4.0 +- **Version:** 1.11.0 +- **Cite:** see [`CITATION.cff`](CITATION.cff) --- @@ -43,38 +44,57 @@ Restart Blender and enable the add-on as in step 4 above. --- -## STEP import setup (optional) +## STEP / IGES import setup (optional) -You only need this if you want to import `.step` / `.stp` files directly. If you already -have meshes in your scene, skip it. The add-on works with any geometry. +You only need this if you want to import `.step` / `.stp` or `.iges` / `.igs` files +directly. If you already have meshes in your scene, skip it. The add-on works with any +geometry. -**`.stl` files need none of this.** An STL already holds a mesh, so it is imported straight -through Blender and never touches FreeCAD or STEPper, whatever the STEP Import Mode is set -to. From the moment it lands it is treated exactly like a converted STEP file: same product -collection, same Fit on Import, same standardizing, same batch render and file naming. +**Mesh files need none of this.** STL, OBJ, PLY, glTF/GLB, FBX and 3MF already hold a mesh, +so they are imported straight through Blender and never touch FreeCAD or STEPper, whatever +the STEP Import Mode is set to. From the moment one lands it is treated exactly like a +converted STEP file: same product collection, same Fit on Import, same standardizing, same +batch render and file naming. + +| Format | Imported by | Notes | +| --- | --- | --- | +| STEP (`.step`, `.stp`) | FreeCAD or STEPper | Parts keep their names and colours (FreeCAD route) | +| 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 | Open `Edit → Preferences → Add-ons → MultiView Product Renderer` and expand the preferences: | Setting | What it does | | --- | --- | -| **STEP Import Mode** | `Auto` tries the STEPper add-on first, then falls back to FreeCAD. Or force one. STEP only; STL ignores it. | -| **FreeCAD Executable** | Path to `freecadcmd.exe` (Windows) or `freecadcmd` (macOS/Linux). STEP only. | +| **STEP Import Mode** | `Auto` tries the STEPper add-on first, then falls back to FreeCAD. Or force one. STEP only: IGES always uses FreeCAD, and mesh files ignore it. | +| **FreeCAD Executable** | Path to `freecadcmd.exe` (Windows) or `freecadcmd` (macOS/Linux). STEP and IGES. | | **Tessellation Deflection** | FreeCAD mode only. Lower = finer mesh, bigger files. Default `0.1`. | **FreeCAD route**: install [FreeCAD](https://www.freecad.org/) and point the preference at its console binary, typically: ``` -C:\Program Files\FreeCAD 0.21\bin\freecadcmd.exe +C:\Program Files\FreeCAD 1.1\bin\freecadcmd.exe ``` -The add-on shells out to FreeCAD, tessellates each solid into a mesh, writes a temporary -OBJ, and imports that. Nothing is left behind in your FreeCAD install, and the scratch +The add-on shells out to FreeCAD, tessellates each part into a mesh, writes temporary OBJs, +and imports those. Nothing is left behind in your FreeCAD install, and the scratch directory is removed even if you cancel. FreeCAD is run with its standard input closed, so a build that stops to ask a question fails immediately instead of stalling the import. Any single conversion is given 15 minutes before it is stopped. +**Parts and colours.** Each part of a STEP assembly comes in as its own object, named the +way the CAD file names it (`Housing`, `Lid`, `Screw`...), all inside the one product +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 +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 and uses its import operator automatically. @@ -86,8 +106,9 @@ The sidebar is laid out in the order you should use it. ### 1. Scene Setup -Pick a lighting preset and a background, then **Apply Scene Setup**. This builds an -`MV_Lighting` collection and switches the scene to the add-on's own `MV_World`. +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. @@ -98,17 +119,81 @@ you built yourself survives and can be picked again from World properties. | **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 -**Intensity** multiplies the preset's light energies. **Transparent Background** renders -alpha instead of the **Background Color**. `Rebuild Lighting` and `Apply Background` re-run -either half on its own. +- **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. ### 2. Import Products -Add one or more `.step` / `.stp` / `.stl` files to the list and click **Import CAD Files**. -Each file becomes its own collection under `MV_Products`. That grouping is what the batch -renderer iterates over. You can mix STEP and STL freely in one list; each file is routed by -its extension, and a file that fails does not stop the ones after it. +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 +Subfolders** in the file browser's sidebar to go deeper, on by default) and skips files that +are already in the list. Each file becomes its own collection under `MV_Products`. That +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 (converting)`, Blender stays responsive, and **Esc** cancels after the file in flight. @@ -116,10 +201,16 @@ Files that fail are skipped rather than stopping the batch, and FreeCAD's own er message is reported instead of a generic failure -- run `Window > Toggle System Console` to see the full list. -STL files skip the conversion step entirely, so they land immediately rather than showing -`(converting)`. A `.stl` that Blender cannot read is reported and skipped rather than +Mesh files skip the conversion step entirely, so they land immediately rather than showing +`(converting)`. A file that Blender cannot read is reported and skipped rather than becoming an empty product collection that would render as blank frames. +**Mesh Units** says what unit the numbers in STL, OBJ, PLY, glTF and FBX files are in +(millimetres by default, which is what CAD exports almost always use). It does not change +the geometry; it is only used to report each product's real size and to scale products +against each other with **Keep Proportions**. STEP and IGES come out of FreeCAD in +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. @@ -135,10 +226,22 @@ origin. Turn it off if you want to place and scale products yourself. **Standardize Products** applies the same fit to everything already under `MV_Products`, which is what you want after dragging in your own meshes or importing with the option off: -- **Recenter**: moves each product's bounding box centre onto the world origin. -- **Rescale**: uniformly scales each product so its largest dimension equals **Target - Size**. This is what makes a tiny screw and a large housing frame read identically in - the final images. +- **Recenter**: moves each product's bounding box centre onto the world origin. With + **Rest on Floor**, the bottom of the box goes on the origin instead, so the product stands + on the ground rather than straddling it. +- **Rescale**: uniformly scales each product, one of two ways: + - **Fit Each** (default): every product's largest dimension becomes **Target Size**. This + is what makes a tiny screw and a large housing frame read identically in the final + images. + - **Keep Proportions**: the biggest product in the batch becomes Target Size and the rest + keep their real size relative to it, so a 10 mm screw next to a 400 mm housing renders + at a fortieth of its size. Uses the real sizes recorded at import, converted through + each file's units. + +Each product's real size, measured before anything rescaled it, is shown under the +Standardize button for the active product (`120 x 40 x 15.5 mm`) and written to the +manifest. Products assembled by hand are measured the first time Standardize runs on them, +in metres. - **Auto Smooth**: fixes the faceted look a tessellated CAD import arrives with. FreeCAD writes per-face normals that Blender keeps as custom split normals, and some Blender @@ -170,6 +273,12 @@ Choose a view set and click **Build Cameras**. Cameras land in `MV_Cameras`, nam | **Orthographic** | Front, Back, Top, Bottom, Right, Left (6) | | **Standard** | Front, Top, Right, Perspective (4) | | **Minimal** | Front, Top, Right (3) | +| **Custom** | Whichever of the nine you tick | + +Under the dropdown is a checkbox for each of the nine views. With a preset selected they are +greyed out and show what the preset includes. Pick **Custom** and they become editable, +starting from the preset you had selected, so going from Standard to "Standard plus Back" +takes one tick. **Front Axis** tells the add-on which world axis points out of the front of your product. CAD exports usually land on `+X` (the default); Blender's own convention is `-Y`. Getting @@ -183,7 +292,30 @@ All orthographic views share one framing, and all perspective views share anothe product reads at the same scale across the whole sheet rather than being re-fitted per view. Need an angle that isn't in the presets? Add a **Custom View** with an X/Y/Z direction -vector and an ortho/perspective toggle, and it gets built alongside the rest. +vector and an ortho/perspective toggle, and it gets built alongside the rest. The checkbox at +the start of its row switches it off without deleting it. + +#### Published protocols + +**Protocol** replaces the View Set with the camera rig of a published dataset or method, so +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 | +| **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) | + +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 +works do, and are multiplied by **Target Size**: with **Fit Each** and a Target Size of +`1.0` they are the published numbers. Random protocols take a **Seed**, so the same rig can be +built again exactly. MVDream samples its elevation and field of view at random; the preset +fixes them at the middle of its ranges, and the paper should say so if it matters. #### Orient Products @@ -217,9 +349,19 @@ through. Rotation is about the product's own centre, so it never drifts out of f multi-part assembly turns as one rigid piece. **Rotate Step** sets how far one press turns it, 90 degrees by default, so four presses of the same button return to where you started. -Turning a product changes its bounding box, so **click Build Cameras again once you are -done orienting**. Framing is measured across the whole batch, and it is measured before you -start rotating. +**Auto Orient** turns the product for you, and **All** next to it does the whole batch: + +| Method | What it does | +| --- | --- | +| **Lay Flat** (default) | Rests the product on its largest flat face, then turns it about the vertical so its footprint lines up with the axes and its longest side runs across the Front view. Right for boxes, housings, panels and most machined parts. | +| **Principal Axes** | Lines the product's longest spread up across the Front view, the next longest towards the Front camera, and the thinnest upright. Better for rounded or organic shapes with no flat face. It may leave a product upside down; Up/Down twice fixes that. | + +**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. ### 5. Batch Render @@ -232,7 +374,104 @@ Set an **Output Directory**, choose a **Render Mode**, and hit **Render All**. | **Both** | Writes a `_render` and a `_clay` image per view. | Every product is isolated in turn (all others excluded from the view layer), so nothing -bleeds between shots. Progress prints to the system console. +bleeds between shots. + +#### Extra passes + +Tick any of these under **Extra passes** to write another image per view alongside the +render mode's. Each has its own `{mode}` in the file name, and gets its own contact sheet. + +| Pass | `{mode}` | What it is | +| --- | --- | --- | +| **Line Art** | `lines` | A technical drawing: silhouettes and creases as lines. **Crease Angle** sets how sharp a fold has to be to get a line; smooth curves get only their outline. **Fill** is White (black on white), Clay (lines over the clay render) or None (lines on transparency). | +| **Mask** | `mask` | White product on black, antialiased, for use as a matte. | +| **AO** | `ao` | Ambient occlusion: a white product darkened in its creases and recesses, with lighting and materials taken out. **Distance** is how far occlusion reaches, as a fraction of the product's size. Needs the Cycles add-on (on by default). | + +Line Art and Mask are fast Workbench renders whatever your engine is. Line Art finds its +lines from the geometry's normals rather than with Freestyle, so it needs nothing set up +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, +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. + +| Severity | Found | One-click fix | +| --- | --- | --- | +| Error | No products, or none ticked | | +| Error | Output format is a video rather than an image | | +| Error | Output path is a file, or the folder cannot be written | | +| 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 | | +| 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 | +| Warning | An empty product. It is skipped when rendering rather than producing blank frames | | +| Warning | Faces with flipped normals, or a whole mesh inside out | Recalculate normals | +| Warning | Products are spread around the scene instead of centred, so each renders as a speck | Recenter | +| Warning | Objects outside `MV_Products` that would appear in every shot | Switch off their render visibility | +| Warning | Full render with no lights and no HDRI | Build lighting | +| Warning | Lights Follow Camera is on but the lighting predates the rig | Rebuild lighting | +| 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 +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 +in opposite directions, and where they run the same way one of them is flipped. A closed +mesh whose faces all agree but enclose a negative volume is inside out as a whole. The fix +recalculates every face of the product to point outwards. + +#### Progress and cancelling + +The batch runs one image at a time with Blender still responsive. The status bar shows how +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 +``` + +**Esc** stops the batch after the image currently rendering. The time left is an average of +the images so far, so it settles after the first few. Each file is also printed to the +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. + +**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. + +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 +product later writes into the same folder it used before. + +#### Resuming with Skip Existing + +With **Skip Existing** ticked, images that are already in the output folder are left alone +and only the missing ones are rendered. Use it to pick up a batch that crashed or that you +cancelled. The counter and the time left then cover only the remaining images. A zero-byte +file, which is what an interrupted write leaves behind, counts as missing. + +Leave it off (the default) when you have changed a setting and want every image redone. +Skip Existing only checks whether a file is there, not what settings it was rendered with. + +#### Reframing + +**Reframe Before Rendering** (on by default) rebuilds the camera rig from the current view +selection, Front Axis, padding, output resolution and product positions before the first +image. A product you rotated, or a resolution you changed after building cameras, can't +leave a stale framing behind. + +Framing always covers the whole batch, ticked or not, so a product rendered on its own comes +out at the same scale as the run it belongs to. Turn this off if you have moved or adjusted +the `MV_Cam_*` cameras by hand and want those used as they are. **Only products are isolated.** Geometry that lives outside `MV_Products` is not a product, so it is never hidden and turns up in every shot of every product. The usual offender is the @@ -286,17 +525,299 @@ The `AA_01` label is controlled by three fields: letter pair. After `ZZ` the label grows a character rather than wrapping, so it runs `AAA`, `BBB`, ... and every product keeps a folder of its own. +#### Naming templates + +The layout above is the default. **Naming → Folder** and **Naming → File Name** let you +change it with tokens: + +| Token | Example | Meaning | +| --- | --- | --- | +| `{label}` | `AA_01` | The product's label | +| `{prefix}` | `AA` | The letter part of the label | +| `{number}` | `01` | The number part of the label | +| `{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 | + +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 +`..._Front_{mode}` gives `..._Front` in Full Render mode rather than a trailing underscore. + +- Use `/` in the folder template to nest folders, e.g. `{mode}/{label}_{product}` puts clay + and full renders in separate trees. Leave it empty to put everything straight in the + output directory. +- An unknown token is left in the name as typed, so a typo shows up in the preview under the + 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. + +#### Contact sheets + +Tick **Contact Sheet** and each product also gets one image with all of its views in a grid, +the product's name across the top and each view's name underneath: +`AA_01_Widget/AA_01_Widget_Sheet_clay.png`. Both mode makes one sheet per mode. + +- **Columns**: views per row. `0` picks a roughly square grid. +- **Cell Width**: how wide each view is drawn, in pixels. +- **Labels**, **Background**, **Text**: the captions and colours. + +With Post-Processing on, the sheet is built from the processed copies, so trimmed images +make a tighter sheet. Labels need a Blender recent enough to draw text into an image (5.0 +does); on an older one the sheet is drawn without captions. + +#### Turntables + +Tick **Turntable** and each product is also rendered spinning one full turn, then encoded: + +- **Style**: Clay (fast, matches the clay stills) or Full Render. +- **Frames** and **FPS**: 36 frames at 24 fps is a 1.5 second loop. +- **Elevation**: how far above the product the camera looks down from. +- **MP4** (H.264) and/or **GIF**. The GIF is scaled so its longest edge is **GIF Size**, + and uses one palette for the whole loop so the background doesn't flicker. + +The product turns about its own centre under the fixed studio lights, as on a real +turntable. Every product is framed by one camera wide enough for the whole batch at every +angle, so turntables share a scale the same way the stills do. Frames are kept in a +`turntable` folder next to the stills (`AA_01_Widget/turntable/..._001.png`); the video and +GIF go next to the stills. Transparent renders are put on the scene's **Background Color**, +since neither format does transparency well. The product is put back exactly where it +was afterwards, even if you cancel half way through. + +#### Post-processing + +Tick **Post-Processing** to write a processed copy of every image. **The original renders +are never touched**: the copies go in a separate folder (**Folder**, default `processed`) +that mirrors the original layout, so you can change the settings and re-process without +re-rendering. + +| Option | What it does | +| --- | --- | +| **Trim to Content** | Crops the empty background around the product, leaving **Margin** (a fraction of the product's size) around it. Each image then fills its own frame, so views no longer share one scale. | +| **Pad to Square** | Extends the shorter side with background so the image is square. | +| **Flatten** | Puts transparent renders on a solid colour. JPEG always does this. | +| **Format** | PNG, JPEG or WebP, with **Quality** for the lossy two. | +| **Max Size** | Shrinks the image so its longest edge is at most this many pixels. `0` keeps the size. | +| **Extra sizes** | Smaller copies too, e.g. `1024, 256` writes `..._Front_clay_1024.jpg` and `..._Front_clay_256.jpg`. Images are never scaled up. | + +Trimming works on transparency when the render has it, and otherwise on whatever colour the +four corners share, so it works on a solid studio background too. + +With **Skip Existing** on, an original that is already on disk is not re-rendered, but a +missing processed copy of it is still made. To re-process everything after changing the +settings, delete the `processed` folder and render again with Skip Existing on: only the +copies are rebuilt. + +#### Manifest + +**Manifest** (CSV + JSON by default) writes `manifest.csv` and/or `manifest.json` into the +output directory, listing every output with: + +`label`, `product`, `kind` (render, processed, sheet, turntable), `view`, `mode`, `path` +(relative to the output directory), `width`, `height`, `source_file`, `source_size` and +`source_units`. + +`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 +`source_units` (`mm` for STEP and IGES, the file's own unit for 3MF, **Mesh Units** for +other meshes, empty after a STEPper import). Products assembled by hand have no source file +and are measured in metres the first time Standardize runs. + +Rendering part of a batch updates the rows for those products and keeps the rest, so the +manifest always describes everything in the output directory. Rows for files that have +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 +- the Blender version and the `.blend` file +- render engine, samples, resolution, file format and view transform +- start and finish times +- a snapshot of every setting in the panels, including protocol and randomization seeds + +**Croissant** (in the Manifest section) also writes `croissant.json`: an +[MLCommons Croissant 1.0](https://docs.mlcommons.org/croissant/docs/croissant-spec.html) +description of the output as a dataset, which the NeurIPS datasets track asks for. +- 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. +- 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. + +Croissant always writes the CSV manifest, whatever **Write** is set to. After a sharded +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 +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. + +| Setting | Writes | Notes | +| --- | --- | --- | +| **Cameras** (on by default) | `AA_01_Widget_cameras.json`, `AA_01_Widget_transforms.json`, and `..._Turntable_..._transforms.json` | See below | +| **Depth** | `..._Front_depth.exr` | 32-bit EXR, distance along the view axis in scene units, 0 and alpha 0 off the product | +| **Normals** | `..._Front_normal.exr` | 32-bit EXR, camera space, OpenGL axes (+X right, +Y up, +Z towards the camera), values -1..1 | +| **Part IDs** | `..._Front_parts.png` | 16-bit greyscale PNG: 0 is background, *n* is the *n*-th part in `cameras.json`'s `parts` list. Parts are the product's objects, so a STEP assembly's parts keep the names its CAD file gave them | +| **COCO** | `coco.json` | One instance mask (uncompressed RLE) per visible part per image, with `part_id` and `part_name`. Categories are products. Turns on Part IDs | +| **BOP** | `bop/` | A [BOP](https://github.com/thodan/bop_toolkit/blob/master/docs/bop_datasets_format.md) dataset from the perspective views: `train_pbr//rgb`, `depth` (uint16, mm), `mask`, `mask_visib`, `scene_camera.json`, `scene_gt.json`, `scene_gt_info.json`, plus `models/obj_*.ply` and `models_info.json`. Turns on Depth | + +Depth, Normals and Part IDs are each one extra Cycles render per view: one sample, no +antialiasing filter, every surface replaced by an emitter that outputs the number itself. +Each pixel therefore belongs to exactly one surface. There is no blending of a part's ID with +the background, or of a foreground depth with what is behind it. They need the Cycles add-on +(on by default) whatever engine the Full render uses. They are never post-processed or put +on a contact sheet. + +**`cameras.json`** describes every view of the product: + +- `c2w_opengl`: camera-to-world matrix, Blender/OpenGL axes (looking down -Z, +Y up). +- `w2c_opencv`: world-to-camera matrix, OpenCV axes (looking down +Z, +Y down), as COLMAP + and BOP use. +- `K` for perspective views. Orthographic views get `pixel_size` and `principal_point` + 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, + 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. + +**`transforms.json`** is in the nerfstudio/Instant-NGP format, for NeRF and Gaussian +splatting tools. It lists the perspective views only, with per-frame intrinsics and +`mask_path` when the Mask pass is on. A turntable gets its own `transforms.json`: the +product turns and the camera stays still, so each frame is given the camera that would +circle a still product. That is what those tools expect. + +BOP wants rigid poses in millimetres of the model, although the scene was scaled to fit. +The scale is taken back out of the camera translation, the model is exported in its own +source units converted to mm, and depth is written in the same mm, with a `depth_scale` per +image so large parts still fit in 16 bits. Only perspective views go into BOP, because the +format has no orthographic camera. + +#### Engineering drawings + +Tick **Drawings** for vector three-view drawings of each product: Front, Top and Right as +separate SVGs (`AA_01_Widget_Front_drawing.svg`, ...), and all three on one sheet +(`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 + 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 + against a depth image of the same view, along its whole length, so a line that passes + behind something turns dashed exactly where it disappears. **Accuracy** is that depth + image's size; raise it for very fine detail. +- **Drafting layout.** The views use cameras of their own, oriented the way a drawing + needs. The plan is seen with the front at the bottom, so its width lines up with the front + elevation's, and the side view stands upright. **Third Angle** puts the plan above and the + right view on the right; **First Angle** puts the plan below and the right view on the + left. +- **Dimensions** print each view's overall width and height in millimetres. + +Because lines come from the mesh, a curved face's tessellation can add short creases where +the facets are coarse. A higher Crease Angle, or a finer Tessellation Deflection at import, +removes them. The drawings have no centre lines or detailed dimensioning; they are +a faithful outline, not a finished drawing. + +#### Randomization + +**Randomize** varies each image for training data while keeping every annotation exact: + +| Setting | What varies | +| --- | --- | +| **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 | +| **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 +always give the same images. A product or view added later does not shift anyone else's +numbers. A view's image, mask, depth, normals and part IDs are all rendered from the same +jittered camera, and that is the camera `cameras.json`, `transforms.json` and BOP record. +Distractors are only ever placed on the far side of the product from the camera, so they +never cover it, and they appear only in Full renders. Masks, part IDs, COCO instances and BOP +visibility therefore stay exactly the product. Turntables are not randomized. Everything is +put back afterwards: lights, world, cameras, and the distractors are deleted. + +## Command line + +Everything the panels do can run without Blender's window, from a job file: + +```bash +blender -b --factory-startup --python multiview_product_renderer/cli.py -- --config job.json +``` + +The add-on does not need to be installed for this: `cli.py` loads it from the folder it sits +in. A job is JSON or TOML, and relative paths in it are relative to the job file: + +```json +{ + "inputs": ["parts/", "extra/housing.step"], + "recursive": true, + "output": "renders/", + "freecad": "C:/Program Files/FreeCAD 1.1/bin/freecadcmd.exe", + "step_import_mode": "FREECAD", + "standardize": false, + "auto_orient": "FLAT", + "scene_setup": true, + "settings": { + "render_mode": "CLAY", + "view_set": "ALL", + "pass_depth": true, + "export_coco": true, + "turntable_elevation": 20 + }, + "render": {"engine": "CYCLES", "samples": 64, "device": "CPU", + "resolution": [1024, 1024], "file_format": "PNG"}, + "save_blend": "renders/job.blend" +} +``` + +| Key | Meaning | +| --- | --- | +| `blend` | Start from this `.blend` instead of an empty scene. With no `inputs`, its existing products are rendered | +| `inputs`, `recursive` | Files and folders to import (folders expanded, sorted) | +| `output` | Output folder; `--output` overrides it | +| `freecad`, `step_import_mode`, `deflection` | STEP/IGES conversion, as in the add-on preferences | +| `standardize`, `auto_orient` | Run Standardize Products / Auto Orient (true, `"FLAT"` or `"PCA"`) on everything after import | +| `scene_setup` | Apply Scene Setup (lighting, background, ground). Default true | +| `settings` | Any panel setting by name; `--list-settings` prints them all with their types. Angles are in degrees | +| `render` | `engine`, `samples`, `device`, `resolution`, `percentage`, `file_format` | +| `save_blend` | Save the set-up scene afterwards, to open and inspect in Blender | + +Other modes: + +- `--check` imports and sets up, runs the pre-flight check, prints its findings and renders + nothing. +- `--shard 2/8` imports and renders only every eighth file, starting with the third. Labels + still count through the whole job, so eight shards started at once, on one machine or + eight, write exactly the files one run would. Each writes its own + `manifest_shard02.json`, `coco_shard02.json` and BOP `models_info_shard02.json`. +- `--merge renders/` then combines those into `manifest.json`/`.csv`, `coco.json` and + `models_info.json`. +- `--list-settings` prints every setting `settings` accepts. + +It prints a `MULTIVIEW_SUMMARY {...}` JSON line at the end. The exit code is 0 when +everything rendered, 1 when some images failed, and 2 when the job could not run: a bad job +file, nothing imported, or pre-flight errors. + ## Collections the add-on creates | Collection | Contents | | --- | --- | | `MV_Products` | One child collection per product. This is what gets iterated and isolated. | | `MV_Cameras` | Generated cameras, named `MV_Cam_`. | -| `MV_Lighting` | The lights from the active lighting preset. | +| `MV_Lighting` | The lights from the active lighting preset, hung off the `MV_LightRig` empty. | +| `MV_Stage` | The ground plane, `MV_Ground`, when Ground is not None. | -It also creates one world datablock, `MV_World`, for the background. +It also creates one world datablock, `MV_World`, for the background, and a few materials +named `MV_...` for the ground, the AO pass and the material presets. Rebuilding cameras or lighting clears and regenerates only that collection, discarding the camera and light datablocks it made last time rather than leaving them behind. Your @@ -306,7 +827,17 @@ 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. +**"No cameras. Build cameras first."**: run *Build Cameras* in the Cameras / 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. + +**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 @@ -333,13 +864,32 @@ the default while orienting. Click **Show All** in Cameras / Views, or untick th Renders are unaffected either way. **Renders are cropped after rotating products**: rotating changes the bounding box the -framing was measured from. Click *Build Cameras* again. +framing was measured from. Switch on **Reframe Before Rendering**, or click *Build Cameras* +again. **Imported STEP geometry is blocky**: lower **Tessellation Deflection** in preferences (try `0.02`) and re-import. --- +## Tests + +The `tests/` folder holds the test suite: each `test_*.py` runs inside a background Blender +and checks the add-on end to end, by rendering real images and reading them back. + +```bash +python tests/run_tests.py --blender "C:/Program Files/Blender Foundation/Blender 5.0/blender.exe" +``` + +Any Python 3 runs the runner, including Blender's own (`/5.0/python/bin/python`). +FreeCAD is optional: with `freecadcmd` on `PATH`, or `FREECAD_CMD` set, the STEP and IGES +import checks run too; without it they are skipped (`FREECAD_CMD=none` forces that). GitHub +Actions runs the suite on every push against Blender 5.0.0 (`.github/workflows/tests.yml`), +without FreeCAD. + +The suite is written against **Blender 5.0**. The add-on aims to support 4.0 and newer, but +only 5.0 is tested. + ## License MIT. See [LICENSE](LICENSE). diff --git a/multiview_product_renderer/__init__.py b/multiview_product_renderer/__init__.py index e673500..16f4da4 100644 --- a/multiview_product_renderer/__init__.py +++ b/multiview_product_renderer/__init__.py @@ -1,24 +1,35 @@ bl_info = { "name": "MultiView Product Renderer", "author": "Danyal Sarfraz", - "version": (1, 4, 0), + "version": (1, 11, 0), "blender": (4, 0, 0), "location": "View3D > Sidebar > MultiView", - "description": "Import STEP and STL files, set up cameras and 3-point lighting, batch render products across views.", + "description": "Import CAD and mesh files, set up cameras and studio lighting, batch render products across views.", "category": "Render", } import bpy +import blf import os +import re +import csv +import json import math +import struct import subprocess import tempfile import shutil import time -from mathutils import Vector, Matrix +import traceback +import zlib +from contextlib import contextmanager +from datetime import datetime +import numpy as np +from mathutils import Vector, Matrix, Quaternion from bpy.props import ( StringProperty, EnumProperty, BoolProperty, FloatProperty, - IntProperty, FloatVectorProperty, CollectionProperty, PointerProperty, + IntProperty, FloatVectorProperty, BoolVectorProperty, CollectionProperty, + PointerProperty, ) from bpy.types import ( Operator, Panel, PropertyGroup, AddonPreferences, UIList, @@ -79,7 +90,35 @@ {"name": "Rim", "pos": ( 0.0, 1.8, 2.4), "size": 3.5, "energy": 900, "color": (1, 1, 1)}, ], }, + "NONE": { + "label": "No Lights (HDRI only)", + "lights": [], + }, +} + +# Saved lighting presets are tagged with this prefix in the enum, so they can +# never collide with a built-in key. +USER_PRESET_PREFIX = "USER:" +LIGHT_RIG = "MV_LightRig" + +MATERIAL_PRESETS = { + "MATTE_PLASTIC": {"label": "Matte Plastic", "inputs": {"Roughness": 0.55}}, + "GLOSSY_PLASTIC": {"label": "Glossy Plastic", "inputs": {"Roughness": 0.12, "Coat Weight": 0.4}}, + "RUBBER": {"label": "Rubber", "inputs": {"Roughness": 0.9}}, + "BRUSHED_METAL": {"label": "Brushed Metal", "inputs": {"Metallic": 1.0, "Roughness": 0.35, + "Anisotropic": 0.8}}, + "POLISHED_METAL": {"label": "Polished Metal", "inputs": {"Metallic": 1.0, "Roughness": 0.06}}, + "GLASS": {"label": "Glass", "inputs": {"Roughness": 0.02, "IOR": 1.45, + "Transmission Weight": 1.0}}, } +MATERIAL_ITEMS = [(k, v["label"], f"Render with a {v['label'].lower()} material") + for k, v in MATERIAL_PRESETS.items()] +MATERIAL_MOD = "MV_Material" +MATERIAL_GROUP = "MV_SetMaterial" + +UNIT_ITEMS = [("mm", "Millimetres", ""), ("cm", "Centimetres", ""), ("m", "Metres", ""), + ("in", "Inches", ""), ("ft", "Feet", "")] +UNIT_TO_MM = {"um": 0.001, "mm": 1.0, "cm": 10.0, "m": 1000.0, "in": 25.4, "ft": 304.8} # Object types that put something in front of a camera. Lights and cameras # are not here; neither are empties, which only carry a transform. @@ -88,22 +127,41 @@ CAMERA_COLL = "MV_Cameras" LIGHT_COLL = "MV_Lighting" PRODUCT_COLL = "MV_Products" +STAGE_COLL = "MV_Stage" +DISTRACTOR_COLL = "MV_Distractors" WORLD_NAME = "MV_World" +GROUND_NAME = "MV_Ground" GUIDE_LINES = [ - "1. Scene Setup: pick a lighting preset and background, then Apply.", - "2. Import: add STEP or STL files, then click Import. STEP needs", - " FreeCAD or STEPper; STL is already a mesh and imports directly.", - " Each file becomes a collection under MV_Products.", + "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, and fix", - " the faceted shading a CAD tessellation arrives with. Fit on Import", - " does the same automatically as each file lands.", - "4. Cameras: pick a view set and front axis, click Build.", - " Then use Orient Products to walk the batch one at a time and turn", - " anything that faces the wrong way, before rebuilding the cameras.", + "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,", - " then Render All. Files are named PREFIX_NN_Product_View.", + " 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.", + "", + "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.", "", @@ -112,6 +170,121 @@ ] +# -- published view protocols ---------------------------------------------------- +# +# Camera rigs from papers that render 3D object collections, so a dataset can +# be rendered the way a benchmark expects. Azimuth 0 is the Front view and 90 +# the Right view; elevation is above the horizon. "radius" is a camera +# distance in units of the object's largest dimension, as these works +# normalize into a unit cube; it is multiplied by Target Size. Views without a +# radius use MultiView's own shared framing. "fov" is the horizontal field of +# view in degrees (sensor fitted to the longer image side). + +def sphere_dir(azimuth, elevation): + a, e = math.radians(azimuth), math.radians(elevation) + return (math.sin(a) * math.cos(e), -math.cos(a) * math.cos(e), math.sin(e)) + + +def _radical_inverse(i): + result, f = 0.0, 0.5 + while i: + result += f * (i & 1) + i >>= 1 + f *= 0.5 + return result + + +def _hammersley_views(n, rng): + """TRELLIS's sphere Hammersley sequence, with its random offset seeded.""" + off_u, off_v = rng.random(), rng.random() + views = [] + for i in range(n): + u = i / n + off_u / n + v = (_radical_inverse(i) + off_v) % 1.0 + u = 2 * u if u < 0.25 else 2 / 3 * u + 1 / 3 + pitch = math.acos(max(-1.0, min(1.0, 1 - 2 * u))) - math.pi / 2 + yaw = v * 2 * math.pi + views.append((math.cos(yaw) * math.cos(pitch), math.sin(yaw) * math.cos(pitch), + math.sin(pitch))) + return views + + +def _uniform_sphere(n, rng): + dirs = rng.normal(size=(n, 3)) + return [tuple(d / np.linalg.norm(d)) for d in dirs] + + +VIEW_PROTOCOLS = { + 'MVCNN_12': { + "label": "MVCNN, 12 views", + "source": "Su et al., Multi-view CNNs, ICCV 2015 (arXiv 1505.00880)", + "make": lambda rng: [(f"MVCNN_{i:02d}", sphere_dir(30 * i, 30), None) for i in range(12)], + }, + 'SYNCDREAMER_16': { + "label": "SyncDreamer, 16 views", + "source": "Liu et al., SyncDreamer, ICLR 2024 (arXiv 2309.03453)", + "make": lambda rng: [(f"SD_{i:02d}", sphere_dir(22.5 * i, 30), None) for i in range(16)], + }, + 'ZERO123PP_6': { + "label": "Zero123++ v1.2, 6 views", + "source": "Shi et al., Zero123++ (arXiv 2310.15110), v1.2 README", + "fov": 30.0, + "make": lambda rng: [(f"Z123PP_{i}", sphere_dir(30 + 60 * i, 20 if i % 2 == 0 else -10), None) + for i in range(6)], + }, + 'MVDREAM_4': { + "label": "MVDream, 4 views", + "source": "Shi et al., MVDream, ICLR 2024 (arXiv 2308.16512); elevation and field " + "of view fixed at the middle of its random ranges", + "fov": 37.5, + "make": lambda rng: [(f"MVD_{i}", sphere_dir(90 * i, 15), None) for i in range(4)], + }, + 'ZERO123_12': { + "label": "Zero-1-to-3, 12 random views", + "source": "Liu et al., Zero-1-to-3, ICCV 2023 (arXiv 2303.11328)", + "fov": 49.1, "random": True, + "make": lambda rng: [(f"Z123_{i:02d}", d, float(rng.uniform(1.5, 2.2))) + for i, d in enumerate(_uniform_sphere(12, rng))], + }, + 'TRELLIS_150': { + "label": "TRELLIS, 150 views", + "source": "Xiang et al., TRELLIS, CVPR 2025 (arXiv 2412.01506), dataset_toolkits/render.py", + "fov": 40.0, "random": True, + "make": lambda rng: [(f"TRL_{i:03d}", d, 2.0) + for i, d in enumerate(_hammersley_views(150, 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", + "make": lambda rng: [("Front_0", sphere_dir(0, 0), None), + ("Front_15", sphere_dir(0, 15), None), + ("FrontRight_15", sphere_dir(45, 15), None), + ("Right_15", sphere_dir(90, 15), None), + ("Back_15", sphere_dir(180, 15), None), + ("Left_15", sphere_dir(270, 15), None), + ("FrontLeft_15", sphere_dir(315, 15), None)], + }, +} +PROTOCOL_ITEMS = [('NONE', "None (use View Set)", "Pick views with the View Set below")] + [ + (k, v["label"], v["source"]) for k, v in VIEW_PROTOCOLS.items()] + + +def protocol_views(settings): + """[(name, direction, radius or None)] for the active protocol, or None.""" + spec = VIEW_PROTOCOLS.get(settings.view_protocol) + if spec is None: + return None + return spec["make"](np.random.default_rng(settings.protocol_seed)) + + +def protocol_lens(settings): + spec = VIEW_PROTOCOLS.get(settings.view_protocol) + if not spec or "fov" not in spec: + return PERSP_LENS + return SENSOR_WIDTH / 2.0 / math.tan(math.radians(spec["fov"]) / 2.0) + + class MV_AddonPreferences(AddonPreferences): bl_idname = __name__ @@ -146,11 +319,36 @@ def draw(self, context): layout.prop(self, "tessellation_deflection") +# Set by the command line, which can run the add-on straight from its folder +# without it being installed, and so without add-on preferences to read. +PREFS_OVERRIDE = None + + +def addon_prefs(context): + if PREFS_OVERRIDE is not None: + return PREFS_OVERRIDE + addon = context.preferences.addons.get(__name__) + if addon is not None: + return addon.preferences + from types import SimpleNamespace + return SimpleNamespace(step_import_mode='AUTO', freecad_path="", + tessellation_deflection=0.1) + + +# What the last Render All did, for the command line's summary and exit code. +LAST_RENDER = {} + + class MV_StepFileItem(PropertyGroup): path: StringProperty(name="Path", subtype='FILE_PATH') class MV_CustomView(PropertyGroup): + enabled: BoolProperty( + name="Enabled", + description="Build a camera for this view and render it", + default=True, + ) name: StringProperty(name="Name", default="CustomView") dir_x: FloatProperty(name="X", default=1.0) dir_y: FloatProperty(name="Y", default=-1.0) @@ -158,6 +356,70 @@ class MV_CustomView(PropertyGroup): is_ortho: BoolProperty(name="Ortho", default=False) +def user_preset_dir(create=False): + return bpy.utils.user_resource( + 'SCRIPTS', path=os.path.join("presets", "multiview_lighting"), create=create) + + +def user_lighting_presets(): + """{enum key: (label, path)} for every saved lighting preset. + + Kept in Blender's user scripts folder rather than in the .blend, so a rig + saved once is there in every file, the way Blender's own presets are. + """ + folder = user_preset_dir() + found = {} + if folder and os.path.isdir(folder): + for name in sorted(os.listdir(folder)): + if name.lower().endswith(".json"): + stem = name[:-5] + found[USER_PRESET_PREFIX + stem] = (stem, os.path.join(folder, name)) + return found + + +# Blender keeps only pointers to the strings a dynamic enum returns, so the +# list has to outlive the callback or the labels turn to garbage. +_lighting_items = [] + + +def lighting_preset_items(self, context): + # Built-ins keep the numbers they had as a fixed enum (0, 1, 2, ...), so + # files saved before presets could be added still open on their choice. + # Saved ones get a number from their name, which stays put as others are + # added and removed around them. + items = [(k, v["label"], v["label"], i) for i, (k, v) in enumerate(LIGHTING_PRESETS.items())] + for key, (label, path) in user_lighting_presets().items(): + items.append((key, label, f"Saved preset: {path}", + 1000 + zlib.crc32(key.encode("utf-8")) % 1000000)) + _lighting_items[:] = items + return _lighting_items + + +def lighting_specs(key): + """The light list for a preset key, or None if it cannot be read.""" + if key in LIGHTING_PRESETS: + return LIGHTING_PRESETS[key]["lights"] + entry = user_lighting_presets().get(key) + if entry is None: + return None + try: + with open(entry[1], encoding="utf-8") as f: + lights = json.load(f)["lights"] + for spec in lights: + Vector(spec["pos"]) + float(spec["energy"]) + except (OSError, ValueError, KeyError, TypeError): + return None + return lights + + +class MV_Issue(PropertyGroup): + severity: StringProperty() + message: StringProperty() + product: StringProperty() + fix: StringProperty() + + def _update_isolate(self, context): """Apply the isolate toggle the moment it is ticked, rather than waiting for the next rotate or step. @@ -170,6 +432,25 @@ def _update_isolate(self, context): isolate_product(context, coll.name if (self.isolate_active and coll) else None) +def _update_active_product(self, context): + """Picking a product in the render list makes it the one being oriented, + so isolation follows the click the same way it follows Previous/Next.""" + if self.isolate_active: + coll, _ = active_product(context) + if coll is not None: + isolate_product(context, coll.name) + + +def _update_view_set(self, context): + """Copy a preset into the per-view checkboxes, so switching to Custom + afterwards starts from the views you were just looking at rather than + from a blank or stale selection.""" + if self.view_set == 'CUSTOM': + return + names = {v[0] for v in VIEW_SETS[self.view_set]} + self.views_enabled = [v[0] in names for v in VIEWS_ALL] + + class MV_Settings(PropertyGroup): step_files: CollectionProperty(type=MV_StepFileItem) step_files_index: IntProperty(default=0) @@ -181,8 +462,30 @@ class MV_Settings(PropertyGroup): ("ORTHO", "Orthographic", "Front, Back, Top, Bottom, Right, Left"), ("STD", "Standard", "Front, Top, Side, Perspective"), ("MIN", "Minimal", "Front, Top, Side"), + ("CUSTOM", "Custom", "Tick the views you want individually"), ], default="ALL", + update=_update_view_set, + ) + view_protocol: EnumProperty( + name="Protocol", + description="Use the camera rig of a published dataset or method instead of the " + "View Set: its views, field of view and, where it fixes them, camera " + "distances", + items=PROTOCOL_ITEMS, + default='NONE', + ) + protocol_seed: IntProperty( + name="Seed", + description="Seed for protocols whose views are random, so the same rig can be " + "built again", + default=0, min=0, + ) + views_enabled: BoolVectorProperty( + name="Views", + description="Which of the standard views get a camera when View Set is Custom", + size=len(VIEWS_ALL), + default=(True,) * len(VIEWS_ALL), ) front_axis: EnumProperty( @@ -199,20 +502,81 @@ class MV_Settings(PropertyGroup): lighting_preset: EnumProperty( name="Lighting", - items=[(k, v["label"], v["label"]) for k, v in LIGHTING_PRESETS.items()], - default="STUDIO_SOFT", + description="Light rig to build. Saved presets appear after the built-in ones", + items=lighting_preset_items, + default=0, ) lighting_intensity: FloatProperty( name="Intensity", description="Multiplier on preset light energies", default=1.0, min=0.0, soft_max=5.0, ) + lights_follow_camera: BoolProperty( + name="Lights Follow Camera", + description="Turn the light rig with each camera while rendering, so every view " + "is lit the way Front is. Off, the lights stay put and views from " + "behind or below are lit mostly by the rim light", + default=True, + ) + world_mode: EnumProperty( + name="Background", + items=[('COLOR', "Color", "A flat colour behind the product"), + ('HDRI', "HDRI", "An environment image that lights the product and, " + "optionally, shows behind it")], + default='COLOR', + ) transparent_bg: BoolProperty(name="Transparent Background", default=False) bg_color: FloatVectorProperty( name="Background Color", subtype='COLOR', size=4, min=0.0, max=1.0, default=(0.5, 0.5, 0.5, 1.0), ) + hdri_path: StringProperty( + name="HDRI", subtype='FILE_PATH', + description="Environment image, usually .hdr or .exr", + ) + hdri_strength: FloatProperty(name="Strength", default=1.0, min=0.0, soft_max=10.0) + hdri_rotation: FloatProperty( + name="Rotation", description="Turn the environment about the vertical axis", + default=0.0, subtype='ANGLE', + ) + hdri_visible: BoolProperty( + name="Show HDRI", + description="See the environment behind the product. Off, it still lights and " + "reflects in the product but the camera sees the background colour", + default=False, + ) + + ground_mode: EnumProperty( + name="Ground", + description="Something for the product to stand on in Full renders", + items=[('NONE', "None", "The product floats"), + ('SHADOW', "Shadow Catcher", "An invisible floor that only shows the " + "product's shadow. Cycles only"), + ('FLOOR', "Floor", "A visible floor in the colour below")], + default='NONE', + ) + ground_color: FloatVectorProperty( + name="Floor Color", subtype='COLOR', size=3, + min=0.0, max=1.0, default=(0.8, 0.8, 0.8), + ) + ground_roughness: FloatProperty( + name="Roughness", description="Lower makes the floor reflect the product", + default=0.5, min=0.0, max=1.0, subtype='FACTOR', + ) + + material_preset: EnumProperty( + name="Material", + description="Material every product renders with in Full renders, unless the " + "product picks its own", + items=[('KEEP', "Keep Own", "Use the materials the products already have")] + + MATERIAL_ITEMS, + default='KEEP', + ) + material_color: FloatVectorProperty( + name="Color", subtype='COLOR', size=3, + min=0.0, max=1.0, default=(0.8, 0.8, 0.8), + ) render_output_dir: StringProperty( name="Output Directory", subtype='DIR_PATH', default="//renders/", @@ -230,6 +594,368 @@ class MV_Settings(PropertyGroup): name="Clay Color", subtype='COLOR', size=3, min=0.0, max=1.0, default=(0.75, 0.75, 0.75), ) + pass_lines: BoolProperty( + name="Line Art", + description="Also write a technical-drawing image per view: outlines and creases " + "drawn as lines. Named with mode 'lines'", + default=False, + ) + pass_mask: BoolProperty( + name="Mask", + description="Also write a silhouette per view: white product on black. " + "Named with mode 'mask'", + default=False, + ) + pass_ao: BoolProperty( + name="AO", + description="Also write an ambient occlusion image per view: white product " + "darkened in its creases and contact areas. Needs Cycles. Named " + "with mode 'ao'", + default=False, + ) + lines_angle: FloatProperty( + name="Crease Angle", + description="Edges where faces meet more sharply than this are drawn", + default=math.radians(30.0), min=math.radians(1.0), max=math.radians(179.0), + subtype='ANGLE', + ) + lines_width: IntProperty(name="Line Width", description="In pixels", default=2, min=1, max=12) + lines_color: FloatVectorProperty( + name="Line Color", subtype='COLOR', size=3, + min=0.0, max=1.0, default=(0.0, 0.0, 0.0), + ) + lines_fill: EnumProperty( + name="Fill", + items=[('WHITE', "White", "Black lines on white: a technical drawing"), + ('CLAY', "Clay", "Lines drawn over the clay render"), + ('NONE', "None", "Lines only, on transparency")], + default='WHITE', + ) + pass_depth: BoolProperty( + name="Depth", + description="Write a depth map per view: 32-bit EXR, distance along the view " + "axis in scene units. Needs Cycles. Mode 'depth'", + default=False, + ) + pass_normal: BoolProperty( + name="Normals", + description="Write a camera-space normal map per view: 32-bit EXR, OpenGL axes. " + "Needs Cycles. Mode 'normal'", + default=False, + ) + pass_parts: BoolProperty( + name="Part IDs", + description="Write a 16-bit PNG per view saying which part each pixel shows: 0 is " + "background, the rest number the parts as listed in cameras.json. " + "Needs Cycles. Mode 'parts'", + default=False, + ) + export_cameras: BoolProperty( + name="Cameras", + description="Write each product's camera intrinsics and poses (cameras.json) and " + "a nerfstudio-style transforms.json for its perspective views and " + "turntable", + default=True, + ) + export_coco: BoolProperty( + name="COCO", + description="Write coco.json with one instance mask per visible part per image. " + "Turns on Part IDs", + default=False, + ) + export_bop: BoolProperty( + name="BOP", + description="Write a BOP-format dataset (bop/) from the perspective views: RGB, " + "depth in mm, masks, object poses and models. Turns on Depth", + default=False, + ) + export_drawings: BoolProperty( + name="Drawings", + description="Write vector three-view engineering drawings (Front, Top, Right) as " + "SVG at 1:1 in the product's source units, hidden lines dashed, plus a " + "sheet with all three. Needs Cycles", + default=False, + ) + drawing_crease: FloatProperty( + name="Crease Angle", + description="Edges where faces meet more sharply than this are drawn", + default=math.radians(30.0), min=math.radians(1.0), max=math.radians(179.0), + subtype='ANGLE', + ) + drawing_resolution: IntProperty( + name="Accuracy", + description="Size in pixels of the depth image used to decide which lines are " + "hidden. Higher is more exact near overlaps, and slower", + default=2048, min=256, max=8192, + ) + drawing_projection: EnumProperty( + name="Projection", + items=[('THIRD', "Third Angle", "Plan above the front view, right view on its right " + "(US, ISO E)"), + ('FIRST', "First Angle", "Plan below the front view, right view on its left " + "(Europe, ISO A)")], + default='THIRD', + ) + drawing_hidden: BoolProperty(name="Hidden Lines", default=True, + description="Draw hidden edges dashed") + drawing_dimensions: BoolProperty(name="Dimensions", default=True, + description="Overall width and height of each view") + random_enabled: BoolProperty( + name="Randomize", + description="Vary cameras, lights, environment, materials and clutter per image, " + "for training data. Every draw comes from the seed, the product's " + "label and the view, so a run can be repeated exactly. Turntables are " + "not randomized", + default=False, + ) + random_seed: IntProperty(name="Seed", default=0, min=0) + random_camera_angle: FloatProperty( + name="Camera Jitter", + description="Each view's camera swings up to this far off its canonical direction", + default=math.radians(8.0), min=0.0, max=math.radians(45.0), subtype='ANGLE', + ) + random_camera_distance: FloatProperty( + name="Distance Jitter", + description="Each perspective camera moves this fraction nearer or farther", + default=0.1, min=0.0, max=0.5, subtype='FACTOR', + ) + random_lights: BoolProperty(name="Lights", default=True, + description="Turn the light rig and vary each light's strength") + random_light_yaw: FloatProperty( + name="Light Turn", default=math.radians(60.0), min=0.0, max=math.radians(180.0), + subtype='ANGLE', description="The rig turns up to this far about the vertical", + ) + random_light_energy: FloatProperty( + name="Light Strength", default=0.4, min=0.0, max=0.9, subtype='FACTOR', + description="Each light is up to this fraction stronger or weaker", + ) + random_hdri: BoolProperty( + name="HDRI", default=False, + description="Light and back each image with a random HDRI, turned and scaled at random", + ) + random_hdri_folder: StringProperty( + name="HDRI Folder", subtype='DIR_PATH', + description="Folder of .hdr/.exr environments. Empty uses the studio HDRIs that ship " + "with Blender", + ) + random_hdri_min: FloatProperty(name="Min Strength", default=0.5, min=0.0) + random_hdri_max: FloatProperty(name="Max Strength", default=1.5, min=0.0) + random_material: BoolProperty( + name="Materials", default=False, + description="Give the product a random material preset and colour per image, in " + "Full renders", + ) + random_distractors: IntProperty( + name="Distractors", default=0, min=0, max=24, + description="Random shapes placed behind the product, never in front of it, in Full " + "renders only, so masks and annotations stay exact", + ) + export_croissant: BoolProperty( + name="Croissant", + description="Write croissant.json, MLCommons Croissant metadata describing the " + "output as a dataset (needed for NeurIPS datasets submissions). Writes " + "the CSV manifest too", + default=False, + ) + dataset_name: StringProperty(name="Name", description="Dataset name. Empty uses the " + "output folder's name") + dataset_description: StringProperty(name="Description") + dataset_license: StringProperty( + 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") + 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 " + "each other's lists", + default="", options={'HIDDEN'}, + ) + ao_samples: IntProperty(name="AO Samples", default=32, min=1, max=4096) + ao_distance: FloatProperty( + name="AO Distance", + description="How far occlusion reaches, as a fraction of the product's size", + default=0.5, min=0.001, soft_max=2.0, + ) + preflight_on_render: BoolProperty( + name="Check Before Rendering", + description="Run the pre-flight check when Render All is pressed. Errors stop the " + "render; warnings ask before going ahead", + default=True, + ) + preflight_issues: CollectionProperty(type=MV_Issue) + preflight_time: StringProperty() + skip_existing: BoolProperty( + name="Skip Existing", + description="Leave images that are already in the output folder alone and " + "render only the missing ones. Use it to resume a batch that " + "crashed or was cancelled. Turn it off to overwrite after " + "changing a setting", + default=False, + ) + reframe_on_render: BoolProperty( + name="Reframe Before Rendering", + description="Rebuild the cameras from the current view settings and product " + "positions when a render starts, so rotating a product or changing " + "resolution never leaves a stale framing. Turn off to keep cameras " + "you have adjusted by hand", + default=True, + ) + + file_template: StringProperty( + name="File Name", + description="Name of each image, without extension. Tokens: {label} (AA_01), " + "{prefix} (AA), {number} (01), {product}, {view}, {mode} (render, " + "clay, or nothing for Full Render). A token that comes out empty " + "takes one separator next to it with it", + default="{label}_{product}_{view}_{mode}", + ) + folder_template: StringProperty( + name="Folder", + description="Folder each image goes in, under the output directory. Same tokens " + "as the file name; use / to nest folders, or leave empty to put " + "everything straight in the output directory", + default="{label}_{product}", + ) + + sheet_enabled: BoolProperty( + name="Contact Sheet", + description="After each product, lay all its views out on one labelled image", + default=False, + ) + sheet_columns: IntProperty( + name="Columns", + description="Views per row. 0 picks a roughly square grid", + default=0, min=0, max=16, + ) + sheet_cell_width: IntProperty( + name="Cell Width", + description="Width in pixels each view is scaled to on the sheet", + default=512, min=64, max=4096, + ) + sheet_labels: BoolProperty( + name="Labels", + description="Print the product name across the top and the view name under each image", + default=True, + ) + sheet_bg: FloatVectorProperty( + name="Background", subtype='COLOR', size=4, + min=0.0, max=1.0, default=(1.0, 1.0, 1.0, 1.0), + ) + sheet_text_color: FloatVectorProperty( + name="Text", subtype='COLOR', size=3, + min=0.0, max=1.0, default=(0.1, 0.1, 0.1), + ) + + turntable_enabled: BoolProperty( + name="Turntable", + description="Also render each product spinning a full turn, one frame per step", + default=False, + ) + turntable_frames: IntProperty( + name="Frames", + description="Frames in one full turn", + default=36, min=4, max=720, + ) + turntable_elevation: FloatProperty( + name="Elevation", + description="How far above the product the turntable camera looks down from", + default=math.radians(20.0), min=math.radians(-89.0), max=math.radians(89.0), + subtype='ANGLE', + ) + turntable_style: EnumProperty( + name="Style", + items=[('CLAY', "Clay", "Fast Workbench clay, like the clay stills"), + ('FULL', "Full Render", "Your current engine and materials")], + default='CLAY', + ) + turntable_mp4: BoolProperty( + name="MP4", + description="Encode the frames into an H.264 video", + default=True, + ) + turntable_gif: BoolProperty( + name="GIF", + description="Encode the frames into a looping animated GIF", + default=False, + ) + turntable_fps: IntProperty(name="FPS", default=24, min=1, max=60) + turntable_gif_size: IntProperty( + name="GIF Size", + description="Longest edge of the GIF in pixels. GIFs get large fast", + default=480, min=64, max=2048, + ) + + post_enabled: BoolProperty( + name="Post-Processing", + description="Write a processed copy of every image: trimmed, padded, resized " + "or converted. The original renders are kept as they are", + default=False, + ) + post_folder: StringProperty( + name="Folder", + description="Subfolder of the output directory the processed copies go in. " + "It mirrors the folder layout of the originals", + default="processed", + ) + post_trim: BoolProperty( + name="Trim to Content", + description="Crop away the empty background around the product. Each image " + "then fills its own frame, so views no longer share one scale", + default=False, + ) + post_margin: FloatProperty( + name="Margin", + description="Space left around the product after trimming, as a fraction of its size", + default=0.05, min=0.0, max=0.5, subtype='FACTOR', + ) + post_square: BoolProperty( + name="Pad to Square", + description="Extend the shorter side with background so the image is square", + default=False, + ) + post_flatten: BoolProperty( + name="Flatten Transparency", + description="Put transparent renders on a solid colour. JPEG always does this", + default=False, + ) + post_bg_color: FloatVectorProperty( + name="Flatten Color", subtype='COLOR', size=3, + min=0.0, max=1.0, default=(1.0, 1.0, 1.0), + ) + post_format: EnumProperty( + name="Format", + items=[('PNG', "PNG", "Lossless, keeps transparency"), + ('JPEG', "JPEG", "Small, no transparency"), + ('WEBP', "WebP", "Small, keeps transparency")], + default='PNG', + ) + post_quality: IntProperty( + name="Quality", description="JPEG and WebP quality", + default=90, min=1, max=100, subtype='PERCENTAGE', + ) + post_max_size: IntProperty( + name="Max Size", + description="Shrink so the longest edge is at most this many pixels. 0 keeps the size", + default=0, min=0, max=16384, + ) + post_extra_sizes: StringProperty( + name="Extra Sizes", + description="Also write smaller copies with these longest edges, e.g. '1024, 256'. " + "Each gets the size added to its name: AA_01_Widget_Front_256", + default="", + ) + + manifest_format: EnumProperty( + name="Manifest", + description="List every output with its product, view, size and source file, " + "in the output directory", + items=[('NONE', "None", "Do not write a manifest"), + ('CSV', "CSV", "manifest.csv"), + ('JSON', "JSON", "manifest.json"), + ('BOTH', "CSV + JSON", "manifest.csv and manifest.json")], + default='BOTH', + ) start_prefix: StringProperty( name="Start Prefix", @@ -284,6 +1010,38 @@ class MV_Settings(PropertyGroup): default=math.radians(30.0), min=0.0, max=math.radians(180.0), subtype='ANGLE', ) + scale_mode: EnumProperty( + name="Scale", + items=[('FIT', "Fit Each", "Every product is scaled to Target Size, so all of them " + "fill the frame the same"), + ('RELATIVE', "Keep Proportions", "The biggest product is scaled to Target " + "Size and the rest keep their real size " + "relative to it")], + default='FIT', + ) + rest_on_floor: BoolProperty( + name="Rest on Floor", + description="Recenter puts the bottom of each product at the origin instead of " + "its middle, so it stands on the ground", + default=False, + ) + mesh_units: EnumProperty( + name="Mesh Units", + description="Unit the numbers in STL, OBJ, PLY, glTF and FBX files are in. Only " + "used to report real sizes and to scale products against each " + "other; STEP, IGES and 3MF know their own", + items=UNIT_ITEMS, + default='mm', + ) + orient_method: EnumProperty( + name="Method", + items=[('FLAT', "Lay Flat", "Rest the product on its largest flat face, longest " + "side across the Front view"), + ('PCA', "Principal Axes", "Line the product's longest spread up across the " + "Front view and its thinnest upright. Better for " + "rounded shapes with no flat face")], + default='FLAT', + ) fit_on_import: BoolProperty( name="Fit on Import", description="Straight after importing, scale each product to fit inside a cube of " @@ -295,8 +1053,9 @@ class MV_Settings(PropertyGroup): active_product_index: IntProperty( name="Active Product", - description="Which product the orientation buttons act on", + description="Which product the orientation buttons and Render Active act on", default=0, min=0, + update=_update_active_product, ) isolate_active: BoolProperty( name="Isolate Active Product", @@ -363,10 +1122,14 @@ def stray_render_objects(context): products = set() for pc in get_products(): products.update(pc.all_objects) + # The ground and distractors are the add-on's own, placed on purpose. + products.update(staging_objects()) strays = [] for obj in context.view_layer.objects: - if obj.type not in GEOMETRY_TYPES or obj in products or obj.hide_render: + # Straight after objects are deleted the view layer can still list a + # slot for them, empty until it next resyncs. + if obj is None or obj.type not in GEOMETRY_TYPES or obj in products or obj.hide_render: continue # An object linked into several collections still renders as long as # one of them is on, so this takes all() rather than any(). @@ -435,6 +1198,8 @@ def clear_collection_objects(coll): bpy.data.cameras.remove(data) elif isinstance(data, bpy.types.Light): bpy.data.lights.remove(data) + elif isinstance(data, bpy.types.Mesh): + bpy.data.meshes.remove(data) def collection_meshes(coll): @@ -448,13 +1213,41 @@ def collection_roots(coll): return [o for o in members if o.parent is None or o.parent not in members] -def fit_collection(coll, target_size=None, recenter=True): +IDENTITY_16 = tuple(float(i % 5 == 0) for i in range(16)) + + +def product_to_world(coll): + """Matrix taking the product's source coordinates (as imported, in its + source units) to where it sits in the scene now.""" + m = coll.mv_to_world + return Matrix([m[i * 4:i * 4 + 4] for i in range(4)]) + + +def move_product(coll, xform): + """Apply a world-space transform to a whole product, and fold it into the + product's source-to-world record. + + Every move MultiView makes goes through here -- fitting, recentring, + turning, auto-orienting -- so the record stays exact, and a pixel in any + render can be traced back to a point in the original CAD file. Baking does + not move geometry, so it needs no entry. Moves made by hand in the + viewport are not seen. + """ + for obj in collection_roots(coll): + obj.matrix_world = xform @ obj.matrix_world + total = xform @ product_to_world(coll) + coll.mv_to_world = [v for row in total for v in row] + + +def fit_collection(coll, target_size=None, recenter=True, floor=False): """Uniformly scale a product so its bounding box fits inside a cube of target_size, and optionally move that box onto the world origin. - Pass target_size=None to only recenter. Returns False if the collection - holds no geometry. The whole product is treated as one rigid assembly, so - parts keep their relative positions. + Pass target_size=None to only recenter. With floor, recentering puts the + bottom of the box on the origin rather than its middle, so the product + stands on the ground plane instead of straddling it. Returns False if the + collection holds no geometry. The whole product is treated as one rigid + assembly, so parts keep their relative positions. """ objs = collection_meshes(coll) if not objs: @@ -467,15 +1260,268 @@ def fit_collection(coll, target_size=None, recenter=True): if max_dim > 1e-9: factor = target_size / max_dim - dest = Vector((0, 0, 0)) if recenter else center + if not recenter: + dest = center + elif floor: + dest = Vector((0.0, 0.0, size.z * factor * 0.5)) + else: + dest = Vector((0.0, 0.0, 0.0)) if abs(factor - 1.0) < 1e-9 and (dest - center).length < 1e-9: return True xform = (Matrix.Translation(dest) @ Matrix.Scale(factor, 4) @ Matrix.Translation(-center)) - for obj in collection_roots(coll): - obj.matrix_world = xform @ obj.matrix_world + move_product(coll, xform) + return True + + +def record_source_size(coll, units): + """Remember how big a product really is, before anything rescales it. + + Called at import, and by Standardize for products assembled by hand, + which are measured the first time they are rescaled. Never overwrites a + size already recorded, since by then the product may have been scaled. + """ + if any(coll.mv_source_size): + return + meshes = collection_meshes(coll) + if meshes: + coll.mv_source_size = bbox_of(meshes)[1] + coll.mv_source_units = units + + +def real_extent_mm(coll): + """Largest dimension of the product as imported, in millimetres, or None.""" + size = max(coll.mv_source_size) + if size <= 0: + return None + return size * UNIT_TO_MM.get(coll.mv_source_units, 1.0) + + +def format_real_size(coll): + size = coll.mv_source_size + if not any(size): + return None + unit = coll.mv_source_units or "units" + return " x ".join(f"{v:.4g}" for v in size) + f" {unit}" + + +def scale_targets(settings, products): + """The size each product's largest dimension should be scaled to. + + Fit Each gives every product Target Size, which is what makes a screw and + a housing read the same in the renders. Keep Proportions gives the batch + one scale instead: the biggest product gets Target Size and the rest keep + their real size relative to it, so a screw stays a fraction of a housing. + A product with no recorded size cannot be placed on that scale and falls + back to Target Size. + """ + target = settings.target_size + if settings.scale_mode != 'RELATIVE': + return {pc.name: target for pc in products} + extents = {pc.name: real_extent_mm(pc) for pc in products} + biggest = max((e for e in extents.values() if e), default=None) + return {name: (target * e / biggest if e and biggest else target) + for name, e in extents.items()} + + +def standardize_products(context, products, scale=True, recenter=True): + """Scale and/or recenter products and bake the result. Returns the names + of objects whose transform could not be baked.""" + s = context.scene.mv_settings + for pc in products: + # Hand-built products are in Blender units, which are metres. + record_source_size(pc, "m") + targets = scale_targets(s, products) if scale else {} + unbaked = set() + context.view_layer.update() + for pc in products: + if fit_collection(pc, targets.get(pc.name), recenter=recenter, floor=s.rest_on_floor): + _, skipped = bake_product(pc, apply_scale=scale, origin_to_world=recenter) + unbaked.update(skipped) + context.view_layer.update() + return unbaked + + +# -- orientation ------------------------------------------------------------ + +HOME_KEY = "mv_home_rotation" + + +def remember_home(coll): + """Record each root's rotation as the product's home orientation, unless + one is recorded already. Reset Orientation returns the product to it.""" + roots = collection_roots(coll) + if any(HOME_KEY in o for o in roots): + return + for obj in roots: + obj[HOME_KEY] = list(obj.matrix_world.to_quaternion()) + + +def turn_product(coll, rotation): + """Rotate a product rigidly about its own bounding box centre.""" + objs = collection_meshes(coll) + if not objs: + return False + center, _ = bbox_of(objs) + xform = (Matrix.Translation(center) @ rotation.to_4x4() @ Matrix.Translation(-center)) + move_product(coll, xform) + return True + + +def reset_orientation(coll): + """Turn a product back to the orientation it had when imported. False if + no home was ever recorded for it.""" + roots = [o for o in collection_roots(coll) if HOME_KEY in o] + if not roots: + return False + home = Quaternion(roots[0][HOME_KEY]) + now = roots[0].matrix_world.to_quaternion() + # Every root turned together, so undoing one root's turn undoes them all. + return turn_product(coll, (home @ now.inverted()).to_matrix()) + + +def world_triangles(coll): + """(n, 3, 3) array of the product's triangles in world space.""" + chunks = [] + for obj in collection_meshes(coll): + if obj.type != 'MESH' or not len(obj.data.polygons): + continue + me = obj.data + me.calc_loop_triangles() + co = np.empty(len(me.vertices) * 3, np.float64) + me.vertices.foreach_get("co", co) + idx = np.empty(len(me.loop_triangles) * 3, np.int64) + me.loop_triangles.foreach_get("vertices", idx) + m = np.array(obj.matrix_world) + world = co.reshape(-1, 3) @ m[:3, :3].T + m[:3, 3] + chunks.append(world[idx.reshape(-1, 3)]) + return np.concatenate(chunks) if chunks else None + + +def front_axes(settings): + """World directions of the Front view: across it, and towards its camera.""" + angle = FRONT_AXIS_ANGLES[settings.front_axis] + return (np.array(rotate_dir_z((1.0, 0.0, 0.0), angle)), + np.array(rotate_dir_z((0.0, -1.0, 0.0), angle))) + + +def sample_points(tris, limit=20000): + pts = tris.reshape(-1, 3) + if len(pts) > limit: + pts = pts[np.linspace(0, len(pts) - 1, limit).astype(np.int64)] + return pts + + +def flat_rotation(tris): + """Rotation that lays the product on its largest flat face. + + Triangles are grouped by the way they face, to within a few degrees, and + the group with the most area wins: for a box that is a large side, for a + phone its back. That face is turned to point straight down. + """ + cross = np.cross(tris[:, 1] - tris[:, 0], tris[:, 2] - tris[:, 0]) + area = np.linalg.norm(cross, axis=1) + keep = area > 1e-12 + normals = cross[keep] / area[keep, None] + area = area[keep] + if not len(area): + return Matrix.Identity(3) + keys, inverse = np.unique(np.round(normals * 24).astype(np.int64), axis=0, + return_inverse=True) + inverse = inverse.reshape(-1) + best = np.argmax(np.bincount(inverse, weights=area)) + n = (normals[inverse == best] * area[inverse == best, None]).sum(axis=0) + n /= max(np.linalg.norm(n), 1e-12) + return Vector(n).rotation_difference(Vector((0.0, 0.0, -1.0))).to_matrix() + + +def convex_hull_2d(points): + """Andrew's monotone chain, counter-clockwise, no repeated end point.""" + pts = [tuple(p) for p in np.unique(np.round(points, 9), axis=0)] + if len(pts) < 3: + return np.array(pts) + + def half(seq): + chain = [] + for p in seq: + while len(chain) >= 2 and ( + (chain[-1][0] - chain[-2][0]) * (p[1] - chain[-2][1]) + - (chain[-1][1] - chain[-2][1]) * (p[0] - chain[-2][0])) <= 0: + chain.pop() + chain.append(p) + return chain + + lower, upper = half(pts), half(reversed(pts)) + return np.array(lower[:-1] + upper[:-1]) + + +def footprint_rotation(points, across): + """Turn about the vertical so the product's footprint lines up with the + axes, longest side across the Front view. + + The smallest rectangle around a shape always has a side along one of its + convex hull's edges, so only those angles need trying, and the answer is + exact rather than the nearest step of a sweep. + """ + xy = points[:, :2] - points[:, :2].mean(axis=0) + hull = convex_hull_2d(xy) + if len(hull) < 3: + hull = xy + edges = np.roll(hull, -1, axis=0) - hull + candidates = np.unique(np.round(np.mod(-np.arctan2(edges[:, 1], edges[:, 0]), + math.pi / 2), 9)) + best_angle, best_area = 0.0, float("inf") + for angle in candidates: + c, s = math.cos(angle), math.sin(angle) + x = hull[:, 0] * c - hull[:, 1] * s + y = hull[:, 0] * s + hull[:, 1] * c + area = np.ptp(x) * np.ptp(y) + if area < best_area - 1e-12: + best_angle, best_area = float(angle), area + turn = Matrix.Rotation(best_angle, 3, 'Z') + turned = np.asarray(xy) @ np.array(turn)[:2, :2].T + # Extent along the direction across the Front view, against the other way. + along = np.ptp(turned @ across[:2]) + other = np.ptp(turned @ np.array((-across[1], across[0]))) + if along < other: + turn = Matrix.Rotation(best_angle + math.pi / 2, 3, 'Z') + return turn + + +def principal_rotation(points, across, towards): + """Rotation that puts the product's longest spread across the Front view, + its next longest pointing at the Front camera, and its thinnest upright.""" + centered = points - points.mean(axis=0) + _, vectors = np.linalg.eigh(np.cov(centered.T)) + small, middle, large = vectors[:, 0], vectors[:, 1], vectors[:, 2] + source = np.column_stack([large, middle, small]) + target = np.column_stack([across, towards, (0.0, 0.0, 1.0)]) + rot = target @ source.T + if np.linalg.det(rot) < 0: + source[:, 1] *= -1 + rot = target @ source.T + return Matrix(rot.tolist()) + + +def auto_orient(context, coll, method): + """Turn a product to a sensible resting orientation. False if it has no + mesh to measure.""" + tris = world_triangles(coll) + if tris is None or not len(tris): + return False + remember_home(coll) + across, towards = front_axes(context.scene.mv_settings) + points = sample_points(tris) + if method == 'PCA': + rot = principal_rotation(points, across, towards) + else: + lay = flat_rotation(tris) + laid = points @ np.array(lay).T + rot = footprint_rotation(laid, across) @ lay + turn_product(coll, rot) + context.view_layer.update() return True @@ -699,8 +1745,7 @@ def rotate_product(context, coll, direction, step): xform = (Matrix.Translation(center) @ Matrix.Rotation(angle, 4, axis) @ Matrix.Translation(-center)) - for obj in collection_roots(coll): - obj.matrix_world = xform @ obj.matrix_world + move_product(coll, xform) return True @@ -724,6 +1769,13 @@ def alpha_pair_to_idx(prefix): return max(0, ord(prefix[0]) - ord('A')) +def label_index(position, coll): + """Where a product's label is counted from: its own recorded index when + the command line has set one, otherwise its place in MV_Products.""" + fixed = getattr(coll, "mv_label_index", -1) + return fixed if fixed >= 0 else position + + def product_label(start_prefix, start_number, offset, per_letter): base = alpha_pair_to_idx(start_prefix) linear = (max(1, start_number) - 1) + offset @@ -764,36 +1816,45 @@ def safe_name(name, fallback="unnamed"): return cleaned or fallback -STL_EXTENSIONS = {".stl"} - -# Blender 4.2 replaced the Python STL importer with a C++ one under a new name -# and retired the old operator soon after, so both are tried in turn. Forward -# Y / up Z is the identity mapping and is what CAD writes; it is passed -# explicitly rather than trusting an importer default to stay put. -STL_OPERATORS = ( - ("wm.stl_import", {"forward_axis": 'Y', "up_axis": 'Z'}), - ("import_mesh.stl", {"axis_forward": 'Y', "axis_up": 'Z'}), -) - - -def is_stl(path): - return os.path.splitext(path)[1].lower() in STL_EXTENSIONS +STEP_EXTENSIONS = {".step", ".stp"} +IGES_EXTENSIONS = {".iges", ".igs"} +CAD_EXTENSIONS = STEP_EXTENSIONS | IGES_EXTENSIONS + +# Each format lists its importers newest first. Blender has renamed several +# of them (4.2 replaced the Python STL importer with a C++ one under a new +# name), and calling an operator Blender does not have raises rather than +# returning, so a missing name just falls through to the next. +# +# Axes are spelled out only for STL and PLY, which have no up axis of their +# own and come out of CAD Z-up: forward Y / up Z is the identity mapping, and +# it is passed explicitly rather than trusting an importer default to stay +# put. OBJ, glTF and FBX carry or imply their own up axis, and the importers' +# defaults already convert it. +MESH_IMPORTERS = { + ".stl": (("wm.stl_import", {"forward_axis": 'Y', "up_axis": 'Z'}), + ("import_mesh.stl", {"axis_forward": 'Y', "axis_up": 'Z'})), + ".ply": (("wm.ply_import", {"forward_axis": 'Y', "up_axis": 'Z'}), + ("import_mesh.ply", {})), + ".obj": (("wm.obj_import", {}), ("import_scene.obj", {})), + ".gltf": (("import_scene.gltf", {}),), + ".glb": (("import_scene.gltf", {}),), + ".fbx": (("import_scene.fbx", {}),), +} +MESH_EXTENSIONS = set(MESH_IMPORTERS) | {".3mf"} +SUPPORTED_EXTENSIONS = CAD_EXTENSIONS | MESH_EXTENSIONS +FILE_FILTER = ";".join(f"*{e};*{e.upper()}" for e in sorted(SUPPORTED_EXTENSIONS)) +# Imported alongside the geometry by glTF and FBX, but a camera or lamp inside +# a product would light or frame every render of it. +NON_PRODUCT_TYPES = {'CAMERA', 'LIGHT', 'LIGHT_PROBE', 'SPEAKER'} -def import_stl_file(path): - """Import an STL straight into the scene. True only on a real success. +def file_ext(path): + return os.path.splitext(path)[1].lower() - An STL already holds a mesh, so it needs neither STEPper nor the FreeCAD - round trip a STEP file goes through. Everything after this point -- the - product collection, fitting, standardizing, rendering -- treats what lands - here exactly like a converted STEP file. - Calling an operator Blender does not have raises rather than returning, so - a missing name falls through to the next candidate; a genuine read failure - exhausts the list and reports False, which keeps one bad file from taking - the rest of the batch down with it. - """ - for op_path, axes in STL_OPERATORS: +def run_importer(candidates, path): + """True only on a real success from the first importer Blender has.""" + for op_path, axes in candidates: module, func = op_path.split(".", 1) mod = getattr(bpy.ops, module, None) op = getattr(mod, func, None) if mod else None @@ -809,6 +1870,170 @@ def import_stl_file(path): return False +def import_mesh_file(path): + """Import a mesh file straight into the scene: (success, units or None). + + A mesh needs neither STEPper nor the FreeCAD round trip a STEP file goes + through. Everything after this point -- the product collection, fitting, + standardizing, rendering -- treats what lands here exactly like a + converted STEP file. Only 3MF says what unit it is in. + """ + ext = file_ext(path) + if ext == ".3mf": + return import_3mf(path) + return run_importer(MESH_IMPORTERS.get(ext, ()), path), None + + +def srgb_to_linear(value): + return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + + +def color_material(rgb): + """One shared material per colour a CAD or 3MF file asked for. + + The colours arrive as display values, so they are linearized for Base + Color, and also set as the viewport colour so Solid mode shows them. + """ + rgb = tuple(max(0.0, min(1.0, float(c))) for c in rgb[:3]) + name = "MV_Color_%02X%02X%02X" % tuple(int(round(c * 255)) for c in rgb) + mat = bpy.data.materials.get(name) + if mat is not None: + return mat + mat = bpy.data.materials.new(name) + linear = tuple(srgb_to_linear(c) for c in rgb) + (1.0,) + mat.diffuse_color = linear + tree = node_tree_of(mat) + bsdf = next((n for n in tree.nodes if n.bl_idname == "ShaderNodeBsdfPrincipled"), None) + if bsdf is None: + tree.nodes.clear() + bsdf = tree.nodes.new("ShaderNodeBsdfPrincipled") + out = tree.nodes.new("ShaderNodeOutputMaterial") + out.location = (300, 0) + tree.links.new(bsdf.outputs[0], out.inputs["Surface"]) + bsdf.inputs["Base Color"].default_value = linear + return mat + + +# -- 3MF ---------------------------------------------------------------------- + +NS_3MF = "{http://schemas.microsoft.com/3dmanufacturing/core/2015/02}" +UNITS_3MF = {"micron": "um", "millimeter": "mm", "centimeter": "cm", + "inch": "in", "foot": "ft", "meter": "m"} + + +def matrix_3mf(text): + """A 3MF transform as a Blender matrix. + + 3MF writes twelve numbers for a row-vector matrix (point times matrix, + translation in the last row); Blender's are column vectors, so it is the + transpose. + """ + if not text: + return Matrix.Identity(4) + m = [float(v) for v in text.split()] + return Matrix(((m[0], m[3], m[6], m[9]), + (m[1], m[4], m[7], m[10]), + (m[2], m[5], m[8], m[11]), + (0.0, 0.0, 0.0, 1.0))) + + +def color_3mf(text): + text = (text or "").lstrip("#") + if len(text) < 6: + return None + try: + return tuple(int(text[i:i + 2], 16) / 255.0 for i in (0, 2, 4)) + except ValueError: + return None + + +def import_3mf(path): + """A small 3MF reader: meshes, components, build transforms and base + material colours. Blender has no 3MF importer of its own. + + Each mesh object the build places becomes a Blender object, positioned by + the build item's and any component's transforms. Vertex values are kept in + the file's unit, the same as an STL, so its size is recorded as is. + """ + import zipfile + import xml.etree.ElementTree as ET + + try: + with zipfile.ZipFile(path) as z: + model = "3D/3dmodel.model" + try: + rels = ET.fromstring(z.read("_rels/.rels")) + for rel in rels: + if rel.get("Type", "").endswith("/3dmodel"): + model = rel.get("Target", model).lstrip("/") + except KeyError: + pass + root = ET.fromstring(z.read(model)) + except (OSError, KeyError, zipfile.BadZipFile, ET.ParseError): + return False, None + + units = UNITS_3MF.get(root.get("unit", "millimeter"), "mm") + resources = root.find(NS_3MF + "resources") + build = root.find(NS_3MF + "build") + if resources is None or build is None: + return False, None + + colours = {} + for group in resources.findall(NS_3MF + "basematerials"): + for i, base in enumerate(group.findall(NS_3MF + "base")): + colours[(group.get("id"), str(i))] = color_3mf(base.get("displaycolor")) + objects = {o.get("id"): o for o in resources.findall(NS_3MF + "object")} + stem = os.path.splitext(os.path.basename(path))[0] + made = [] + + def make_mesh(node, matrix, name): + mesh = node.find(NS_3MF + "mesh") + verts = [(float(v.get("x")), float(v.get("y")), float(v.get("z"))) + for v in mesh.find(NS_3MF + "vertices")] + tris, keys = [], [] + default = (node.get("pid"), node.get("pindex")) + for t in mesh.find(NS_3MF + "triangles"): + tris.append((int(t.get("v1")), int(t.get("v2")), int(t.get("v3")))) + pid = t.get("pid", default[0]) + keys.append((pid, t.get("p1", default[1] if pid == default[0] else "0"))) + me = bpy.data.meshes.new(name) + me.from_pydata(verts, [], tris) + slots = [] + for key in keys: + if key not in slots: + slots.append(key) + if any(colours.get(k) for k in slots): + for key in slots: + me.materials.append(color_material(colours.get(key) or (0.8, 0.8, 0.8))) + me.polygons.foreach_set("material_index", [slots.index(k) for k in keys]) + me.update() + obj = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(obj) + obj.matrix_world = matrix + made.append(obj) + + def place(obj_id, matrix, depth=0): + node = objects.get(obj_id) + if node is None or depth > 32: + return + name = node.get("name") or stem + if node.find(NS_3MF + "mesh") is not None: + make_mesh(node, matrix, name) + comps = node.find(NS_3MF + "components") + if comps is not None: + for comp in comps.findall(NS_3MF + "component"): + place(comp.get("objectid"), matrix @ matrix_3mf(comp.get("transform")), depth + 1) + + try: + for item in build.findall(NS_3MF + "item"): + place(item.get("objectid"), matrix_3mf(item.get("transform"))) + except (TypeError, ValueError, AttributeError): + for obj in made: + bpy.data.objects.remove(obj, do_unlink=True) + return False, None + return bool(made), units + + def has_geometry(objs): """True when at least one of these objects carries geometry worth keeping. @@ -869,47 +2094,292 @@ def try_stepper_import(filepath): return False -def freecad_prepare(step_path, freecad_exe, deflection): - """Write the conversion script for one STEP file. None if FreeCAD is not - configured. Nothing is launched yet.""" - if not freecad_exe or not os.path.isfile(freecad_exe): +STEP_ENTITY = re.compile(r"#(\d+)\s*=\s*([A-Z0-9_]+)\s*\((.*?)\)\s*;", re.S) +STEP_REF = re.compile(r"#(\d+)") +STEP_SOLIDS = {"MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS", "SHELL_BASED_SURFACE_MODEL", + "FACETED_BREP"} +STEP_SHELLS = {"CLOSED_SHELL", "OPEN_SHELL"} +STEP_FACES = {"ADVANCED_FACE", "FACE_SURFACE"} +STEP_REPS = {"ADVANCED_BREP_SHAPE_REPRESENTATION", "MANIFOLD_SURFACE_SHAPE_REPRESENTATION", + "SHAPE_REPRESENTATION", "FACETED_BREP_SHAPE_REPRESENTATION"} +STEP_STYLE_CHAIN = {"PRESENTATION_STYLE_ASSIGNMENT", "SURFACE_STYLE_USAGE", "SURFACE_SIDE_STYLE", + "SURFACE_STYLE_FILL_AREA", "FILL_AREA_STYLE", "FILL_AREA_STYLE_COLOUR", + "COLOUR_RGB", "DRAUGHTING_PRE_DEFINED_COLOUR"} +STEP_NEEDED = (STEP_SOLIDS | STEP_SHELLS | STEP_FACES | STEP_REPS | STEP_STYLE_CHAIN + | {"STYLED_ITEM", "OVER_RIDING_STYLED_ITEM", "SHAPE_DEFINITION_REPRESENTATION", + "SHAPE_REPRESENTATION_RELATIONSHIP", "PRODUCT_DEFINITION_SHAPE", + "PRODUCT_DEFINITION", "PRODUCT_DEFINITION_FORMATION", + "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE", "PRODUCT"}) +STEP_NAMED_COLOURS = {"red": (1, 0, 0), "green": (0, 1, 0), "blue": (0, 0, 1), + "yellow": (1, 1, 0), "magenta": (1, 0, 1), "cyan": (0, 1, 1), + "black": (0, 0, 0), "white": (1, 1, 1)} + + +def step_string(args): + """The first quoted string in a STEP argument list, unescaped.""" + m = re.match(r"\s*'((?:[^']|'')*)'", args) + if not m: + return "" + text = m.group(1).replace("''", "'") + text = re.sub(r"\\X2\\((?:[0-9A-F]{4})+)\\X0\\", + lambda g: "".join(chr(int(g.group(1)[i:i + 4], 16)) + for i in range(0, len(g.group(1)), 4)), text) + return re.sub(r"\\X\\([0-9A-F]{2})", lambda g: chr(int(g.group(1), 16)), text) + + +def step_colors(path): + """Colours a STEP file gives its parts and faces, keyed by product name. + + {name: {"color": (r, g, b) or None, "faces": {face index: (r, g, b)}}} + + FreeCAD reads STEP colours only into its GUI, so a console conversion + loses every one of them. They are read here instead, from the text: each + STYLED_ITEM points at a solid or a face, a solid belongs to a shape + representation, and the representation leads through the product + definition to the PRODUCT whose name FreeCAD gives the part. Face indices + count through the solid's shells in file order, which is the order the + faces come out of FreeCAD in. + + Best effort: anything unexpected yields fewer colours, never an error. + """ + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError: + return {} + if "STYLED_ITEM" not in text: + return {} + + ents = {} + for m in STEP_ENTITY.finditer(text): + if m.group(2) in STEP_NEEDED: + ents[int(m.group(1))] = (m.group(2), m.group(3)) + refs = {i: [int(r) for r in STEP_REF.findall(args)] for i, (_, args) in ents.items()} + + def kind(i): + return ents.get(i, ("", ""))[0] + + def colour_of(i, depth=0): + t = kind(i) + if t == "COLOUR_RGB": + nums = re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", ents[i][1].split(",", 1)[-1]) + return tuple(float(v) for v in nums[-3:]) if len(nums) >= 3 else None + if t == "DRAUGHTING_PRE_DEFINED_COLOUR": + return STEP_NAMED_COLOURS.get(step_string(ents[i][1]).lower()) + if t not in STEP_STYLE_CHAIN or depth > 8: + return None + for r in refs[i]: + found = colour_of(r, depth + 1) + if found: + return found return None - tmp_dir = tempfile.mkdtemp(prefix="mv_step_") - obj_out = os.path.join(tmp_dir, os.path.splitext(os.path.basename(step_path))[0] + ".obj") - # Paths go in as %r, not hand-escaped into '...'. A path holding an - # apostrophe -- C:\Users\Dan's Files\part.step -- closed the string early - # and the script died of a SyntaxError that surfaced as a bare "conversion - # failed". repr() handles quotes, backslashes and newlines in one step. - script = ( - "import Import, MeshPart, FreeCAD\n" - "Import.open(%r)\n" % step_path + - "doc = FreeCAD.ActiveDocument\n" - "meshes = []\n" - "for obj in doc.Objects:\n" - " if hasattr(obj, 'Shape') and obj.Shape and not obj.Shape.isNull():\n" - " try:\n" - " meshes.append(MeshPart.meshFromShape(Shape=obj.Shape, " - "LinearDeflection=%r, AngularDeflection=0.5, Relative=False))\n" % float(deflection) + - " except Exception as e:\n" - " print('mesh failed', obj.Name, e)\n" - "if meshes:\n" - " m = meshes[0]\n" - " for extra in meshes[1:]:\n" - " m.addMesh(extra)\n" - " m.write(%r)\n" % obj_out - ) - script_path = os.path.join(tmp_dir, "convert.py") - with open(script_path, "w", encoding="utf-8") as f: - f.write(script) - return {"tmp_dir": tmp_dir, "obj": obj_out, "script": script_path, - "log": os.path.join(tmp_dir, "freecad.log"), "exe": freecad_exe, - "proc": None, "handle": None} + # Solid -> its faces in order, and face -> (solid, index). + solid_faces, face_home = {}, {} + for i in ents: + if kind(i) not in STEP_SOLIDS: + continue + faces = [] + for shell in refs[i]: + if kind(shell) in STEP_SHELLS: + faces.extend(f for f in refs[shell] if kind(f) in STEP_FACES) + solid_faces[i] = faces + for n, face in enumerate(faces): + face_home.setdefault(face, (i, n)) + + # Solid -> product name, through representation and product definition. + rep_of = {} + for i in ents: + if kind(i) in STEP_REPS: + for item in refs[i]: + if kind(item) in STEP_SOLIDS: + rep_of.setdefault(item, i) + rep_links = {} + for i in ents: + if kind(i) == "SHAPE_REPRESENTATION_RELATIONSHIP" and len(refs[i]) >= 2: + a, b = refs[i][-2], refs[i][-1] + rep_links.setdefault(a, []).append(b) + rep_links.setdefault(b, []).append(a) + pds_of = {} + for i in ents: + if kind(i) == "SHAPE_DEFINITION_REPRESENTATION" and len(refs[i]) >= 2: + pds_of[refs[i][1]] = refs[i][0] + + def product_name(rep): + for candidate in [rep] + rep_links.get(rep, []): + pds = pds_of.get(candidate) + if pds is None or kind(pds) != "PRODUCT_DEFINITION_SHAPE": + continue + for pd in refs[pds]: + if kind(pd) != "PRODUCT_DEFINITION": + continue + for pdf in refs[pd]: + if kind(pdf).startswith("PRODUCT_DEFINITION_FORMATION"): + for product in refs[pdf]: + if kind(product) == "PRODUCT": + return step_string(ents[product][1]) + return None -def freecad_start(job): - """Launch freecadcmd without waiting on it. + per_solid, per_face = {}, {} + # Plain styles first, so an over-riding style wins where both apply. + for t in ("STYLED_ITEM", "OVER_RIDING_STYLED_ITEM"): + for i in ents: + if kind(i) != t or not refs[i]: + continue + colour = next((c for c in (colour_of(r) for r in refs[i][:-1]) if c), None) + target = refs[i][-1] + if t == "OVER_RIDING_STYLED_ITEM" and len(refs[i]) >= 2: + target = next((r for r in refs[i] if kind(r) in STEP_SOLIDS | STEP_FACES), target) + if colour is None: + continue + if kind(target) in STEP_SOLIDS: + per_solid[target] = colour + elif kind(target) in STEP_SHELLS: + for solid, faces in solid_faces.items(): + if target in refs[solid]: + per_solid[solid] = colour + elif target in face_home: + per_face[target] = colour + + result = {} + for solid, faces in solid_faces.items(): + name = product_name(rep_of.get(solid)) + if name is None: + continue + entry = result.setdefault(name, {"colors": []}) + base = per_solid.get(solid) + entry["colors"].extend(per_face.get(face, base) for face in faces) + out = {} + for name, entry in result.items(): + colours = entry["colors"] + if not any(colours): + continue + if len(set(colours)) == 1: + out[name] = {"color": colours[0], "faces": {}} + else: + out[name] = {"color": None, + "faces": {str(n): c for n, c in enumerate(colours) if c}} + return out + + +# The script FreeCAD runs. It reads its settings from job.json beside it, so +# no path or name is ever pasted into code: a path holding an apostrophe -- +# C:\Users\Dan's Files\part.step -- once closed a string early and surfaced as +# a bare "conversion failed". +FREECAD_SCRIPT = r''' +import json, os, re, sys, traceback +import FreeCAD, Part, MeshPart, Import + +here = os.path.dirname(os.path.abspath(__file__)) +job = json.load(open(os.path.join(here, "job.json"), encoding="utf-8")) +Import.open(job["source"]) +doc = FreeCAD.ActiveDocument +colors = job.get("colors", {}) + + +def shaped(o): + return (o.isDerivedFrom("Part::Feature") and o.Shape and not o.Shape.isNull() + and len(o.Shape.Faces) > 0) + + +# Leaves only. A STEP assembly arrives as an App::Part container whose shape +# is all of its children combined, plus origin planes with shapes of their +# own; meshing everything with a shape doubled every part. +leaves = [o for o in doc.Objects if shaped(o) and not any(shaped(c) for c in o.OutList)] if doc else [] +if not leaves: + # Console FreeCAD opens IGES into an empty document. Reading the shape + # directly works for it, and for anything else Import.open passes over. + doc = FreeCAD.newDocument("MV") + whole = doc.addObject("Part::Feature", os.path.splitext(os.path.basename(job["source"]))[0]) + whole.Shape = Part.read(job["source"]) + leaves = [whole] if shaped(whole) else [] + + +def world_shape(o): + shape = o.Shape.copy() + try: + shape.Placement = o.getGlobalPlacement() + except Exception: + pass + return shape - stdin is closed rather than inherited: a FreeCAD build that stops to ask a + +def mesh(shape): + return MeshPart.meshFromShape(Shape=shape, LinearDeflection=job["deflection"], + AngularDeflection=0.5, Relative=False) + + +def colour_info(label): + if label in colors: + return colors[label] + return colors.get(re.sub(r"\d{3}$", "", label), {}) + + +try: + parts = [] + for i, o in enumerate(leaves): + shape = world_shape(o) + info = colour_info(o.Label) + base = tuple(info["color"]) if info.get("color") else None + face_colors = info.get("faces") or {} + groups = {} + if face_colors: + for n, face in enumerate(shape.Faces): + key = tuple(face_colors.get(str(n)) or base or ()) or None + groups.setdefault(key, []).append(face) + else: + groups[base] = None + files = [] + for g, (colour, faces) in enumerate(groups.items()): + piece = shape if faces is None else Part.makeCompound(faces) + name = "part_%04d_%d.obj" % (i, g) + m = mesh(piece) + if m.CountFacets: + m.write(os.path.join(here, name)) + files.append({"file": name, "color": list(colour) if colour else None}) + if files: + parts.append({"name": o.Label, "files": files}) + json.dump({"parts": parts}, open(os.path.join(here, "parts.json"), "w", encoding="utf-8")) +except Exception: + # Whatever went wrong, still hand back the geometry in one piece. + traceback.print_exc() + merged = None + for o in leaves: + try: + m = mesh(world_shape(o)) + except Exception as e: + print("mesh failed", o.Name, e) + continue + if merged is None: + merged = m + else: + merged.addMesh(m) + if merged is not None: + merged.write(os.path.join(here, "model.obj")) +''' + + +def freecad_prepare(source, freecad_exe, deflection, colors=None): + """Write the conversion job for one STEP or IGES file. None if FreeCAD is + not configured. Nothing is launched yet.""" + if not freecad_exe or not os.path.isfile(freecad_exe): + return None + tmp_dir = tempfile.mkdtemp(prefix="mv_step_") + with open(os.path.join(tmp_dir, "job.json"), "w", encoding="utf-8") as f: + json.dump({"source": source, "deflection": float(deflection), + "colors": colors or {}}, f) + script_path = os.path.join(tmp_dir, "convert.py") + with open(script_path, "w", encoding="utf-8") as f: + f.write(FREECAD_SCRIPT) + return {"tmp_dir": tmp_dir, "script": script_path, + "log": os.path.join(tmp_dir, "freecad.log"), "exe": freecad_exe, + "proc": None, "handle": None} + + +def freecad_start(job): + """Launch freecadcmd without waiting on it. + + stdin is closed rather than inherited: a FreeCAD build that stops to ask a question used to sit there holding Blender hostage for the whole timeout. Output goes to a file, not a pipe, so a chatty conversion cannot deadlock against a pipe buffer nobody is draining while we poll. @@ -932,7 +2402,13 @@ def freecad_running(job): def freecad_collect(job): - """Reap a finished job: (obj path or None, whatever FreeCAD printed).""" + """Reap a finished job: (result or None, whatever FreeCAD printed). + + The result is {"parts": [{"name", "files": [{"path", "color"}]}]}: one + entry per part, each split by colour. A conversion that could not split + the parts falls back to a single merged OBJ, which comes back as one + nameless part. + """ if job.get("handle") is not None: job["handle"].close() job["handle"] = None @@ -942,9 +2418,20 @@ def freecad_collect(job): log = f.read() except OSError: pass - obj = job["obj"] - if os.path.isfile(obj) and os.path.getsize(obj) > 0: - return obj, log + here = job["tmp_dir"] + try: + with open(os.path.join(here, "parts.json"), encoding="utf-8") as f: + parts = json.load(f)["parts"] + for part in parts: + for entry in part["files"]: + entry["path"] = os.path.join(here, entry["file"]) + if parts: + return {"parts": parts}, log + except (OSError, ValueError, KeyError, TypeError): + pass + merged = os.path.join(here, "model.obj") + if file_ok(merged): + return {"parts": [{"name": None, "files": [{"path": merged, "color": None}]}]}, log return None, log @@ -1013,6 +2500,39 @@ def import_obj_file(path): return False +def import_freecad_result(result): + """Bring a converted STEP or IGES into the scene, one object per part. + + A part with faces in several colours arrives as one OBJ per colour; those + are joined back into a single object with a material slot per colour, and + named after the part, so the outliner reads like the CAD tree. + """ + for part in result["parts"]: + pieces = [] + for entry in part["files"]: + before = set(bpy.data.objects) + if not import_obj_file(entry["path"]): + continue + new = [o for o in bpy.data.objects if o not in before and o.type == 'MESH'] + if entry.get("color"): + mat = color_material(entry["color"]) + for obj in new: + obj.data.materials.clear() + obj.data.materials.append(mat) + pieces.extend(new) + if not pieces: + continue + if len(pieces) > 1: + with bpy.context.temp_override(active_object=pieces[0], object=pieces[0], + selected_objects=pieces, + selected_editable_objects=pieces): + bpy.ops.object.join() + obj = pieces[0] + if part.get("name"): + obj.name = part["name"] + obj.data.name = part["name"] + + PERSP_LENS = 50.0 SENSOR_WIDTH = 36.0 @@ -1080,6 +2600,28 @@ def persp_distance(local, tan_x, tan_y, pad): return distance +def planned_views(settings): + """(name, kind, direction) for every view a camera should be built for. + + A protocol replaces the View Set and custom views entirely, so its rig is + exactly the published one. Otherwise a preset is read from VIEW_SETS + rather than from the checkboxes, so a file saved before the checkboxes + existed still gets exactly the views it chose. + """ + protocol = protocol_views(settings) + if protocol is not None: + return [(name, "persp", d) for name, d, _ in protocol] + if settings.view_set == 'CUSTOM': + views = [v for v, on in zip(VIEWS_ALL, settings.views_enabled) if on] + else: + views = list(VIEW_SETS[settings.view_set]) + for cv in settings.custom_views: + if cv.enabled: + views.append((cv.name, "ortho" if cv.is_ortho else "persp", + (cv.dir_x, cv.dir_y, cv.dir_z))) + return views + + def build_cameras(context): settings = context.scene.mv_settings coll = ensure_collection(CAMERA_COLL) @@ -1100,13 +2642,13 @@ def build_cameras(context): diag = max(max((p - center).length for p in corners), 1e-3) pad = max(settings.frame_padding_factor, 1.0) rx, ry = image_aspect(context.scene) - tan_x, tan_y = sensor_tangents(rx, ry) + lens = protocol_lens(settings) + tan_x, tan_y = sensor_tangents(rx, ry, lens) angle = FRONT_AXIS_ANGLES[settings.front_axis] - views = list(VIEW_SETS[settings.view_set]) - for cv in settings.custom_views: - views.append((cv.name, "ortho" if cv.is_ortho else "persp", - (cv.dir_x, cv.dir_y, cv.dir_z))) + views = planned_views(settings) + # A protocol may pin each camera's distance, in object sizes. + radii = {name: r for name, _, r in (protocol_views(settings) or []) if r} # Pass 1: measure every view and take the worst case, so one ortho framing # and one perspective framing cover the whole set. Sizing each camera to its @@ -1146,47 +2688,224 @@ def build_cameras(context): distance = ortho_dist else: cam_data.type = 'PERSP' - cam_data.lens = PERSP_LENS - distance = persp_dist + cam_data.lens = lens + distance = (radii[name] * settings.target_size) if name in radii else persp_dist cam_data.clip_start = max(distance * 0.01, 1e-5) - cam_data.clip_end = (distance + diag) * 4.0 + # Far enough for the ground, which runs fifty product sizes out; any + # shorter and a perspective view sees the floor stop before the horizon. + cam_data.clip_end = (distance + diag) * 100.0 cam_obj = bpy.data.objects.new(f"MV_Cam_{name}", cam_data) cam_obj.matrix_world = look_at(center + d * distance, center) coll.objects.link(cam_obj) + # What this framing was measured from, for the pre-flight's stale check. + coll["mv_framing"] = framing_signature(context) return skipped +def batch_extent(): + """Centre and largest dimension of the whole batch, which is what the light + rig and the ground are sized from.""" + objs = [] + for pc in get_products(): + objs.extend(collection_meshes(pc)) + if not objs: + objs = [o for o in bpy.data.objects + if o.type == 'MESH' and o.name != GROUND_NAME] + center, size = bbox_of(objs) + return center, max(size.x, size.y, size.z, 1e-3) + + +def front_basis(settings, center): + """Rotation of the Front camera, which every preset is laid out against.""" + d = Vector(rotate_dir_z((0.0, -1.0, 0.0), FRONT_AXIS_ANGLES[settings.front_axis])) + return look_at(center + d, center).to_3x3() + + def build_lighting(context, preset_key): + """Rebuild MV_Lighting from a preset. False if the preset cannot be read. + + Every light hangs off one empty at the batch centre, MV_LightRig. Turning + that empty turns the whole rig about the product, which is how the lights + follow the camera without anything being rebuilt per view. + """ settings = context.scene.mv_settings + specs = lighting_specs(preset_key) + if specs is None: + return False coll = ensure_collection(LIGHT_COLL) clear_collection_objects(coll) - objs = [] - for pc in get_products(): - objs.extend(list(pc.all_objects)) - if not objs: - objs = [o for o in bpy.data.objects if o.type == 'MESH'] - center, size = bbox_of(objs) - max_dim = max(size.x, size.y, size.z, 1e-3) + center, max_dim = batch_extent() angle = FRONT_AXIS_ANGLES[settings.front_axis] + turn = Matrix.Rotation(math.radians(angle), 4, 'Z') intensity = settings.lighting_intensity - for spec in LIGHTING_PRESETS[preset_key]["lights"]: - ld = bpy.data.lights.new(f"MV_Light_{spec['name']}", type='AREA') - ld.size = spec["size"] * max_dim * 0.5 - ld.energy = spec["energy"] * max_dim * intensity - ld.color = spec["color"] - obj = bpy.data.objects.new(f"MV_Light_{spec['name']}", ld) - pos = Vector(rotate_dir_z(spec["pos"], angle)) * max_dim - obj.matrix_world = look_at(center + pos, center) + rig = bpy.data.objects.new(LIGHT_RIG, None) + rig.empty_display_type = 'SPHERE' + rig.empty_display_size = max_dim * 0.15 + rig.matrix_world = Matrix.Translation(center) + coll.objects.link(rig) + + for spec in specs: + kind = spec.get("type", 'AREA') + name = f"MV_Light_{spec.get('name', 'Light')}" + ld = bpy.data.lights.new(name, type=kind) + size = spec.get("size", 1.0) * max_dim * 0.5 + if kind == 'AREA': + ld.size = size + if "shape" in spec: + ld.shape = spec["shape"] + ld.size_y = spec.get("size_y", spec.get("size", 1.0)) * max_dim * 0.5 + elif kind in {'POINT', 'SPOT'}: + ld.shadow_soft_size = size + if kind == 'SPOT': + ld.spot_size = spec.get("spot_size", ld.spot_size) + ld.spot_blend = spec.get("spot_blend", ld.spot_blend) + if kind == 'SUN': + ld.angle = spec.get("angle", ld.angle) + # Sun strength is irradiance, which does not grow with the product. + ld.energy = spec["energy"] * intensity + else: + ld.energy = spec["energy"] * max_dim * intensity + ld.color = spec.get("color", (1.0, 1.0, 1.0)) + + obj = bpy.data.objects.new(name, ld) coll.objects.link(obj) + obj.parent = rig + pos = center + Vector(rotate_dir_z(spec["pos"], angle)) * max_dim + if "rot" in spec: + rot = turn @ Quaternion(spec["rot"]).to_matrix().to_4x4() + obj.matrix_world = Matrix.Translation(pos) @ rot + else: + obj.matrix_world = look_at(pos, center) + return True + + +def orient_light_rig(context, camera): + """Turn the rig so it stands to camera the way it stands to Front.""" + rig = bpy.data.objects.get(LIGHT_RIG) + if rig is None or camera is None: + return + center = rig.matrix_world.translation.copy() + turn = camera.matrix_world.to_3x3().normalized() @ front_basis( + context.scene.mv_settings, center).transposed() + rig.matrix_world = Matrix.Translation(center) @ turn.to_4x4() + + +def reset_light_rig(): + rig = bpy.data.objects.get(LIGHT_RIG) + if rig is not None: + rig.matrix_world = Matrix.Translation(rig.matrix_world.translation.copy()) + + +def capture_lighting(context): + """MV_Lighting's lights as preset specs, in the units presets are written in: + positions and sizes as multiples of the batch's size, laid out against the + Front view with Front Axis taken back out, energy before Intensity. + + Read back through build_lighting, a rig saved here comes out the same on a + product of any size and any front axis. + """ + settings = context.scene.mv_settings + coll = bpy.data.collections.get(LIGHT_COLL) + lights = [o for o in coll.objects if o.type == 'LIGHT'] if coll else [] + if not lights: + return [] + center, max_dim = batch_extent() + rig = bpy.data.objects.get(LIGHT_RIG) + # Measured against the rig rather than the world, so saving straight after + # a render, with the rig still turned for the last view, still works. + frame = rig.matrix_world.copy() if rig else Matrix.Translation(center) + untilt = Matrix.Rotation(-math.radians(FRONT_AXIS_ANGLES[settings.front_axis]), 4, 'Z') + intensity = settings.lighting_intensity or 1.0 + + specs = [] + for obj in lights: + local = untilt @ frame.inverted() @ obj.matrix_world + ld = obj.data + spec = { + "name": obj.name.replace("MV_Light_", "") or "Light", + "type": ld.type, + "pos": [round(v / max_dim, 5) for v in local.translation], + "rot": [round(v, 6) for v in local.to_quaternion().normalized()], + "color": [round(v, 5) for v in ld.color], + "energy": round(ld.energy / (intensity * (1.0 if ld.type == 'SUN' else max_dim)), 5), + } + if ld.type == 'AREA': + spec["size"] = round(ld.size / (max_dim * 0.5), 5) + spec["shape"] = ld.shape + spec["size_y"] = round(ld.size_y / (max_dim * 0.5), 5) + elif ld.type in {'POINT', 'SPOT'}: + spec["size"] = round(ld.shadow_soft_size / (max_dim * 0.5), 5) + if ld.type == 'SPOT': + spec["spot_size"] = round(ld.spot_size, 5) + spec["spot_blend"] = round(ld.spot_blend, 5) + if ld.type == 'SUN': + spec["angle"] = round(ld.angle, 5) + specs.append(spec) + return specs + + +def node_tree_of(idblock): + """The node tree of a material or world, switching nodes on only where + that is still a setting. Blender 5 always uses nodes and warns about the + flag, which it plans to remove.""" + if idblock.node_tree is None or (bpy.app.version < (5, 0, 0) and not idblock.use_nodes): + idblock.use_nodes = True + return idblock.node_tree + + +def staging_objects(): + """The add-on's own scenery: the ground and any randomization distractors.""" + found = [] + for name in (STAGE_COLL, DISTRACTOR_COLL): + coll = bpy.data.collections.get(name) + if coll is not None: + found.extend(coll.all_objects) + return found + + +def build_ground(context): + """Rebuild MV_Stage's ground to match the settings, or clear it for None.""" + s = context.scene.mv_settings + stage = bpy.data.collections.get(STAGE_COLL) + if s.ground_mode == 'NONE': + if stage: + clear_collection_objects(stage) + return None + stage = ensure_collection(STAGE_COLL) + clear_collection_objects(stage) + + center, max_dim = batch_extent() + half = max_dim * 50.0 + mesh = bpy.data.meshes.new(GROUND_NAME) + mesh.from_pydata([(-half, -half, 0), (half, -half, 0), (half, half, 0), (-half, half, 0)], + [], [(0, 1, 2, 3)]) + mat = bpy.data.materials.get(GROUND_NAME) or bpy.data.materials.new(GROUND_NAME) + tree = node_tree_of(mat) + tree.nodes.clear() + bsdf = tree.nodes.new("ShaderNodeBsdfPrincipled") + out = tree.nodes.new("ShaderNodeOutputMaterial") + out.location = (300, 0) + tree.links.new(bsdf.outputs[0], out.inputs["Surface"]) + bsdf.inputs["Base Color"].default_value = (*s.ground_color, 1.0) + bsdf.inputs["Roughness"].default_value = s.ground_roughness + mesh.materials.append(mat) + + ground = bpy.data.objects.new(GROUND_NAME, mesh) + ground.location = (center.x, center.y, center.z) + ground.is_shadow_catcher = s.ground_mode == 'SHADOW' + stage.objects.link(ground) + return ground def apply_world(context): """Point the scene at the add-on's own world and rewrite that one. + Returns an error message, or None. + This used to reuse whatever world the scene already had and clear its node tree, so pressing Apply Background on a file with a hand-built HDRI setup destroyed it with no warning and no undo. The add-on now owns a single @@ -1195,26 +2914,153 @@ def apply_world(context): """ settings = context.scene.mv_settings scene = context.scene + image = None + if settings.world_mode == 'HDRI': + path = bpy.path.abspath(settings.hdri_path) + if not os.path.isfile(path): + return f"HDRI file not found: '{settings.hdri_path}'" + try: + image = bpy.data.images.load(path, check_existing=True) + except RuntimeError as exc: + return f"Cannot read HDRI '{path}': {exc}" + world = bpy.data.worlds.get(WORLD_NAME) or bpy.data.worlds.new(WORLD_NAME) scene.world = world - world.use_nodes = True - nodes = world.node_tree.nodes - links = world.node_tree.links + tree = node_tree_of(world) + nodes = tree.nodes + links = tree.links nodes.clear() - bg = nodes.new("ShaderNodeBackground") out = nodes.new("ShaderNodeOutputWorld") - bg.inputs["Color"].default_value = settings.bg_color - bg.inputs["Strength"].default_value = 1.0 - links.new(bg.outputs["Background"], out.inputs["Surface"]) + out.location = (600, 0) + flat = nodes.new("ShaderNodeBackground") + flat.location = (100, -200) + flat.inputs["Color"].default_value = settings.bg_color + flat.inputs["Strength"].default_value = 1.0 scene.render.film_transparent = settings.transparent_bg + if image is None: + links.new(flat.outputs["Background"], out.inputs["Surface"]) + return None + + coords = nodes.new("ShaderNodeTexCoord") + coords.location = (-700, 150) + mapping = nodes.new("ShaderNodeMapping") + mapping.location = (-500, 150) + mapping.inputs["Rotation"].default_value[2] = settings.hdri_rotation + env = nodes.new("ShaderNodeTexEnvironment") + env.location = (-250, 150) + env.image = image + hdri = nodes.new("ShaderNodeBackground") + hdri.location = (100, 100) + hdri.inputs["Strength"].default_value = settings.hdri_strength + links.new(coords.outputs["Generated"], mapping.inputs["Vector"]) + links.new(mapping.outputs["Vector"], env.inputs["Vector"]) + links.new(env.outputs["Color"], hdri.inputs["Color"]) + if settings.hdri_visible: + links.new(hdri.outputs["Background"], out.inputs["Surface"]) + return None + + # The camera sees the flat colour; everything else (lighting, reflections) + # sees the HDRI. + path = nodes.new("ShaderNodeLightPath") + path.location = (100, 400) + mix = nodes.new("ShaderNodeMixShader") + mix.location = (350, 0) + links.new(path.outputs["Is Camera Ray"], mix.inputs["Fac"]) + links.new(hdri.outputs["Background"], mix.inputs[1]) + links.new(flat.outputs["Background"], mix.inputs[2]) + links.new(mix.outputs["Shader"], out.inputs["Surface"]) + return None + + +def product_material_choice(settings, coll): + """(preset, colour) a product renders with, or None to keep its own.""" + preset = getattr(coll, "mv_material", 'DEFAULT') + if preset == 'DEFAULT': + preset, color = settings.material_preset, settings.material_color + else: + color = coll.mv_material_color + if preset not in MATERIAL_PRESETS: + return None + return preset, tuple(color) + + +def preset_material(name, preset, color): + mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) + tree = node_tree_of(mat) + tree.nodes.clear() + bsdf = tree.nodes.new("ShaderNodeBsdfPrincipled") + out = tree.nodes.new("ShaderNodeOutputMaterial") + out.location = (300, 0) + tree.links.new(bsdf.outputs[0], out.inputs["Surface"]) + bsdf.inputs["Base Color"].default_value = (*color, 1.0) + for key, value in MATERIAL_PRESETS[preset]["inputs"].items(): + socket = bsdf.inputs.get(key) + if socket is not None: + socket.default_value = value + return mat + + +def material_node_group(): + """A Geometry Nodes group that sets one material on everything. + + A modifier carrying it swaps the material for rendering without touching + the product's own material slots, so removing the modifier is a complete + undo, and a mesh with no slots at all (every STL) still gets the material. + """ + group = bpy.data.node_groups.get(MATERIAL_GROUP) + if group is not None and group.bl_idname == 'GeometryNodeTree': + return group + group = bpy.data.node_groups.new(MATERIAL_GROUP, 'GeometryNodeTree') + group.interface.new_socket("Geometry", in_out='INPUT', socket_type='NodeSocketGeometry') + group.interface.new_socket("Material", in_out='INPUT', socket_type='NodeSocketMaterial') + group.interface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') + gin = group.nodes.new("NodeGroupInput") + gout = group.nodes.new("NodeGroupOutput") + gout.location = (400, 0) + setter = group.nodes.new("GeometryNodeSetMaterial") + setter.location = (200, 0) + group.links.new(gin.outputs[0], setter.inputs["Geometry"]) + group.links.new(gin.outputs[1], setter.inputs["Material"]) + group.links.new(setter.outputs["Geometry"], gout.inputs[0]) + return group + + +def apply_product_material(settings, coll, force=False): + """Give a product its preset material for rendering. False when it keeps + its own. force puts a modifier on even then, for Randomize to vary.""" + choice = product_material_choice(settings, coll) + if choice is None: + if not force: + return False + choice = ("MATTE_PLASTIC", (0.8, 0.8, 0.8)) + mat = preset_material(f"MV_Material_{coll.name}", *choice) + group = material_node_group() + socket = next(item.identifier for item in group.interface.items_tree + if getattr(item, "in_out", None) == 'INPUT' + and getattr(item, "socket_type", None) == 'NodeSocketMaterial') + for obj in collection_meshes(coll): + if obj.type != 'MESH' or obj.library is not None: + continue + mod = obj.modifiers.new(MATERIAL_MOD, 'NODES') + mod.node_group = group + mod[socket] = mat + return True + + +def remove_product_materials(): + """Take every preset material back off, wherever it was left.""" + for obj in bpy.data.objects: + for mod in [m for m in obj.modifiers + if m.type == 'NODES' and m.name.startswith(MATERIAL_MOD)]: + obj.modifiers.remove(mod) + class MV_OT_AddStepFile(Operator, ImportHelper): + """Add CAD or mesh files to the import list""" bl_idname = "mv.add_step_file" - bl_label = "Add CAD File" - filter_glob: StringProperty( - default="*.step;*.stp;*.STEP;*.STP;*.stl;*.STL", options={'HIDDEN'}, - ) + bl_label = "Add Files" + filter_glob: StringProperty(default=FILE_FILTER, options={'HIDDEN'}) files: CollectionProperty(type=bpy.types.OperatorFileListElement) directory: StringProperty(subtype='DIR_PATH') @@ -1253,12 +3099,64 @@ def execute(self, context): return {'FINISHED'} +class MV_OT_AddFolder(Operator): + """Add every CAD and mesh file in a folder to the import list""" + bl_idname = "mv.add_folder" + bl_label = "Add Folder" + + directory: StringProperty(subtype='DIR_PATH') + filter_folder: BoolProperty(default=True, options={'HIDDEN'}) + recursive: BoolProperty( + name="Include Subfolders", + description="Also add files from every folder inside this one", + default=True, + ) + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + def execute(self, context): + s = context.scene.mv_settings + folder = bpy.path.abspath(self.directory) + if not os.path.isdir(folder): + self.report({'ERROR'}, f"Not a folder: '{folder}'") + return {'CANCELLED'} + found = [] + for current, dirs, files in os.walk(folder): + dirs.sort() + found.extend(os.path.join(current, f) for f in sorted(files) + if file_ext(f) in SUPPORTED_EXTENSIONS) + if not self.recursive: + break + # A folder added twice, or overlapping one added before, would import + # the same file twice as two products. + listed = {os.path.normcase(os.path.abspath(bpy.path.abspath(i.path))) + for i in s.step_files} + added = 0 + for path in found: + key = os.path.normcase(os.path.abspath(path)) + if key in listed: + continue + listed.add(key) + s.step_files.add().path = path + added += 1 + if not found: + self.report({'WARNING'}, "No supported files in that folder.") + else: + skipped = len(found) - added + self.report({'INFO'}, f"Added {added} file(s)" + + (f", {skipped} already in the list." if skipped else ".")) + return {'FINISHED'} + + class MV_OT_ImportAllSteps(Operator): """Import every file in the list, one at a time. - STEP files are converted first, by STEPper or by FreeCAD. STL files are - meshes already, so they skip both and go straight through Blender's own - importer; from the product collection onwards the two are indistinguishable. + STEP files are converted first, by STEPper or by FreeCAD, and IGES by + FreeCAD. Mesh files (STL, OBJ, PLY, glTF, FBX, 3MF) skip both and go + straight through Blender's own importers; from the product collection + onwards they are all indistinguishable. Runs modally: the conversion subprocess is polled between timer ticks instead of being waited on, so Blender keeps redrawing, the status bar @@ -1266,7 +3164,7 @@ class MV_OT_ImportAllSteps(Operator): falls back to running straight through. """ bl_idname = "mv.import_all_steps" - bl_label = "Import CAD Files" + bl_label = "Import Files" bl_options = {'REGISTER', 'UNDO'} def _setup(self, context): @@ -1274,7 +3172,7 @@ def _setup(self, context): if not s.step_files: self.report({'ERROR'}, "No files in the list.") return False - self._prefs = context.preferences.addons[__name__].preferences + self._prefs = addon_prefs(context) self._queue = [bpy.path.abspath(item.path) for item in s.step_files] self._total = len(self._queue) self._done = 0 @@ -1287,6 +3185,7 @@ def _setup(self, context): self._before = set() self._timer = None self._cancelled = False + self._rescale_batch = False return True # -- one file --------------------------------------------------------- @@ -1307,7 +3206,7 @@ def _discard(self, objs): except (ReferenceError, RuntimeError): pass - def _link_new(self, context, path, new_objs): + def _link_new(self, context, path, new_objs, units): # Always a fresh collection. Looking the name up first meant two # files sharing a basename -- partA/housing.step and partB/housing.step # -- landed in one collection and rendered as a single product, and an @@ -1323,15 +3222,41 @@ def _link_new(self, context, path, new_objs): c.objects.unlink(obj) product_coll.objects.link(obj) + # Measured before fitting, which is the last moment the real size is + # known. The manifest reports it next to the renders. + context.view_layer.update() s = context.scene.mv_settings + product_coll.mv_source = path + record_source_size(product_coll, units or s.mesh_units) + if s.fit_on_import: - context.view_layer.update() - if fit_collection(product_coll, s.target_size, recenter=True): + if s.scale_mode == 'RELATIVE': + # The shared scale depends on the biggest product, which may + # not have arrived yet; the whole batch is scaled together at + # the end instead. + fit_collection(product_coll, None, recenter=True, floor=s.rest_on_floor) + self._rescale_batch = True + elif fit_collection(product_coll, s.target_size, recenter=True, + floor=s.rest_on_floor): _, unbaked = bake_product(product_coll) self._unbaked.update(unbaked) + context.view_layer.update() + remember_home(product_coll) self._imported += 1 self._done += 1 + def _import_ready_file(self, context, path, units, what): + """Link what a finished import added, or reject it as empty.""" + new = self._new_objects() + strays = [o for o in new if o.type in NON_PRODUCT_TYPES] + self._discard(strays) + new = [o for o in new if o not in strays] + if has_geometry(new): + self._link_new(context, path, new, units) + else: + self._discard(new) + self._fail(path, f"{what} contained no geometry") + def _new_objects(self): return [o for o in bpy.data.objects if o not in self._before] @@ -1341,28 +3266,33 @@ def _start_file(self, context, path): self._fail(path, "file not found") return self._before = set(bpy.data.objects) + ext = file_ext(path) + label = ext.lstrip(".").upper() - if is_stl(path): + if ext in MESH_EXTENSIONS: # Nothing to convert, so no importer preference applies and no # subprocess is started: the file lands now and _advance moves on # to the next one on the same tick. - if not import_stl_file(path): - self._fail(path, "STL import failed") + ok, units = import_mesh_file(path) + if not ok: + self._discard(self._new_objects()) + self._fail(path, f"{label} import failed") return - new = self._new_objects() - if has_geometry(new): - self._link_new(context, path, new) - else: - self._discard(new) - self._fail(path, "STL contained no geometry") + self._import_ready_file(context, path, units, label) + return + if ext not in CAD_EXTENSIONS: + self._fail(path, "not a supported file type") return mode = self._prefs.step_import_mode + is_step = ext in STEP_EXTENSIONS - if mode in {'AUTO', 'STEPPER'} and try_stepper_import(path): + if is_step and mode in {'AUTO', 'STEPPER'} and try_stepper_import(path): new = self._new_objects() if has_geometry(new): - self._link_new(context, path, new) + # STEPper does its own unit handling, so the size it lands at + # is not known to be in any particular unit. + self._link_new(context, path, new, "") return self._discard(new) if mode == 'STEPPER': @@ -1370,11 +3300,13 @@ def _start_file(self, context, path): return if mode == 'STEPPER': - self._fail(path, "STEPper import failed") + self._fail(path, "STEPper import failed" if is_step + else "IGES needs FreeCAD; STEPper reads STEP only") return job = freecad_prepare(path, self._prefs.freecad_path, - self._prefs.tessellation_deflection) + self._prefs.tessellation_deflection, + step_colors(path) if is_step else None) if job is None: self._fail(path, "no importer available (set the FreeCAD path in preferences)") return @@ -1385,14 +3317,11 @@ def _start_file(self, context, path): def _collect_job(self, context): job, self._job = self._job, None path = self._job_path - obj_path, log = freecad_collect(job) - if obj_path and import_obj_file(obj_path): - new = self._new_objects() - if has_geometry(new): - self._link_new(context, path, new) - else: - self._discard(new) - self._fail(path, "converted mesh contained no geometry") + result, log = freecad_collect(job) + if result: + import_freecad_result(result) + # FreeCAD tessellates in millimetres whatever the file was drawn in. + self._import_ready_file(context, path, "mm", "converted mesh") else: tail = " ".join(log.split())[-160:] self._fail(path, "FreeCAD conversion failed" @@ -1437,6 +3366,11 @@ def _status(self): return f"MultiView: {self._done}/{self._total} {name}{waiting} - Esc to cancel" def _report_result(self): + if self._rescale_batch: + self._rescale_batch = False + unbaked = standardize_products(bpy.context, get_products(), scale=True, + recenter=False) + self._unbaked.update(unbaked) if self._cancelled: level, msg = {'WARNING'}, f"Import cancelled after {self._imported} file(s)." elif not self._imported: @@ -1514,11 +3448,19 @@ class MV_OT_ApplySceneSetup(Operator): def execute(self, context): s = context.scene.mv_settings - apply_world(context) - build_lighting(context, s.lighting_preset) + problems = [] + error = apply_world(context) + if error: + problems.append(error) + if not build_lighting(context, s.lighting_preset): + problems.append("Lighting preset could not be read.") + build_ground(context) if get_products(): build_cameras(context) - self.report({'INFO'}, "Lighting, background, and cameras updated.") + if problems: + self.report({'WARNING'}, " ".join(problems)) + else: + self.report({'INFO'}, "Lighting, background, ground and cameras updated.") return {'FINISHED'} @@ -1527,6 +3469,9 @@ class MV_OT_BuildCameras(Operator): bl_label = "Build Cameras" def execute(self, context): + if not planned_views(context.scene.mv_settings): + self.report({'ERROR'}, "No views selected. Tick at least one view.") + return {'CANCELLED'} skipped = build_cameras(context) if skipped: self.report({'WARNING'}, @@ -1540,7 +3485,9 @@ class MV_OT_BuildLighting(Operator): bl_label = "Rebuild Lighting" def execute(self, context): - build_lighting(context, context.scene.mv_settings.lighting_preset) + if not build_lighting(context, context.scene.mv_settings.lighting_preset): + self.report({'ERROR'}, "Lighting preset could not be read.") + return {'CANCELLED'} return {'FINISHED'} @@ -1549,7 +3496,69 @@ class MV_OT_ApplyWorld(Operator): bl_label = "Apply Background" def execute(self, context): - apply_world(context) + error = apply_world(context) + if error: + self.report({'ERROR'}, error) + return {'CANCELLED'} + build_ground(context) + return {'FINISHED'} + + +class MV_OT_SaveLightingPreset(Operator): + """Save the lights in MV_Lighting, including any you have added or moved, + as a preset available in every file""" + bl_idname = "mv.save_lighting_preset" + bl_label = "Save Lighting Preset" + + name: StringProperty(name="Name", default="My Studio") + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + specs = capture_lighting(context) + if not specs: + self.report({'ERROR'}, f"No lights in '{LIGHT_COLL}' to save.") + return {'CANCELLED'} + stem = safe_name(self.name, fallback="Preset") + folder = user_preset_dir(create=True) + path = os.path.join(folder, stem + ".json") + try: + with open(path, "w", encoding="utf-8") as f: + json.dump({"label": self.name, "lights": specs}, f, indent=2) + except OSError as exc: + self.report({'ERROR'}, f"Could not save preset: {exc}") + return {'CANCELLED'} + context.scene.mv_settings.lighting_preset = USER_PRESET_PREFIX + stem + self.report({'INFO'}, f"Saved {len(specs)} light(s) as '{stem}'.") + return {'FINISHED'} + + +class MV_OT_DeleteLightingPreset(Operator): + """Delete the selected saved lighting preset. Built-in presets cannot be deleted""" + bl_idname = "mv.delete_lighting_preset" + bl_label = "Delete Lighting Preset" + + @classmethod + def poll(cls, context): + return context.scene.mv_settings.lighting_preset.startswith(USER_PRESET_PREFIX) + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + s = context.scene.mv_settings + entry = user_lighting_presets().get(s.lighting_preset) + if entry is None: + self.report({'ERROR'}, "That preset is not on disk any more.") + return {'CANCELLED'} + try: + os.remove(entry[1]) + except OSError as exc: + self.report({'ERROR'}, f"Could not delete preset: {exc}") + return {'CANCELLED'} + s.lighting_preset = 'STUDIO_SOFT' + self.report({'INFO'}, f"Deleted lighting preset '{entry[0]}'.") return {'FINISHED'} @@ -1592,21 +3601,13 @@ def execute(self, context): return {'CANCELLED'} context.view_layer.update() - done = 0 + done = sum(1 for pc in products if collection_meshes(pc)) unbaked = set() smoothed = 0 + if s.recenter or s.rescale: + unbaked.update(standardize_products(context, products, scale=s.rescale, + recenter=s.recenter)) for pc in products: - if s.recenter or s.rescale: - if fit_collection(pc, - target_size=s.target_size if s.rescale else None, - recenter=s.recenter): - done += 1 - _, skipped_objs = bake_product(pc, - apply_scale=s.rescale, - origin_to_world=s.recenter) - unbaked.update(skipped_objs) - elif collection_meshes(pc): - done += 1 if s.auto_smooth: shaded, skipped_objs = smooth_product(pc, s.smooth_angle) smoothed += len(shaded) @@ -1652,6 +3653,8 @@ def execute(self, context): isolate_product(context, coll.name) context.view_layer.update() step = s.rotate_step + # The way it faced before the first turn is what Reset goes back to. + remember_home(coll) if not rotate_product(context, coll, self.direction, step): self.report({'WARNING'}, f"'{coll.name}' has no geometry to rotate.") return {'CANCELLED'} @@ -1661,6 +3664,76 @@ def execute(self, context): return {'FINISHED'} +def _orient_targets(context, all_products): + """The products an orientation button acts on, made visible to act on.""" + s = context.scene.mv_settings + if all_products: + isolate_product(context, None) + context.view_layer.update() + return get_products() + coll, _ = active_product(context) + if coll is None: + return [] + if s.isolate_active: + isolate_product(context, coll.name) + context.view_layer.update() + return [coll] + + +class MV_OT_AutoOrient(Operator): + """Turn products to a resting orientation: on their largest flat face, or + along their principal axes, with the longest side across the Front view""" + bl_idname = "mv.auto_orient" + bl_label = "Auto Orient" + bl_options = {'REGISTER', 'UNDO'} + + all_products: BoolProperty(name="All Products", default=False) + + def execute(self, context): + s = context.scene.mv_settings + products = _orient_targets(context, self.all_products) + if not products: + self.report({'ERROR'}, f"No product collections under '{PRODUCT_COLL}'.") + return {'CANCELLED'} + turned = sum(1 for pc in products if auto_orient(context, pc, s.orient_method)) + if self.all_products: + # Showing everything was only so they could be measured. + if s.isolate_active: + coll, _ = active_product(context) + isolate_product(context, coll.name if coll else None) + if not turned: + self.report({'WARNING'}, "No mesh geometry to orient.") + return {'CANCELLED'} + self.report({'INFO'}, f"Oriented {turned} product(s).") + return {'FINISHED'} + + +class MV_OT_ResetOrientation(Operator): + """Turn products back to the orientation they had when imported""" + bl_idname = "mv.reset_orientation" + bl_label = "Reset Orientation" + bl_options = {'REGISTER', 'UNDO'} + + all_products: BoolProperty(name="All Products", default=False) + + def execute(self, context): + s = context.scene.mv_settings + products = _orient_targets(context, self.all_products) + if not products: + self.report({'ERROR'}, f"No product collections under '{PRODUCT_COLL}'.") + return {'CANCELLED'} + reset = sum(1 for pc in products if reset_orientation(pc)) + context.view_layer.update() + if self.all_products and s.isolate_active: + coll, _ = active_product(context) + isolate_product(context, coll.name if coll else None) + if not reset: + self.report({'WARNING'}, "Nothing to reset: not turned since import.") + return {'CANCELLED'} + self.report({'INFO'}, f"Reset {reset} product(s).") + return {'FINISHED'} + + class MV_OT_ProductStep(Operator): """Show the next or previous product on its own, hiding the others""" bl_idname = "mv.product_step" @@ -1723,6 +3796,20 @@ def execute(self, context): return {'FINISHED'} +class MV_OT_SetProductsRender(Operator): + """Tick or untick every product for the next batch render""" + bl_idname = "mv.set_products_render" + bl_label = "Set Products Render" + bl_options = {'REGISTER', 'UNDO'} + + state: BoolProperty(default=True) + + def execute(self, context): + for pc in get_products(): + pc.mv_render = self.state + return {'FINISHED'} + + def _do_full_render(scene, filepath): scene.render.filepath = filepath bpy.ops.render.render(write_still=True) @@ -1758,144 +3845,3334 @@ def _do_clay_render(scene, filepath, clay_color): scene.display.shading.show_cavity = saved['show_cavity'] -class _BatchAborted(Exception): - """Stop the batch early without dumping a traceback on the user.""" +NORMAL_MATCAP = "check_normal+y.exr" -class MV_OT_RenderAll(Operator): - bl_idname = "mv.render_all" - bl_label = "Render All" +@contextmanager +def temporarily(*changes): + """Set (owner, attribute, value) triples, and put every one back on the way + out, in reverse, even when the body raises. Settings that depend on each + other (file format before colour depth, light before studio light) are + set in the order given and restored in the opposite one.""" + saved = [] + try: + for owner, attr, value in changes: + old = getattr(owner, attr) + if not isinstance(old, (str, bytes)) and hasattr(old, "__len__") \ + and not isinstance(old, bpy.types.ID): + old = tuple(old) + saved.append((owner, attr, old)) + setattr(owner, attr, value) + yield + finally: + for owner, attr, old in reversed(saved): + try: + setattr(owner, attr, old) + except (AttributeError, TypeError, ValueError): + pass - def _fail(self, failures, done, path, exc): - """Record one failed write, aborting the batch if nothing has landed. - Collected and reported at the end rather than raised, so a single - unwritable file cannot throw away a long batch that is otherwise fine. - Before the first success there is nothing to salvage and the cause is - almost always the setup, so that case stops immediately instead of - failing the same way for every remaining image. - """ - note = f"{os.path.basename(path)}: {exc}" - failures.append(note) - print(f"[MultiView] FAILED {note}") - if not done: - raise _BatchAborted from exc +def _png_output(scene): + image = scene.render.image_settings + return ((image, "file_format", 'PNG'), (image, "color_mode", 'RGBA'), + (image, "color_depth", '8')) - def execute(self, context): - scene = context.scene - s = scene.mv_settings - 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.report({'ERROR'}, "No cameras. Build cameras first.") - return {'CANCELLED'} +def _neutral_view(scene): + """Display values straight through, so white is white and a normal reads + back as the number it was.""" + view = scene.view_settings + return ((view, "view_transform", 'Standard'), (view, "look", 'None'), + (view, "exposure", 0.0), (view, "gamma", 1.0)) - products = get_products() - if not products: - self.report({'ERROR'}, f"No product collections under '{PRODUCT_COLL}'.") - return {'CANCELLED'} - out_root = bpy.path.abspath(s.render_output_dir) - try: - os.makedirs(out_root, exist_ok=True) - except OSError as exc: - self.report({'ERROR'}, f"Cannot use output folder '{out_root}': {exc}") - return {'CANCELLED'} +def _render_to(scene, path): + scene.render.filepath = path + bpy.ops.render.render(write_still=True) + return written_path(scene, path) - saved_hide = {} - for pc in products: - lc = find_layer_coll(context.view_layer.layer_collection, pc.name) - if lc: - saved_hide[pc.name] = lc.exclude - saved_cam = scene.camera - saved_path = scene.render.filepath - mode = s.render_mode - both = mode == 'BOTH' - total_per_pair = (2 if both else 1) * len(cameras) - total = len(products) * total_per_pair - done = 0 - failures = [] +def _save_like_render(scene, px, filepath): + """Write a computed image where, and in the format, a render would have.""" + image = scene.render.image_settings + save_pixels(px, written_path(scene, filepath), image.file_format, image.quality) + scene.render.filepath = filepath - try: - for i, pc in enumerate(products): - for other in products: - lc = find_layer_coll(context.view_layer.layer_collection, other.name) - if lc: - lc.exclude = (other.name != pc.name) - - label = product_label(s.start_prefix, s.start_number, i, s.products_per_letter) - product_dir = os.path.join(out_root, safe_name(f"{label}_{pc.name}")) - try: - os.makedirs(product_dir, exist_ok=True) - except OSError as exc: - self._fail(failures, done, product_dir, exc) - continue - for cam in cameras: - scene.camera = cam - view_name = cam.name.replace("MV_Cam_", "") - base = safe_name(f"{label}_{pc.name}_{view_name}") - - clay = (tuple(s.clay_color),) - if mode == 'FULL': - attempts = ((_do_full_render, base, ()),) - elif mode == 'CLAY': - attempts = ((_do_clay_render, base + "_clay", clay),) - else: - attempts = ((_do_full_render, base + "_render", ()), - (_do_clay_render, base + "_clay", clay)) - - for render, name, extra in attempts: - path = os.path.join(product_dir, name) - try: - render(scene, path, *extra) - except RuntimeError as exc: - # bpy.ops.render.render raises when the image cannot - # be written. Letting that escape put a Python - # traceback in front of the user instead of the name - # of the file that failed. - self._fail(failures, done, path, exc) - continue - done += 1 - print(f"[MultiView] {done}/{total}: {scene.render.filepath}") - except _BatchAborted: - pass - finally: - for pc in products: - lc = find_layer_coll(context.view_layer.layer_collection, pc.name) - if lc and pc.name in saved_hide: - lc.exclude = saved_hide[pc.name] - scene.camera = saved_cam - scene.render.filepath = saved_path - - if not failures: - self.report({'INFO'}, f"Wrote {done} image(s).") - return {'FINISHED'} +def srgb_decode(rgb): + rgb = np.clip(rgb, 0.0, 1.0) + return np.where(rgb <= 0.04045, rgb / 12.92, np.power((rgb + 0.055) / 1.055, 2.4)) - if not done: - # Nothing landed at all, so the batch stopped at the first failure - # rather than burning render time failing the same way every time. - self.report({'ERROR'}, f"Nothing rendered. {failures[0]}") - return {'CANCELLED'} - self.report({'WARNING'}, - f"Wrote {done} of {total} image(s). {len(failures)} failed, " - f"first: {failures[0]}. Full list in the System Console.") - return {'FINISHED'} +def line_mask(normals, crease_angle, width): + """Where to draw lines, from a render of view-space normals. + A line goes wherever neighbouring normals differ by more than the crease + angle, and along every silhouette. Pixels two apart are compared as well + as adjacent ones, because antialiasing blends one pixel between two faces + and halves the step each side of it: a 40 degree crease would otherwise + read as two 20 degree ones and slip under a 30 degree threshold. + """ + inside = normals[..., 3] > 0.5 + n = srgb_decode(normals[..., :3]) * 2.0 - 1.0 + n /= np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-6) + limit = math.cos(crease_angle) + edges = np.zeros(inside.shape, bool) + for step in (1, 2): + for axis in (0, 1): + a = [slice(None)] * 2 + b = [slice(None)] * 2 + a[axis], b[axis] = slice(None, -step), slice(step, None) + a, b = tuple(a), tuple(b) + crease = inside[a] & inside[b] & ((n[a] * n[b]).sum(axis=-1) < limit) + edges[b] |= crease + if step == 1: + rim = inside[a] != inside[b] + edges[a] |= rim & inside[a] + edges[b] |= rim & inside[b] + thick = edges.copy() + for i in range(1, width): + thick[i:, :] |= edges[:-i, :] + thick[:, i:] |= edges[:, :-i] + return thick, inside + + +def _do_lines_render(scene, filepath, options): + """Technical-drawing pass: outlines and creases as lines. + + Workbench renders the product with a matcap that colours each pixel by its + view-space normal, plus object outlines so separate parts that touch still + get a line between them. The lines are found from that image rather than + with Freestyle or Line Art, which keeps the pass a fast Workbench render + and independent of the engine the full render uses. + """ + angle, width, color, fill, clay_color = options + work = tempfile.mkdtemp(prefix="mv_lines_") + try: + base = None + if fill == 'CLAY': + clay = os.path.join(work, "clay") + _do_clay_render(scene, clay, clay_color) + base = load_pixels(written_path(scene, clay)) + shading = scene.display.shading + transparent = scene.render.film_transparent + with temporarily( + (scene.render, "engine", 'BLENDER_WORKBENCH'), + (shading, "type", 'SOLID'), + (shading, "light", 'MATCAP'), + (shading, "studio_light", NORMAL_MATCAP), + (shading, "show_cavity", False), + (shading, "show_shadows", False), + (shading, "show_specular_highlight", False), + (shading, "show_object_outline", True), + (shading, "object_outline_color", (0.0, 0.0, 0.0)), + (scene.render, "film_transparent", True), + (scene.render, "filepath", scene.render.filepath), + *_neutral_view(scene), *_png_output(scene)): + normals = load_pixels(_render_to(scene, os.path.join(work, "normals"))) + finally: + shutil.rmtree(work, ignore_errors=True) + + lines, inside = line_mask(normals, angle, width) + if base is None: + base = np.ones(normals.shape, np.float32) + if fill == 'NONE': + base[..., 3] = 0.0 + elif transparent: + base[..., 3] = inside | lines + ink = np.zeros(normals.shape, np.float32) + ink[..., :3] = color + ink[..., 3] = lines + paste_over(base, ink, 0, 0) + _save_like_render(scene, base, filepath) + + +def _do_mask_render(scene, filepath): + """Silhouette pass: white where the product is, black elsewhere, with + antialiased edges so it works as a matte.""" + work = tempfile.mkdtemp(prefix="mv_mask_") + shading = scene.display.shading + try: + with temporarily( + (scene.render, "engine", 'BLENDER_WORKBENCH'), + (shading, "type", 'SOLID'), + (shading, "light", 'FLAT'), + (shading, "color_type", 'SINGLE'), + (shading, "single_color", (1.0, 1.0, 1.0)), + (shading, "show_cavity", False), + (shading, "show_shadows", False), + (shading, "show_object_outline", False), + (scene.render, "film_transparent", True), + (scene.render, "filepath", scene.render.filepath), + *_png_output(scene)): + px = load_pixels(_render_to(scene, os.path.join(work, "mask"))) + finally: + shutil.rmtree(work, ignore_errors=True) + out = np.empty_like(px) + out[..., :3] = px[..., 3:4] + out[..., 3] = 1.0 + _save_like_render(scene, out, filepath) + + +def _ao_material(distance): + mat = bpy.data.materials.get("MV_AO") or bpy.data.materials.new("MV_AO") + tree = node_tree_of(mat) + tree.nodes.clear() + ao = tree.nodes.new("ShaderNodeAmbientOcclusion") + ao.samples = 16 + ao.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0) + ao.inputs["Distance"].default_value = distance + emit = tree.nodes.new("ShaderNodeEmission") + emit.location = (200, 0) + out = tree.nodes.new("ShaderNodeOutputMaterial") + out.location = (400, 0) + tree.links.new(ao.outputs["Color"], emit.inputs["Color"]) + tree.links.new(emit.outputs["Emission"], out.inputs["Surface"]) + return mat + + +def _ao_world(): + world = bpy.data.worlds.get("MV_AO_World") or bpy.data.worlds.new("MV_AO_World") + tree = node_tree_of(world) + tree.nodes.clear() + bg = tree.nodes.new("ShaderNodeBackground") + bg.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0) + out = tree.nodes.new("ShaderNodeOutputWorld") + tree.links.new(bg.outputs["Background"], out.inputs["Surface"]) + return world + + +def _do_ao_render(scene, filepath, options): + """Ambient occlusion pass: Cycles with every surface swapped for a white + emitter darkened by its own occlusion, so light and materials play no part + and only the shape shows.""" + samples, distance = options + if not hasattr(scene, "cycles"): + raise RuntimeError("the AO pass needs the Cycles add-on enabled") + with temporarily( + (scene.render, "engine", 'CYCLES'), + (scene.cycles, "samples", samples), + (bpy.context.view_layer, "material_override", _ao_material(distance)), + (scene, "world", _ao_world()), + *_neutral_view(scene)): + scene.render.filepath = filepath + bpy.ops.render.render(write_still=True) -class MV_UL_StepFiles(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - row = layout.row(align=True) - row.label(text=os.path.basename(item.path) or "(empty)", icon='FILE') - row.operator("mv.remove_step_file", text="", icon='X').index = index +# -- data passes ------------------------------------------------------------ +# +# Depth, normals and part IDs are measurements, not pictures. Each is a Cycles +# render of one sample with every surface swapped for an emitter that outputs +# the number wanted, written as 32-bit EXR so nothing is colour managed, +# clamped or quantized. One sample with a near-zero pixel filter puts every +# pixel on exactly one surface: no antialiasing to average a foreground depth +# with the background, or blend two part IDs into a third that does not exist. -class MV_UL_CustomViews(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): - row = layout.row(align=True) +def write_png16(path, values): + """Write a 2D array of 0-65535 as a 16-bit greyscale PNG, top row first. + + Blender's image API saves 8 bits from Python, which caps a label map at + 255 parts and a depth map at 255 steps, so this writes the file directly. + """ + values = np.ascontiguousarray(np.clip(values, 0, 65535).astype(">u2")) + h, w = values.shape + rows = b"".join(b"\x00" + values[y].tobytes() for y in range(h)) + + def chunk(kind, data): + body = kind + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body)) + + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + f.write(chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 16, 0, 0, 0, 0))) + f.write(chunk(b"IDAT", zlib.compress(rows, 6))) + f.write(chunk(b"IEND", b"")) + + +def read_png16(path): + """Read back a 16-bit greyscale PNG written by write_png16.""" + with open(path, "rb") as f: + data = f.read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + pos, idat, w, h = 8, b"", 0, 0 + while pos < len(data): + length, kind = struct.unpack(">I4s", data[pos:pos + 8]) + body = data[pos + 8:pos + 8 + length] + if kind == b"IHDR": + w, h, depth, colour = struct.unpack(">IIBB", body[:10]) + if depth != 16 or colour != 0: + raise ValueError("not a 16-bit greyscale PNG") + elif kind == b"IDAT": + idat += body + pos += 12 + length + raw = np.frombuffer(zlib.decompress(idat), dtype=np.uint8).reshape(h, 1 + w * 2) + if raw[:, 0].any(): + raise ValueError("row filters other than None are not supported") + return raw[:, 1:].copy().view(">u2").astype(np.uint16) + + +def _black_world(): + world = bpy.data.worlds.get("MV_Data_World") or bpy.data.worlds.new("MV_Data_World") + tree = node_tree_of(world) + tree.nodes.clear() + bg = tree.nodes.new("ShaderNodeBackground") + bg.inputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0) + bg.inputs["Strength"].default_value = 0.0 + out = tree.nodes.new("ShaderNodeOutputWorld") + tree.links.new(bg.outputs["Background"], out.inputs["Surface"]) + return world + + +def _data_material(kind): + """An emitter whose colour is the measurement itself.""" + name = f"MV_Data_{kind}" + mat = bpy.data.materials.get(name) or bpy.data.materials.new(name) + tree = node_tree_of(mat) + tree.nodes.clear() + emit = tree.nodes.new("ShaderNodeEmission") + emit.inputs["Strength"].default_value = 1.0 + out = tree.nodes.new("ShaderNodeOutputMaterial") + out.location = (400, 0) + tree.links.new(emit.outputs["Emission"], out.inputs["Surface"]) + if kind == "depth": + cam = tree.nodes.new("ShaderNodeCameraData") + cam.location = (-300, 0) + tree.links.new(cam.outputs["View Z Depth"], emit.inputs["Color"]) + elif kind == "normal": + # World space here; _do_normal_render turns it into camera space with + # the camera's own matrix. Cycles' "camera space" points Z the other + # way from OpenGL, so relying on it would flip every normal. + geo = tree.nodes.new("ShaderNodeNewGeometry") + geo.location = (-300, 0) + tree.links.new(geo.outputs["Normal"], emit.inputs["Color"]) + else: + info = tree.nodes.new("ShaderNodeObjectInfo") + info.location = (-300, 0) + tree.links.new(info.outputs["Object Index"], emit.inputs["Color"]) + return mat + + +def _render_data(scene, filepath, kind, extra=()): + if not hasattr(scene, "cycles"): + raise RuntimeError(f"the {kind} pass needs the Cycles add-on enabled") + image = scene.render.image_settings + with temporarily( + (scene.render, "engine", 'CYCLES'), + (scene.cycles, "samples", 1), + (scene.cycles, "use_denoising", False), + (scene.cycles, "use_adaptive_sampling", False), + (scene.cycles, "filter_width", 0.01), + (scene.render, "use_motion_blur", False), + (scene.render, "film_transparent", True), + (scene.render, "use_file_extension", True), + (bpy.context.view_layer, "material_override", _data_material(kind)), + (scene, "world", _black_world()), + (image, "file_format", 'OPEN_EXR'), + (image, "color_mode", 'RGBA'), + (image, "color_depth", '32'), + *_neutral_view(scene), + *extra): + scene.render.filepath = filepath + bpy.ops.render.render(write_still=True) + return filepath + ".exr" + + +def _do_depth_render(scene, filepath): + """Distance along the camera's view axis, in scene units, in R, G and B; + 0 and alpha 0 where nothing was hit.""" + _render_data(scene, filepath, "depth") + + +def save_exr(px, path): + """Write float RGBA pixels (rows bottom to top) to a 32-bit EXR as is.""" + h, w = px.shape[:2] + img = bpy.data.images.new("MV_Data", w, h, alpha=True, float_buffer=True) + try: + img.pixels.foreach_set(np.ascontiguousarray(px, dtype=np.float32).ravel()) + img.filepath_raw = path + img.file_format = 'OPEN_EXR' + img.save() + finally: + bpy.data.images.remove(img) + + +def _do_normal_render(scene, filepath): + """Shading normals in camera space, OpenGL convention: +X right, +Y up, + +Z towards the camera. Values in -1..1, not remapped.""" + work = tempfile.mkdtemp(prefix="mv_normal_") + try: + px = load_pixels(_render_data(scene, os.path.join(work, "world"), "normal"), raw=True) + finally: + shutil.rmtree(work, ignore_errors=True) + to_cam = np.array(scene.camera.matrix_world.to_3x3().normalized().transposed()) + hit = px[..., 3] > 0.5 + px[..., :3] = np.where(hit[..., None], px[..., :3] @ to_cam.T, 0.0) + save_exr(px, filepath + ".exr") + scene.render.filepath = filepath + + +def _do_parts_render(scene, filepath, part_map): + """Which part every pixel shows, as a 16-bit PNG: 0 is background, n is + the n-th part in the product's part list. Returns the label array. + + Part numbers go in through each object's pass index, set only for the + render and put back afterwards. + """ + extra = [] + for name, part_id in part_map.items(): + obj = bpy.data.objects.get(name) + if obj is not None: + extra.append((obj, "pass_index", part_id)) + work = tempfile.mkdtemp(prefix="mv_parts_") + try: + exr = _render_data(scene, os.path.join(work, "ids"), "ids", extra) + px = load_pixels(exr, raw=True) + finally: + shutil.rmtree(work, ignore_errors=True) + labels = np.where(px[..., 3] > 0.5, np.rint(px[..., 0]), 0).astype(np.int64) + labels = np.clip(labels, 0, 65535)[::-1] + write_png16(filepath + ".png", labels) + scene.render.filepath = filepath + return labels + + +# (name, render function, file extension). Unlike the image modes these are +# never post-processed or put on a contact sheet: they are data. +# -- engineering drawings ------------------------------------------------------ +# +# Vector three-view drawings of a product, true to scale in its source units. +# Lines are the mesh's feature edges -- creases sharper than the crease angle, +# silhouettes and open boundaries -- found geometrically, then split into +# visible and hidden stretches by testing points along each edge against a +# depth render of the same view. Hidden stretches are drawn dashed, as in a +# technical drawing. +# +# The three views use cameras of their own rather than the render rig's, +# because a drawing has rules a render rig does not: the plan is seen with the +# product's front at the bottom, so its horizontal lines up with the front +# elevation, and the side view stands upright beside it. + +DRAWING_VIEWS = ("Front", "Top", "Right") + + +def _drawing_bases(settings): + """{view: (right, up, toward camera)} as world unit vectors.""" + front = Vector(rotate_dir_z((0.0, -1.0, 0.0), FRONT_AXIS_ANGLES[settings.front_axis])) + z = Vector((0.0, 0.0, 1.0)) + right = z.cross(front) + return { + "Front": (right, z, front), + "Top": (right, -front, z), + "Right": (z.cross(right), z, right), + } + + +def _feature_edges(obj, toward, crease_cos): + """World endpoints (n, 2, 3) of an object's feature edges as seen looking + against `toward` (the direction to the camera).""" + me = obj.data + nv, ne, nl, npoly = len(me.vertices), len(me.edges), len(me.loops), len(me.polygons) + if not ne or not npoly: + return np.zeros((0, 2, 3)) + m = np.array(obj.matrix_world) + co = np.empty(nv * 3) + me.vertices.foreach_get("co", co) + co = co.reshape(-1, 3) @ m[:3, :3].T + m[:3, 3] + edges = np.empty(ne * 2, np.int64) + me.edges.foreach_get("vertices", edges) + edges = edges.reshape(-1, 2) + normals = np.empty(npoly * 3) + me.polygons.foreach_get("normal", normals) + normal_matrix = np.linalg.inv(m[:3, :3]).T + normals = normals.reshape(-1, 3) @ normal_matrix.T + normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-12) + loop_edge = np.empty(nl, np.int64) + me.loops.foreach_get("edge_index", loop_edge) + totals = np.empty(npoly, np.int64) + me.polygons.foreach_get("loop_total", totals) + loop_poly = np.repeat(np.arange(npoly), totals) + + order = np.argsort(loop_edge, kind="stable") + sorted_edges, sorted_polys = loop_edge[order], loop_poly[order] + uniq, first, counts = np.unique(sorted_edges, return_index=True, return_counts=True) + feature = np.zeros(ne, bool) + feature[uniq[counts != 2]] = True # open boundaries and non-manifold + pair = counts == 2 + e2, f1, f2 = uniq[pair], sorted_polys[first[pair]], sorted_polys[first[pair] + 1] + n1, n2 = normals[f1], normals[f2] + crease = (n1 * n2).sum(axis=1) < crease_cos + facing = np.asarray(toward) + silhouette = (n1 @ facing) * (n2 @ facing) < 0 + feature[e2[crease | silhouette]] = True + feature[np.setdiff1d(np.arange(ne), uniq)] = True # loose edges + return co[edges[feature]] + + +def drawing_view(context, coll, view, basis, settings): + """Visible and hidden polylines of one view, in source units, paper axes + (x right, y down), centred on the product.""" + scene = context.scene + right, up, toward = (Vector(v).normalized() for v in basis) + meshes = [o for o in collection_meshes(coll) if o.type == 'MESH'] + center, size = bbox_of(meshes) + to_world = product_to_world(coll) + scale = abs(to_world.to_3x3().determinant()) ** (1.0 / 3.0) or 1.0 + units_mm = UNIT_TO_MM.get(coll.mv_source_units, 1.0) + + half = size * 0.5 + corners = [center + Vector((x, y, z)) for x in (-half.x, half.x) + for y in (-half.y, half.y) for z in (-half.z, half.z)] + ex = max(abs((c - center).dot(right)) for c in corners) * 1.05 + 1e-6 + ey = max(abs((c - center).dot(up)) for c in corners) * 1.05 + 1e-6 + reach = max((c - center).dot(toward) for c in corners) + long_side = settings.drawing_resolution + if ex >= ey: + w, h = long_side, max(8, int(round(long_side * ey / ex))) + pixel = 2 * ex / w + else: + w, h = max(8, int(round(long_side * ex / ey))), long_side + pixel = 2 * ey / h + + data = bpy.data.cameras.new("MV_Drawing_Cam") + data.type = 'ORTHO' + data.ortho_scale = pixel * max(w, h) + distance = reach + size.length + 1e-3 + data.clip_start = 1e-4 + data.clip_end = distance + size.length * 2 + 1.0 + cam = bpy.data.objects.new("MV_Drawing_Cam", data) + scene.collection.objects.link(cam) + position = center + toward * distance + basis_m = Matrix((right, up, toward)).transposed() + cam.matrix_world = Matrix.Translation(position) @ basis_m.to_4x4() + r = scene.render + work = tempfile.mkdtemp(prefix="mv_drawing_") + try: + with temporarily((scene, "camera", cam), (r, "resolution_x", w), (r, "resolution_y", h), + (r, "resolution_percentage", 100), + (r, "pixel_aspect_x", 1.0), (r, "pixel_aspect_y", 1.0)): + px = load_pixels(_render_data(scene, os.path.join(work, "depth"), "depth"), raw=True) + finally: + shutil.rmtree(work, ignore_errors=True) + remove_object(cam) + depth = np.where(px[..., 3] > 0.5, px[..., 0], np.inf)[::-1] + # The farthest depth among a pixel and its neighbours: a line exactly on a + # silhouette or a step stays visible however the pixel grid falls. + padded = np.pad(depth, 1, mode="edge") + deepest = np.max([padded[1 + dy:1 + dy + h, 1 + dx:1 + dx + w] + for dy in (-1, 0, 1) for dx in (-1, 0, 1)], axis=0) + + segments = np.concatenate([_feature_edges(o, toward, math.cos(settings.drawing_crease)) + for o in meshes]) if meshes else np.zeros((0, 2, 3)) + if not len(segments): + return {"visible": [], "hidden": []} + rel = segments - np.array(center) + xs = rel @ np.array(right) + ys = rel @ np.array(up) + zs = distance - rel @ np.array(toward) + u = w / 2 + xs / pixel + v = h / 2 - ys / pixel + length = np.hypot(u[:, 1] - u[:, 0], v[:, 1] - v[:, 0]) + steps = np.maximum(np.ceil(length).astype(np.int64), 1) + 1 + edge_of = np.repeat(np.arange(len(segments)), steps) + offsets = np.arange(edge_of.size) - np.repeat(np.cumsum(steps) - steps, steps) + t = offsets / np.repeat(steps - 1, steps) + su = u[edge_of, 0] + (u[edge_of, 1] - u[edge_of, 0]) * t + sv = v[edge_of, 0] + (v[edge_of, 1] - v[edge_of, 0]) * t + sz = zs[edge_of, 0] + (zs[edge_of, 1] - zs[edge_of, 0]) * t + iu = np.clip(np.floor(su).astype(np.int64), 0, w - 1) + iv = np.clip(np.floor(sv).astype(np.int64), 0, h - 1) + visible = sz <= deepest[iv, iu] + 2.5 * pixel + + # Runs of samples on one edge with one visibility become one line. + to_paper = pixel / scale * units_mm + px_x = (su - w / 2) * to_paper + px_y = (sv - h / 2) * to_paper + breaks = np.flatnonzero((np.diff(edge_of) != 0) | (np.diff(visible.astype(np.int8)) != 0)) + 1 + starts = np.concatenate([[0], breaks]) + ends = np.concatenate([breaks, [edge_of.size]]) - 1 + lines = {"visible": [], "hidden": []} + for a, b in zip(starts, ends): + # A run on its own owns the samples at both ends; where two runs meet, + # extend to the shared boundary so dashes join their solid lines. + b2 = b + 1 if b + 1 < edge_of.size and edge_of[b + 1] == edge_of[a] else b + if b2 == a: + continue + line = (float(px_x[a]), float(px_y[a]), float(px_x[b2]), float(px_y[b2])) + lines["visible" if visible[a] else "hidden"].append(line) + return lines + + +def _bounds(lines): + pts = [(x, y) for group in lines.values() for l in group for x, y in (l[:2], l[2:])] + if not pts: + return 0.0, 0.0, 0.0, 0.0 + xs, ys = zip(*pts) + return min(xs), min(ys), max(xs), max(ys) + + +def drawing_svg(placed, title, unit, show_hidden=True, dimensions=True): + """An SVG of one or more views. placed: [(lines, dx, dy)] in paper units.""" + all_bounds = [(_bounds(lines), dx, dy) for lines, dx, dy in placed] + x0 = min(b[0] + dx for b, dx, dy in all_bounds) + y0 = min(b[1] + dy for b, dx, dy in all_bounds) + x1 = max(b[2] + dx for b, dx, dy in all_bounds) + y1 = max(b[3] + dy for b, dx, dy in all_bounds) + size = max(x1 - x0, y1 - y0, 1e-6) + margin = size * 0.12 + stroke = size / 400 + dash = size / 80 + text = size / 45 + x0, y0, x1, y1 = x0 - margin, y0 - margin, x1 + margin, y1 + margin + + def path(lines, dx, dy): + return " ".join(f"M{a + dx:.4f} {b + dy:.4f}L{c + dx:.4f} {d + dy:.4f}" + for a, b, c, d in lines) + + out = [f'', + f"{title}", + f'Scale 1:1, units {unit}. Generated by MultiView Product Renderer.', + f''] + for lines, dx, dy in placed: + if show_hidden and lines["hidden"]: + out.append(f'') + if lines["visible"]: + out.append(f'') + if dimensions and (lines["visible"] or lines["hidden"]): + bx0, by0, bx1, by1 = _bounds(lines) + bx0, bx1, by0, by1 = bx0 + dx, bx1 + dx, by0 + dy, by1 + dy + gap = margin * 0.35 + tick = stroke * 4 + dim = (f"M{bx0:.4f} {by1 + gap:.4f}L{bx1:.4f} {by1 + gap:.4f}" + f"M{bx0:.4f} {by1 + gap - tick:.4f}L{bx0:.4f} {by1 + gap + tick:.4f}" + f"M{bx1:.4f} {by1 + gap - tick:.4f}L{bx1:.4f} {by1 + gap + tick:.4f}" + f"M{bx1 + gap:.4f} {by0:.4f}L{bx1 + gap:.4f} {by1:.4f}" + f"M{bx1 + gap - tick:.4f} {by0:.4f}L{bx1 + gap + tick:.4f} {by0:.4f}" + f"M{bx1 + gap - tick:.4f} {by1:.4f}L{bx1 + gap + tick:.4f} {by1:.4f}") + out.append(f'') + out.append(f'{bx1 - bx0:.2f}') + out.append(f'{by1 - by0:.2f}') + out.append("") + return "\n".join(out) + + +def sheet_layout(views, projection): + """Offsets placing Front, Top and Right on one sheet. Third angle puts the + plan above the front and the right side view on its right; first angle + puts the plan below and the right side view on the left.""" + fx0, fy0, fx1, fy1 = _bounds(views["Front"]) + tx0, ty0, tx1, ty1 = _bounds(views["Top"]) + rx0, ry0, rx1, ry1 = _bounds(views["Right"]) + gap = max(fx1 - fx0, fy1 - fy0, 1e-6) * 0.25 + if projection == 'THIRD': + top_dy = fy0 - gap - ty1 + right_dx = fx1 + gap - rx0 + else: + top_dy = fy1 + gap - ty0 + right_dx = fx0 - gap - rx1 + return [(views["Front"], 0.0, 0.0), (views["Top"], 0.0, top_dy), (views["Right"], right_dx, 0.0)] + + +def data_modes(settings): + s = settings + modes = [] + if s.pass_depth or s.export_bop: + modes.append(("depth", _do_depth_render, ".exr")) + if s.pass_normal: + modes.append(("normal", _do_normal_render, ".exr")) + if s.pass_parts or s.export_coco: + modes.append(("parts", _do_parts_render, ".png")) + return modes + + +def part_list(coll): + """The product's parts, numbered from 1 in name order, as written into + the part-ID images and cameras.json.""" + meshes = sorted((o for o in collection_meshes(coll) if o.type == 'MESH'), + key=lambda o: o.name) + return {o.name: i + 1 for i, o in enumerate(meshes)} + + +# -- cameras -------------------------------------------------------------------- + +def render_resolution(scene): + r = scene.render + return (int(r.resolution_x * r.resolution_percentage / 100), + int(r.resolution_y * r.resolution_percentage / 100)) + + +def camera_record(scene, cam, matrix=None): + """Everything needed to project the scene into one of this camera's images. + + Extrinsics come in both conventions in use: camera-to-world in OpenGL axes + (Blender's own, and nerfstudio's: +Y up, looking down -Z) and + world-to-camera in OpenCV axes (+Y down, looking down +Z; COLMAP and BOP). + Perspective cameras get a pinhole K. Orthographic ones get the size of a + pixel in scene units instead, since no K describes them. + """ + r = scene.render + w, h = render_resolution(scene) + data = cam.data + c2w = (matrix if matrix is not None else cam.matrix_world).normalized() + flip = Matrix(((1, 0, 0, 0), (0, -1, 0, 0), (0, 0, -1, 0), (0, 0, 0, 1))) + w2c_cv = flip @ c2w.inverted() + pax, pay = r.pixel_aspect_x, r.pixel_aspect_y + fit = data.sensor_fit + if fit == 'AUTO': + fit = 'HORIZONTAL' if w * pax >= h * pay else 'VERTICAL' + aspect = pay / pax + across = w if fit == 'HORIZONTAL' else h * aspect + record = { + "camera": cam.name, + "width": w, "height": h, + "c2w_opengl": [list(row) for row in c2w], + "w2c_opencv": [list(row) for row in w2c_cv], + } + if data.type == 'ORTHO': + size = data.ortho_scale / across + record.update(projection="orthographic", ortho_scale=data.ortho_scale, + pixel_size=[size, size * aspect], + principal_point=[w / 2 - data.shift_x * across, + h / 2 + data.shift_y * across / aspect]) + else: + sensor = data.sensor_height if fit == 'VERTICAL' else data.sensor_width + fx = data.lens / sensor * across + fy = fx / aspect + cx = w / 2 - data.shift_x * across + cy = h / 2 + data.shift_y * across / aspect + record.update(projection="perspective", lens_mm=data.lens, + K=[[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]]) + record["clip"] = [data.clip_start, data.clip_end] + return record + + +def written_path(scene, path): + """Where render.render(write_still=True) puts an image asked for at path. + + Blender appends the format's extension itself while File Extensions is on, + so a skip check that looked for the bare path would never find anything and + a resumed batch would quietly render everything again. + """ + r = scene.render + return path + r.file_extension if r.use_file_extension else path + + +def file_ok(path): + """True for a file worth keeping. An empty one is what a crash mid-write + leaves behind, and is exactly the file a resume is for.""" + return os.path.isfile(path) and os.path.getsize(path) > 0 + + +def format_duration(seconds): + seconds = max(int(round(seconds)), 0) + if seconds < 60: + return f"{seconds}s" + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{minutes}m {seconds:02d}s" + hours, minutes = divmod(minutes, 60) + return f"{hours}h {minutes:02d}m" + + +# -- naming --------------------------------------------------------------- + +DEFAULT_FILE_TEMPLATE = "{label}_{product}_{view}_{mode}" +DEFAULT_FOLDER_TEMPLATE = "{label}_{product}" +TEMPLATE_TOKEN = re.compile(r"\{(\w+)\}") +TEMPLATE_SEPARATORS = "_-. " + + +def expand_template(template, values): + """Fill {token}s from values. + + An empty value takes one neighbouring separator with it, so the blank + {mode} of a Full Render turns 'AA_01_Widget_Front_{mode}' into + 'AA_01_Widget_Front' rather than leaving a dangling underscore. Unknown + tokens are left exactly as typed, so a typo shows up in the panel preview + instead of silently vanishing from every filename. + """ + out = "" + pos = 0 + eat_next = False + for m in TEMPLATE_TOKEN.finditer(template): + literal = template[pos:m.start()] + if eat_next and literal and literal[0] in TEMPLATE_SEPARATORS: + literal = literal[1:] + eat_next = False + out += literal + key = m.group(1) + if key not in values: + out += m.group(0) + elif values[key]: + out += values[key] + elif out and out[-1] in TEMPLATE_SEPARATORS: + out = out[:-1] + else: + eat_next = True + pos = m.end() + literal = template[pos:] + if eat_next and literal and literal[0] in TEMPLATE_SEPARATORS: + literal = literal[1:] + return out + literal + + +def name_values(settings, index, product, view, mode): + label = product_label(settings.start_prefix, settings.start_number, index, + settings.products_per_letter) + prefix, _, number = label.rpartition("_") + return {"label": label, "prefix": prefix, "number": number, + "product": product, "view": view, "mode": mode} + + +def output_folder(settings, values): + """Folder under the output directory, from the folder template. + + Each / separated level is sanitized on its own, so a template can nest + folders but a product name can never climb out of the output directory: + '..' sanitizes to nothing and the level is dropped. + """ + levels = [] + for part in re.split(r"[\\/]+", settings.folder_template or ""): + level = safe_name(expand_template(part, values), fallback="") + if level: + levels.append(level) + return os.path.join(*levels) if levels else "" + + +def output_basename(settings, values): + return safe_name(expand_template(settings.file_template or DEFAULT_FILE_TEMPLATE, values)) + + +class _NameClash(Exception): + """Two outputs of one batch would be written to the same file.""" + + +# -- image buffers ---------------------------------------------------------- +# +# Everything below works on float32 RGBA arrays shaped (height, width, 4), in +# 0-1, with rows bottom to top as Blender stores them. Values are the encoded +# 8-bit display values straight out of the file, never linearized, so an image +# read and written back is byte for byte what Render wrote. + +POST_FORMATS = {'PNG': ".png", 'JPEG': ".jpg", 'WEBP': ".webp"} + + +def srgb_encode(rgb): + rgb = np.clip(rgb, 0.0, None) + return np.where(rgb <= 0.0031308, rgb * 12.92, 1.055 * np.power(rgb, 1 / 2.4) - 0.055) + + +def load_pixels(path, raw=False): + """RGBA pixels of an image file. raw keeps float data exactly as stored, + for depth, normal and ID passes where the numbers are measurements.""" + img = bpy.data.images.load(path, check_existing=False) + try: + w, h = img.size + if not w or not h: + raise RuntimeError("Blender could not read the image") + px = np.empty(w * h * 4, dtype=np.float32) + img.pixels.foreach_get(px) + px = px.reshape(h, w, 4) + if raw: + return px + if img.is_float: + # 16-bit PNG and EXR come in as scene-linear floats. Encode them + # back to display values so they sit with everything else; EXR + # skips the view transform a PNG would have had baked in. + px[..., :3] = srgb_encode(px[..., :3]) + return np.clip(px, 0.0, 1.0) + finally: + bpy.data.images.remove(img) + + +def image_size(path): + try: + img = bpy.data.images.load(path, check_existing=False) + except RuntimeError: + return None, None + try: + w, h = img.size + return (w, h) if w and h else (None, None) + finally: + bpy.data.images.remove(img) + + +def save_pixels(px, path, file_format, quality=90): + h, w = px.shape[:2] + img = bpy.data.images.new("MV_Output", w, h, alpha=True, float_buffer=False) + try: + img.pixels.foreach_set(np.ascontiguousarray(np.clip(px, 0.0, 1.0), dtype=np.float32).ravel()) + img.filepath_raw = path + img.file_format = file_format + try: + img.save(quality=quality) + except TypeError: + img.save() + finally: + bpy.data.images.remove(img) + + +def bilinear(px, new_w, new_h): + h, w = px.shape[:2] + ys = (np.arange(new_h) + 0.5) * h / new_h - 0.5 + xs = (np.arange(new_w) + 0.5) * w / new_w - 0.5 + y0 = np.floor(ys).astype(int) + x0 = np.floor(xs).astype(int) + wy = np.clip(ys - y0, 0.0, 1.0)[:, None, None] + wx = np.clip(xs - x0, 0.0, 1.0)[None, :, None] + y1 = np.clip(y0 + 1, 0, h - 1) + x1 = np.clip(x0 + 1, 0, w - 1) + y0 = np.clip(y0, 0, h - 1) + x0 = np.clip(x0, 0, w - 1) + top = px[y0][:, x0] * (1 - wx) + px[y0][:, x1] * wx + bottom = px[y1][:, x0] * (1 - wx) + px[y1][:, x1] * wx + return (top * (1 - wy) + bottom * wy).astype(np.float32) + + +def resize_pixels(px, new_w, new_h): + """Resample with alpha premultiplied, so transparent pixels' colour cannot + bleed a dark fringe into the edge of the product.""" + h, w = px.shape[:2] + new_w, new_h = max(int(new_w), 1), max(int(new_h), 1) + if (w, h) == (new_w, new_h): + return px + alpha = px[..., 3:4] + pm = np.concatenate([px[..., :3] * alpha, alpha], axis=-1) + # Box-average whole factors first: bilinear alone only samples four + # pixels, which aliases badly when shrinking a 4K render to a thumbnail. + fy, fx = max(1, h // new_h), max(1, w // new_w) + if fx > 1 or fy > 1: + hh, ww = (h // fy) * fy, (w // fx) * fx + pm = pm[:hh, :ww].reshape(hh // fy, fy, ww // fx, fx, 4).mean(axis=(1, 3)) + pm = bilinear(pm, new_w, new_h) + alpha = pm[..., 3:4] + rgb = pm[..., :3] / np.maximum(alpha, 1e-6) + rgb = np.where(alpha > 1e-6, rgb, 0.0) + return np.concatenate([rgb, alpha], axis=-1).astype(np.float32) + + +def fit_long_edge(px, size): + h, w = px.shape[:2] + scale = size / max(w, h) + return resize_pixels(px, round(w * scale), round(h * scale)) + + +def flatten(px, color): + out = np.empty_like(px) + alpha = px[..., 3:4] + out[..., :3] = px[..., :3] * alpha + np.asarray(color[:3], np.float32) * (1 - alpha) + out[..., 3] = 1.0 + return out + + +def has_transparency(px): + return bool(px[..., 3].min() < 0.99) + + +def background_of(px): + """The colour a product sits on: transparent when there is alpha to use, + otherwise whatever the four corners agree on.""" + if has_transparency(px): + return np.zeros(4, np.float32) + corners = np.stack([px[0, 0], px[0, -1], px[-1, 0], px[-1, -1]]) + return np.median(corners, axis=0).astype(np.float32) + + +def content_box(px, background): + """(x0, y0, x1, y1) around everything that is not background, or None.""" + if has_transparency(px): + mask = px[..., 3] > 0.02 + else: + mask = np.abs(px[..., :3] - background[:3]).max(axis=-1) > 0.02 + rows = np.flatnonzero(mask.any(axis=1)) + cols = np.flatnonzero(mask.any(axis=0)) + if not rows.size: + return None + return int(cols[0]), int(rows[0]), int(cols[-1]) + 1, int(rows[-1]) + 1 + + +def pad_pixels(px, left, right, bottom, top, fill): + h, w = px.shape[:2] + out = np.empty((h + bottom + top, w + left + right, 4), np.float32) + out[:] = fill + out[bottom:bottom + h, left:left + w] = px + return out + + +def paste_over(canvas, src, x, y): + """Composite src onto canvas with its bottom-left corner at (x, y).""" + h, w = src.shape[:2] + big_h, big_w = canvas.shape[:2] + x0, y0 = max(x, 0), max(y, 0) + x1, y1 = min(x + w, big_w), min(y + h, big_h) + if x0 >= x1 or y0 >= y1: + return + s = src[y0 - y:y1 - y, x0 - x:x1 - x] + d = canvas[y0:y1, x0:x1] + sa, da = s[..., 3:4], d[..., 3:4] + out_a = sa + da * (1 - sa) + rgb = (s[..., :3] * sa + d[..., :3] * da * (1 - sa)) / np.maximum(out_a, 1e-6) + canvas[y0:y1, x0:x1] = np.concatenate([rgb, out_a], axis=-1) + + +def post_process(px, settings): + """Trim, pad and resize one image the way the Post-Processing panel asks.""" + background = background_of(px) + if settings.post_trim: + box = content_box(px, background) + if box is not None: + x0, y0, x1, y1 = box + px = px[y0:y1, x0:x1] + margin = int(round(settings.post_margin * max(x1 - x0, y1 - y0))) + if margin: + px = pad_pixels(px, margin, margin, margin, margin, background) + if settings.post_square: + h, w = px.shape[:2] + if w != h: + side = max(w, h) + left, bottom = (side - w) // 2, (side - h) // 2 + px = pad_pixels(px, left, side - w - left, bottom, side - h - bottom, background) + # JPEG has no alpha channel. Dropping it unflattened leaves the RGB that + # transparent pixels happen to hold, which for a render is black. + if settings.post_flatten or settings.post_format == 'JPEG': + px = flatten(px, settings.post_bg_color) + if settings.post_max_size and max(px.shape[:2]) > settings.post_max_size: + px = fit_long_edge(px, settings.post_max_size) + return px + + +def parse_sizes(text): + sizes = {int(v) for v in re.findall(r"\d+", text or "") if int(v) >= 8} + return sorted(sizes, reverse=True) + + +# -- contact sheets ----------------------------------------------------------- + +def text_mask(text, size, tmp_dir): + """Coverage (height, width) of text in Blender's UI font, or None on a + Blender that cannot draw text into an image buffer.""" + if not hasattr(blf, "bind_imbuf"): + return None + import imbuf + font = 0 + blf.size(font, size) + width, _ = blf.dimensions(font, text) + buf = imbuf.new((max(int(width) + 4, 1), int(size * 1.5) + 4)) + with blf.bind_imbuf(font, buf): + blf.color(font, 1.0, 1.0, 1.0, 1.0) + blf.position(font, 2, int(size * 0.35) + 2, 0) + blf.draw_buffer(font, text) + # imbuf has no pixel access from Python, so the text goes through a file. + path = os.path.join(tmp_dir, "mv_text.png") + imbuf.write(buf, filepath=path) + return load_pixels(path)[..., 0] + + +def contact_sheet(cells, title, settings, tmp_dir): + """Lay out [(caption, pixels)] on one canvas, captions under each view and + the product's name across the top. Cells arrive already scaled to the + sheet's cell width.""" + cell_w = settings.sheet_cell_width + cell_h = max(px.shape[0] for _, px in cells) + n = len(cells) + cols = min(settings.sheet_columns or math.ceil(math.sqrt(n)), n) + rows = math.ceil(n / cols) + gap = max(8, cell_w // 24) + caption_size = max(11, cell_w // 20) + title_size = int(caption_size * 1.5) + labels = settings.sheet_labels and hasattr(blf, "bind_imbuf") + caption_h = int(caption_size * 1.8) if labels else 0 + title_h = int(title_size * 2.0) if labels else 0 + + width = cols * cell_w + (cols + 1) * gap + height = title_h + rows * (cell_h + caption_h) + (rows + 1) * gap + canvas = np.empty((height, width, 4), np.float32) + canvas[:] = settings.sheet_bg + ink = np.asarray(settings.sheet_text_color, np.float32) + + def draw_text(text, size, x, y_top, max_w): + mask = text_mask(text, size, tmp_dir) + if mask is None: + return + mask = mask[:, :max_w] + layer = np.empty(mask.shape + (4,), np.float32) + layer[..., :3] = ink + layer[..., 3] = mask + paste_over(canvas, layer, x, height - y_top - mask.shape[0]) + + if labels: + draw_text(title, title_size, gap, int(title_size * 0.4), width - 2 * gap) + for i, (caption, px) in enumerate(cells): + row, col = divmod(i, cols) + x = gap + col * (cell_w + gap) + y_top = title_h + gap + row * (cell_h + caption_h + gap) + offset = (cell_h - px.shape[0]) // 2 + # Rows are stored bottom up, so a top-down layout position flips. + paste_over(canvas, px, x, height - (y_top + offset) - px.shape[0]) + if labels: + draw_text(caption, caption_size, x, y_top + cell_h + int(caption_size * 0.2), cell_w) + return canvas + + +# -- turntables --------------------------------------------------------------- + +TURNTABLE_CAM = "MV_Turntable_Cam" + + +def build_turntable_camera(context): + """A perspective camera that keeps every product in frame at every angle. + + Each product spins about its own centre, so the framing is a cylinder + around the batch centre wide enough for the product that reaches furthest + from it, measured from all sides at once. + """ + scene = context.scene + s = scene.mv_settings + boxes = [] + for pc in get_products(): + objs = collection_meshes(pc) + if objs: + boxes.append(bbox_of(objs)) + if not boxes: + return None + lo = Vector([min(c[i] - sz[i] / 2 for c, sz in boxes) for i in range(3)]) + hi = Vector([max(c[i] + sz[i] / 2 for c, sz in boxes) for i in range(3)]) + center = (lo + hi) * 0.5 + radius = 0.0 + for c, sz in boxes: + reach = Vector((c.x - center.x, c.y - center.y)).length + radius = max(radius, reach + 0.5 * Vector((sz.x, sz.y)).length) + steps = 24 + # A polygon through the circle's points would cut inside it between them. + radius = max(radius, 1e-3) / math.cos(math.pi / steps) + corners = [center + Vector((radius * math.cos(a), radius * math.sin(a), z - center.z)) + for a in (2 * math.pi * k / steps for k in range(steps)) + for z in (lo.z, hi.z)] + + elevation = s.turntable_elevation + d = Vector(rotate_dir_z((0.0, -math.cos(elevation), math.sin(elevation)), + FRONT_AXIS_ANGLES[s.front_axis])).normalized() + _, _, depth, local = view_extents(look_at(center + d, center), center, corners) + rx, ry = image_aspect(scene) + tan_x, tan_y = sensor_tangents(rx, ry) + pad = max(s.frame_padding_factor, 1.0) + diag = max((hi - lo).length * 0.5, 1e-3) + distance = max(persp_distance(local, tan_x, tan_y, pad), depth + diag * 0.1) + + data = bpy.data.cameras.new(TURNTABLE_CAM) + data.lens = PERSP_LENS + data.clip_start = max(distance * 0.01, 1e-5) + data.clip_end = (distance + diag) * 100.0 + cam = bpy.data.objects.new(TURNTABLE_CAM, data) + cam.matrix_world = look_at(center + d * distance, center) + # Linked to the scene, not MV_Cameras, so it is never mistaken for a view. + scene.collection.objects.link(cam) + return cam + + +def remove_object(obj): + data = obj.data + bpy.data.objects.remove(obj, do_unlink=True) + if isinstance(data, bpy.types.Camera) and not data.users: + bpy.data.cameras.remove(data) + + +def encode_mp4(frames, out_path, fps, background, tmp_dir): + """H.264 from a list of frame files, through a throwaway sequencer scene. + + Frames are flattened onto the background first, because H.264 has no alpha + and the sequencer would otherwise put a transparent render on black, and + cropped to even dimensions, which the codec requires. + """ + work = tempfile.mkdtemp(prefix="mv_mp4_", dir=tmp_dir) + names = [] + width = height = None + try: + for i, frame in enumerate(frames): + px = flatten(load_pixels(frame), background) + if width is None: + height, width = px.shape[:2] + width -= width % 2 + height -= height % 2 + name = f"frame_{i + 1:04d}.png" + save_pixels(px[:height, :width], os.path.join(work, name), 'PNG') + names.append(name) + + scene = bpy.data.scenes.new("MV_Encode") + try: + r = scene.render + r.resolution_x, r.resolution_y = width, height + r.resolution_percentage = 100 + r.pixel_aspect_x = r.pixel_aspect_y = 1.0 + r.fps, r.fps_base = int(fps), 1.0 + scene.frame_start, scene.frame_end = 1, len(names) + editor = scene.sequence_editor_create() + # Blender 4.4 renamed sequences to strips. Not getattr(...) or: + # a new editor's strips is empty, which reads as false. + strips = editor.strips if hasattr(editor, "strips") else editor.sequences + strip = strips.new_image(name="MV_Frames", filepath=os.path.join(work, names[0]), + channel=1, frame_start=1) + for name in names[1:]: + strip.elements.append(name) + image = r.image_settings + if hasattr(image, "media_type"): + image.media_type = 'VIDEO' + image.file_format = 'FFMPEG' + r.ffmpeg.format = 'MPEG4' + r.ffmpeg.codec = 'H264' + r.ffmpeg.constant_rate_factor = 'HIGH' + # The frames are display values already; a second view transform + # on the way out would wash them out. + scene.view_settings.view_transform = 'Standard' + scene.view_settings.look = 'None' + r.use_sequencer = True + r.use_compositing = False + r.filepath = os.path.join(work, "turntable") + # Blender 5 takes the range from the operator, and left unset it + # reads the window's scene: 250 frames, 242 of them black. + kwargs = {} + props = bpy.ops.render.render.get_rna_type().properties + if "frame_start" in props: + kwargs = {"frame_start": 1, "frame_end": len(names)} + bpy.ops.render.render(animation=True, scene=scene.name, **kwargs) + finally: + bpy.data.scenes.remove(scene) + + produced = [n for n in os.listdir(work) if n.lower().endswith(".mp4")] + if not produced: + raise RuntimeError("Blender's FFmpeg wrote no video") + os.replace(os.path.join(work, produced[0]), out_path) + finally: + shutil.rmtree(work, ignore_errors=True) + return width, height + + +def gif_lzw(data): + """GIF's variable-width LZW over 8-bit palette indices, packed into the + 255-byte sub-blocks the format wants.""" + clear, end = 256, 257 + out = bytearray() + buf = nbits = 0 + size = 9 + table = {} + next_code = 258 + + buf |= clear << nbits + nbits += size + it = iter(data) + prefix = next(it) + for byte in it: + key = (prefix << 8) | byte + code = table.get(key) + if code is not None: + prefix = code + continue + buf |= prefix << nbits + nbits += size + while nbits >= 8: + out.append(buf & 0xFF) + buf >>= 8 + nbits -= 8 + if next_code < 4096: + table[key] = next_code + next_code += 1 + if next_code > (1 << size) and size < 12: + size += 1 + else: + # Table full: start over rather than coding on with a stale one. + buf |= clear << nbits + nbits += size + table.clear() + next_code = 258 + size = 9 + prefix = byte + for code in (prefix, end): + buf |= code << nbits + nbits += size + while nbits >= 8: + out.append(buf & 0xFF) + buf >>= 8 + nbits -= 8 + if nbits: + out.append(buf & 0xFF) + + blocks = bytearray() + for i in range(0, len(out), 255): + chunk = out[i:i + 255] + blocks.append(len(chunk)) + blocks += chunk + blocks.append(0) + return bytes(blocks) + + +def encode_gif(frames, out_path, fps, max_size, background): + """Looping GIF from frame files. + + One palette for the whole animation, from the colours used most across + every frame, so the background does not shimmer from frame to frame the + way per-frame palettes make it. + """ + images = [] + for frame in frames: + px = flatten(load_pixels(frame), background) + if max_size and max(px.shape[:2]) > max_size: + px = fit_long_edge(px, max_size) + # GIF stores rows top down. + images.append((px[::-1, :, :3] * 255 + 0.5).astype(np.uint8)) + height, width = images[0].shape[:2] + + # 6 bits a channel keeps 64 levels of grey for a clay render's shading. + keys = [] + for rgb in images: + q = (rgb >> 2).astype(np.uint32) + keys.append((q[..., 0] << 12) | (q[..., 1] << 6) | q[..., 2]) + used, counts = np.unique(np.concatenate([k.ravel() for k in keys]), return_counts=True) + top = used[np.argsort(counts)[::-1][:256]] + + def expand(k): + return np.stack([(k >> 12) & 63, (k >> 6) & 63, k & 63], axis=-1).astype(np.int32) * 4 + 2 + + palette = expand(top) + lut = np.empty(len(used), np.uint8) + colours = expand(used) + for i in range(0, len(used), 4096): + dist = ((colours[i:i + 4096, None, :] - palette[None, :, :]) ** 2).sum(axis=-1) + lut[i:i + 4096] = dist.argmin(axis=1) + + table = np.zeros((256, 3), np.uint8) + table[:len(palette)] = np.clip(palette, 0, 255) + out = bytearray(b"GIF89a") + out += struct.pack("