Getting ready for deploy
This commit is contained in:
+31
-1
@@ -1 +1,31 @@
|
||||
knot_animation/__pycache__
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# Blender auto-saves and temp files
|
||||
*.blend1
|
||||
*.blend2
|
||||
*.blend@
|
||||
*.bak
|
||||
/output/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Pr3tz are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Minimum Blender version raised to 5.0** — `bl_info["blender"]` updated to `(5, 0, 0)`.
|
||||
Blender 4.x is no longer supported. Drops all legacy compatibility shims:
|
||||
- `_get_action_fcurves` now uses the Blender 5 layered Action API only (no `action.fcurves` fallback).
|
||||
- Principled BSDF shaders use Blender 5 canonical input names directly:
|
||||
`"Specular IOR Level"`, `"Transmission Weight"`, `"Coat Weight"`, `"Coat Roughness"`.
|
||||
- Removed `"Specular"` / `"Clearcoat"` / `"Transmission"` key-in-inputs guards.
|
||||
|
||||
|
||||
## [2.0.0] — Current
|
||||
|
||||
### Added
|
||||
- Full modular rewrite: `knot_animation.py` split into 10 focused modules under `knot_animation/`
|
||||
- 20 built-in shader presets (up from 7 in 1.x)
|
||||
- Per-knot material overrides — color, roughness, metallic, emission strength
|
||||
- Per-knot animation rates: spin phase, revolution phase, height pulse, scale oscillation
|
||||
- Smooth geometry transitions with Linear / Quadratic / Smoothstep easing
|
||||
- Random playlist generator with configurable min/max ranges for every parameter
|
||||
- Bake & Export operator with split-file support for distributed render farms (SheepIt)
|
||||
- Blender 5.0 layered-action API support (`_get_action_fcurves` compatibility helper)
|
||||
- `FitTimeline` and `FitPlaylist` operators
|
||||
- Global Reactivity factor for audio-reactive animation
|
||||
- `preview_resolution` / `render_resolution` split for fast viewport + quality renders
|
||||
- Camera shake baked to `delta_location` keyframes during export
|
||||
- Headless `align_matrix` monkey-patch for crash-free command-line rendering
|
||||
|
||||
### Changed
|
||||
- Handler no longer uses `bpy.ops` or reads `bpy.context.space_data` — safe in all execution contexts
|
||||
- Material cache keyed by preset ID — no rebuilds on every frame
|
||||
|
||||
## [1.x] — Single-file era
|
||||
|
||||
- `knot_animation.py`: procedural torus knot animation with hardcoded 10-knot config list
|
||||
- 7 shader presets
|
||||
- No playlist editor, no transitions, no export
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 knot_animation project contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Pr3tz — Procedural Torus-Knot Animation for Blender
|
||||
|
||||
A Blender add-on that generates a continuously morphing parade of procedural [torus knots](https://en.wikipedia.org/wiki/Torus_knot) with configurable shaders, geometry transitions, and render-farm export — all driven by a non-destructive playlist inside Blender's N-panel.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **20 built-in shader presets** — Gloss Blue, Neon Glow, Metallic, Glass, Hologram, Lava, Iridescent, Matte Clay, Ghost, Car Paint, Chrome, Ruby Glass, Frosted Ice, Lichen Rough, Copper Patina, Zebra Stripes, Wood Veneer, Carbon Fiber, Pearlescent, Plasma Glow
|
||||
- **Playlist-based animation** — add, remove, reorder any number of knots; each entry controls its own topology, geometry, material, and animation rates
|
||||
- **Smooth transitions** — configurable per-knot morph windows with Linear, Quadratic In/Out, or Smoothstep easing
|
||||
- **Audio-reactive hooks** — drive the global Reactivity factor from a sound driver to pulse spin, height, and scale rates in sync with audio
|
||||
- **Random playlist generator** — configure min/max ranges for every parameter and generate an entire playlist in one click
|
||||
- **Bake & Export** — bake the procedural animation to a standalone `.blend` with per-frame mesh visibility keyframes; no addon required at render time; supports split-file export for SheepIt / distributed render farms
|
||||
- **Blender 5.0+ native** — uses the layered Action API (`action → layers → strips → channelbag → fcurves`) and Blender 5 Principled BSDF input names directly
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
| Requirement | Version |
|
||||
|---|---|
|
||||
| Blender | **5.0** or later |
|
||||
| Blender built-in addon | **Add Curve: Extra Objects** (auto-enabled by the script) |
|
||||
|
||||
> **Note:** The single-file version (`knot_animation.py`) has no installation step — just open and run in the Text Editor.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Option A — Persistent Add-on (recommended)
|
||||
|
||||
1. Download or clone this repository.
|
||||
2. In Blender, go to **Edit → Preferences → Add-ons → Install…**
|
||||
3. Select the `knot_animation/` **folder** (zip it first if your Blender version requires a zip).
|
||||
4. Enable **"Pr3tz"** from the add-on list.
|
||||
5. Open the **3D Viewport → N-panel → Pr3tz** tab.
|
||||
|
||||
### Option B — Run as a Script (no installation)
|
||||
|
||||
```
|
||||
# From inside Blender's Text Editor:
|
||||
# File → Open → knot_animation/__init__.py → Run Script (Alt+P)
|
||||
|
||||
# Or from the command line:
|
||||
"C:\Program Files\Blender Foundation\Blender 5.0\blender.exe" --python knot_animation/__init__.py
|
||||
```
|
||||
|
||||
### Option C — Single-file version
|
||||
|
||||
`knot_animation_single_file.py` is a self-contained copy of the entire add-on in one file, useful for quick testing without touching Blender's add-on directory.
|
||||
|
||||
```
|
||||
blender.exe --python knot_animation_single_file.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install and enable the add-on (see above).
|
||||
2. In the N-panel, click **"Populate Default List"** to load 10 example knots.
|
||||
3. Press **Space** to play the animation and watch the knots morph.
|
||||
4. Select any knot in the playlist to tweak its topology, shader, and animation rates in the panel below.
|
||||
5. Use **"Generate Playlist"** with the randomiser settings to create entirely new playlists procedurally.
|
||||
|
||||
---
|
||||
|
||||
## Baking for Render Farms
|
||||
|
||||
The **Bake & Export** button (bottom of the Pr3tz panel) converts the procedural animation into a standalone `.blend`:
|
||||
|
||||
1. Set your **frame range** in the Timeline.
|
||||
2. Click **Bake & Export for Render Farm** and choose an output path.
|
||||
3. Optional: enable **Split Export** and set **Frames Per File** for multi-chunk jobs.
|
||||
4. Submit the resulting `.blend` file(s) to your render farm — no addon needed on the farm.
|
||||
|
||||
---
|
||||
|
||||
## Module Layout
|
||||
|
||||
```
|
||||
knot_animation/
|
||||
├── __init__.py # Add-on entry point, bl_info, register/unregister
|
||||
├── constants.py # Default playlist configs, camera/light positions, name tags
|
||||
├── types.py # KnotConfig TypedDict (shared data contract)
|
||||
├── compat.py # Headless align_matrix fix and other Blender compat patches
|
||||
├── properties.py # Blender PropertyGroups (KnotItem, KnotGlobalSettings, …)
|
||||
├── operators.py # KNOT_OT_* operators (Add, Remove, Move, Generate, Fit…)
|
||||
├── ui.py # N-panel, UILists, draw_knot_properties()
|
||||
├── geometry.py # _make_torus_knot() — pure parametric NURBS, no bpy.context reads
|
||||
├── materials.py # 20 shader builders, material cache, blend/transition system
|
||||
├── handler.py # @persistent frame_change_post handler, easing, playlist timing
|
||||
├── scene_setup.py # setup_scene() — camera, area light, world, render settings
|
||||
└── bake_export.py # KNOT_OT_BakeExport — bake to standalone .blend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Parameters (KnotItem)
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| **p / q** | Torus knot winding numbers — must be coprime for a true knot |
|
||||
| **Major / Minor radius** | Size of the torus; switch to Ext/Int mode for exterior/interior control |
|
||||
| **Bevel Depth** | Tube thickness of the curve |
|
||||
| **Multiple Links** | Render all p link components separately |
|
||||
| **Transition Frames** | Number of frames to morph into the next knot |
|
||||
| **Cycle Rate** | Per-knot speed multiplier (>1 lingers, <1 advances faster) |
|
||||
| **Spin / Rev Phase Rate** | Animate tube or orbital rotation over time |
|
||||
| **Height Pulse Rate** | Oscillate the torus height — creates a breathing warp effect |
|
||||
| **Scale Rate / Amplitude** | Per-knot scale oscillation frequency and amplitude |
|
||||
| **Shader Preset** | One of 20 built-in material presets |
|
||||
| **Project Material** | Use any material already in the .blend file |
|
||||
|
||||
---
|
||||
|
||||
## Global Settings
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| **Frames per Knot** | Base display duration for each playlist entry |
|
||||
| **Curve Resolution** | NURBS subdivision — higher = smoother curve |
|
||||
| **Bevel Resolution** | Tube cross-section segments |
|
||||
| **Global Scale** | Uniform scale applied to all knots |
|
||||
| **Global Speed** | Multiplies the effective frame counter — keyframeable |
|
||||
| **Animation Phase** | Frame offset before all calculations — keyframeable |
|
||||
| **Reactivity** | Scales all per-knot animation rates; wire a driver here for audio-reactive animation |
|
||||
|
||||
---
|
||||
|
||||
## Shader Presets Reference
|
||||
|
||||
| ID | Description |
|
||||
|---|---|
|
||||
| `GLOSS_BLUE` | Classic glossy blue dielectric |
|
||||
| `NEON_GLOW` | Emissive neon with bloom-ready strength |
|
||||
| `METALLIC` | Polished gold / copper metal |
|
||||
| `GLASS` | Refractive clear glass |
|
||||
| `HOLOGRAM` | Sci-fi translucent hologram |
|
||||
| `LAVA` | Subsurface-scattered lava with emission |
|
||||
| `IRIDESCENT` | Rainbow thin-film interference |
|
||||
| `MATTE_CLAY` | Diffuse clay for concept previews |
|
||||
| `GHOST` | Semi-transparent emission ghost |
|
||||
| `CAR_PAINT` | Pearlescent automotive clearcoat |
|
||||
| `CHROME` | Mirror-finish chrome |
|
||||
| `RUBY_GLASS` | Deep red refractive glass |
|
||||
| `FROSTED_ICE` | Rough refractive ice |
|
||||
| `LICHEN_ROUGH` | Organic rough surface |
|
||||
| `COPPER_PATINA` | Oxidised copper green |
|
||||
| `ZEBRA_STRIPES` | Procedural stripe pattern |
|
||||
| `WOOD_VENEER` | Procedural wood grain |
|
||||
| `CARBON_FIBER` | Woven carbon fiber weave |
|
||||
| `PEARLESCENT` | Angle-dependent pearl sheen |
|
||||
| `PLASMA_GLOW` | High-energy plasma emission |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome. For major changes please open an issue first.
|
||||
|
||||
### Development setup
|
||||
|
||||
```powershell
|
||||
# Clone
|
||||
git clone https://github.com/YOUR_USERNAME/animknots.git
|
||||
|
||||
# Run the syntax checker (no Blender needed)
|
||||
python check_syntax.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Pr3tz — Architecture Overview
|
||||
|
||||
This document describes the internal design of the add-on for contributors and developers who want to extend or debug it.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
```
|
||||
Blender Timeline scrub / playback
|
||||
│
|
||||
▼
|
||||
frame_change_post handler ── handler.py :: knot_frame_handler()
|
||||
│
|
||||
├── compute playlist position (which knot is active, transition blend weight)
|
||||
│
|
||||
├── geometry.py :: _make_torus_knot(config)
|
||||
│ └── writes NURBS control points into the shared "AnimKnot" object
|
||||
│
|
||||
└── materials.py :: apply shader / blend material to "AnimKnot"
|
||||
```
|
||||
|
||||
**Key design choice:** The handler never calls `bpy.ops` and never touches `bpy.context.space_data`. This makes it safe to run headlessly (command-line renders, bake loops) without crashes.
|
||||
|
||||
---
|
||||
|
||||
## Module Descriptions
|
||||
|
||||
### `compat.py`
|
||||
Applied once at addon load. Monkey-patches `align_matrix` in `add_curve_torus_knots` so it doesn't crash when there is no `space_data` (headless / baked renders).
|
||||
|
||||
### `constants.py`
|
||||
Pure Python — no `bpy` imports. Contains:
|
||||
- `KNOT_CONFIGS` — the default 10-entry demo playlist.
|
||||
- Scene defaults (camera position, light energy).
|
||||
- String constants for well-known object/material names.
|
||||
|
||||
### `types.py`
|
||||
A `TypedDict` called `KnotConfig` that documents every key the geometry and materials systems expect. Used for type-checking; not enforced at runtime.
|
||||
|
||||
### `properties.py`
|
||||
All Blender `PropertyGroup` subclasses:
|
||||
- `KnotAllowedMaterial` — an item in the generator's allowed-material list.
|
||||
- `KnotGlobalSettings` — scene-level animation controls.
|
||||
- `KnotItem` — one entry in the knot playlist. Includes `to_dict()` to serialize to a plain Python dict.
|
||||
- `KnotGeneratorSettings` — the random playlist generator's configuration.
|
||||
|
||||
### `geometry.py`
|
||||
`_make_torus_knot(config)` — the core parametric geometry function.
|
||||
- Accepts a `KnotConfig` dict.
|
||||
- Finds or creates the `AnimKnot` NURBS curve object.
|
||||
- Writes control points directly into `curve.splines[0].points`, bypassing `bpy.ops.curve.torus_knot_plus` for headless safety.
|
||||
- Returns immediately if the config would produce a degenerate knot.
|
||||
|
||||
### `materials.py`
|
||||
- 20 shader builder functions, each returning a `bpy.types.Material`.
|
||||
- A material **cache** (`KnotShader_<id>`) avoids rebuilding identical materials every frame.
|
||||
- **Preset materials per KnotItem** (`KnotItem_Preset_<uid>`) allow per-item color/roughness overrides without polluting the global cache.
|
||||
- **Blend materials** (`KnotBlend_<uid_a>_<uid_b>`) mix two adjacent presets during transition windows using a `MixShader` node driven by a `Value` node that the handler updates each frame.
|
||||
- `prewarm_materials_and_blends(scene)` — builds all materials upfront so the first frame has no stutter.
|
||||
- `prebuild_playlist_blend_materials(scene)` — called whenever the playlist changes (add/remove/reorder) to keep blend materials in sync.
|
||||
|
||||
### `handler.py`
|
||||
`knot_frame_handler(scene)` — the `@persistent` callback registered to `frame_change_post`.
|
||||
|
||||
Timeline math:
|
||||
```
|
||||
effective_frame = (current_frame × global_speed) + animation_phase
|
||||
|
||||
for each knot_i in playlist:
|
||||
duration_i = frames_per_knot × cycle_rate_i
|
||||
cumulative_start_i = sum(duration_j for j < i) + transition_frames_i/2
|
||||
|
||||
active_knot, blend_weight = resolve(effective_frame, playlist)
|
||||
```
|
||||
|
||||
During a transition window the handler interpolates geometry config dicts and sets the blend material's mix factor.
|
||||
|
||||
### `operators.py`
|
||||
All `KNOT_OT_*` operator classes:
|
||||
- `Add / Remove / Move` — playlist CRUD.
|
||||
- `Populate` — loads `KNOT_CONFIGS` into the playlist.
|
||||
- `BakePreview` — runs `_make_torus_knot` once for the selected item (useful while scrubbing).
|
||||
- `SyncGeneratorMaterials` — rebuilds the generator's allowed-materials list from live `bpy.data.materials`.
|
||||
- `GenerateRandom` — fills the playlist with randomly configured knots.
|
||||
- `FitTimeline / FitPlaylist` — synchronise frame range and `frames_per_knot`.
|
||||
|
||||
### `ui.py`
|
||||
- `KNOT_UL_List` — the playlist UIList.
|
||||
- `KNOT_UL_AllowedMaterialsList` — the generator's material filter UIList.
|
||||
- `draw_knot_properties(layout, item)` — renders the full KnotItem editor (topology, animation, material).
|
||||
- `KNOT_PT_Panel` — the root N-panel.
|
||||
|
||||
### `scene_setup.py`
|
||||
`setup_scene()` — called once when the add-on runs as a script. Creates or repositions the camera, area light, and sets sensible render defaults (Cycles, HDRI world, denoising).
|
||||
|
||||
### `bake_export.py`
|
||||
`KNOT_OT_BakeExport` — seven-phase bake operator. See [PARAMETERS.md](PARAMETERS.md#bake--export-options) for user-facing options.
|
||||
|
||||
`_get_action_fcurves(obj)` — walks the Blender 5 layered Action tree
|
||||
(`action → layers → strips → channelbag(slot) → fcurves`) to return an
|
||||
iterable of FCurves without touching the removed `action.fcurves` attribute.
|
||||
|
||||
Phases:
|
||||
1. **Bake geometry** — iterate every frame, convert NURBS → mesh, fingerprint for deduplication.
|
||||
2. **Visibility keyframes** — insert `hide_render` / `hide_viewport` with CONSTANT interpolation.
|
||||
3. **Camera shake bake** — convert procedural camera shake to explicit `delta_location` keyframes.
|
||||
4. **Cleanup** — remove the live `AnimKnot` NURBS object.
|
||||
5. **Pack** — make paths relative, pack external textures, purge zero-user data blocks (first chunk only).
|
||||
6. **Save copy** — `wm.save_as_mainfile(copy=True, compress=True)`.
|
||||
7. **Restore session** — remove all baked objects, re-register the handler, trigger one handler call to rebuild the live knot.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```
|
||||
Scene properties (knot_list, knot_globals)
|
||||
│
|
||||
│ read by
|
||||
▼
|
||||
handler.py ──────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ _make_torus_knot(config) apply_material(knot_obj, material) │
|
||||
▼ ▼ │
|
||||
geometry.py materials.py │
|
||||
│ │ │
|
||||
└── writes into ──► "AnimKnot" ◄───┘ │
|
||||
(NURBS curve object in scene) │
|
||||
│
|
||||
bake_export.py ◄──────────────────────────────────────┘
|
||||
(iterates frames, calls frame_set which triggers handler,
|
||||
then converts knot_obj to mesh)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Shader Preset
|
||||
|
||||
1. Open `materials.py`.
|
||||
2. Add a builder function following the pattern of existing builders (e.g. `_build_gloss_blue`). The function receives `color`, `roughness`, `metallic`, `emission_strength` keyword arguments.
|
||||
3. Add the new ID string to `SHADER_IDS` in `constants.py` **and** `materials.py`'s dispatch dict.
|
||||
4. The EnumProperty in `KnotItem` is auto-generated from `SHADER_IDS`, so the UI will pick it up automatically.
|
||||
|
||||
## Adding a New KnotItem Property
|
||||
|
||||
1. Add the `bpy.props.*` declaration to `KnotItem` in `properties.py`.
|
||||
2. Add the corresponding key to `KnotItem.to_dict()`.
|
||||
3. If the property should affect geometry, read it in `geometry.py :: _make_torus_knot()`.
|
||||
4. If it should be randomisable, add `r_<name>`, `min_<name>`, `max_<name>` entries to `KnotGeneratorSettings` in `properties.py` and wire them in `operators.py :: KNOT_OT_GenerateRandom.execute()`.
|
||||
5. Add UI for it in `ui.py :: draw_knot_properties()`.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Pr3tz — Full Parameter Reference
|
||||
|
||||
This document describes every configurable parameter available in the Pr3tz add-on.
|
||||
|
||||
---
|
||||
|
||||
## Global Settings (`KnotGlobalSettings`)
|
||||
|
||||
Accessed via the **"Global Settings"** section at the top of the Pr3tz panel.
|
||||
These settings apply to the entire animation.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `frames_per_knot` | int | 12 | Base display duration (in frames) for each playlist entry. Each knot's effective duration is `frames_per_knot × cycle_rate`. |
|
||||
| `resolution` | int | 128 | NURBS curve subdivision — controls how smooth the knot path is. Range: 3–1024. |
|
||||
| `bevel_resolution` | int | 8 | Number of sides on the tube cross-section. Range: 0–64. |
|
||||
| `knot_scale` | float | 1.0 | Uniform scale multiplier applied to all knots. |
|
||||
| `global_speed` | float | 1.0 | Multiplies the effective frame counter for the entire playlist. Keyframeable. |
|
||||
| `animation_phase` | float | 0.0 | Frame offset added before all calculations. Keyframe or drive for reactive control. |
|
||||
| `reactivity_factor` | float | 1.0 | Scales all per-knot animation rates (spin, revolution, height, scale). Wire a driver to an audio amplitude for audio-reactive animation. Range: 0–10. |
|
||||
|
||||
---
|
||||
|
||||
## Per-Knot Settings (`KnotItem`)
|
||||
|
||||
Each entry in the playlist has its own complete set of parameters.
|
||||
|
||||
### Topology
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `torus_p` | int | 2 | Number of revolutions around the torus axis. With `q`, defines the knot type. Must be coprime with `q` for a true knot. |
|
||||
| `torus_q` | int | 3 | Number of spins around the torus tube. Must be coprime with `p`. |
|
||||
| `flip_p` | bool | False | Reverse the direction of the p (revolution) component. |
|
||||
| `flip_q` | bool | False | Reverse the direction of the q (spin) component. |
|
||||
| `multiple_links` | bool | False | Render all `gcd(p, q)` link components as separate curves. |
|
||||
| `torus_u` | int | 1 | Revolution multiplier — repeats the revolution pattern. |
|
||||
| `torus_v` | int | 1 | Spin multiplier — repeats the spin pattern. |
|
||||
| `torus_rP` | float | 0.0 | Revolution phase offset (orbit rotation). |
|
||||
| `torus_sP` | float | 0.0 | Spin phase offset (tube rotation). |
|
||||
|
||||
### Dimensions — Major/Minor Mode
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `mode` | enum | `MAJOR_MINOR` | Choose `MAJOR_MINOR` or `EXT_INT` dimension mode. |
|
||||
| `torus_R` | float | 2.0 | Major radius — distance from centre of torus to centre of tube. |
|
||||
| `torus_r` | float | 1.0 | Minor radius — radius of the tube itself. |
|
||||
| `torus_h` | float | 1.0 | Height scaling of the torus. Values > 1 stretch the knot vertically. |
|
||||
|
||||
### Dimensions — Exterior/Interior Mode
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `torus_eR` | float | 3.0 | Exterior radius (outer edge of the torus). |
|
||||
| `torus_iR` | float | 1.0 | Interior radius (inner edge / hole radius). |
|
||||
|
||||
### Geometry
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `geo_bDepth` | float | 0.04 | Bevel depth — tube thickness. |
|
||||
| `geo_extrude` | float | 0.0 | Extrude the curve profile outward. Creates a ribbon effect when combined with `geo_offset`. |
|
||||
| `geo_offset` | float | 0.0 | Offset the extruded profile from the curve centreline. |
|
||||
|
||||
### Animation Rates
|
||||
|
||||
All rate properties are **scaled by `reactivity_factor`** before being applied.
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `cycle_rate` | float | 1.0 | Per-knot speed multiplier. `> 1` makes this knot linger longer; `< 1` advances it faster. |
|
||||
| `spin_phase_rate` | float | 0.0 | Rate of change of spin phase (tube rotation) per frame. |
|
||||
| `rev_phase_rate` | float | 0.0 | Rate of change of revolution phase (orbit rotation) per frame. |
|
||||
| `height_rate` | float | 0.0 | Oscillation frequency of torus height. Creates a breathing / pulsing warp. |
|
||||
| `scale_rate` | float | 0.0 | Frequency of per-knot scale oscillation. |
|
||||
| `scale_amplitude` | float | 0.0 | Amplitude of per-knot scale oscillation (0 = no oscillation). |
|
||||
|
||||
### Transitions
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `transition_frames` | int | 0 | Number of frames to smoothly morph from the *previous* knot into this one. 0 = instant cut. |
|
||||
| `transition_easing` | enum | `QUAD_IN_OUT` | Interpolation curve for the morph: `LINEAR`, `QUAD_IN_OUT`, or `SMOOTHSTEP`. |
|
||||
|
||||
### Material
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `material_mode` | enum | `PRESET` | `PRESET` uses a built-in shader; `PROJECT` uses an existing material from the .blend file. |
|
||||
| `shader_id` | enum | `GLOSS_BLUE` | Which of the 20 built-in shader presets to use. |
|
||||
| `preset_color` | color | (0.2, 0.6, 1.0) | Base color tint passed into the selected shader preset. |
|
||||
| `preset_roughness` | float | 0.1 | Roughness override for the preset. |
|
||||
| `preset_metallic` | float | 0.0 | Metallic override for the preset. |
|
||||
| `preset_emission_strength` | float | 1.0 | Emission strength override for presets that emit light. Range: 0–100. |
|
||||
| `project_material` | Material | — | A material data-block from the current .blend. Active when `material_mode = PROJECT`. |
|
||||
|
||||
---
|
||||
|
||||
## Fit Timeline / Fit Playlist Operators
|
||||
|
||||
| Operator | Description |
|
||||
|---|---|
|
||||
| **Fit Timeline to Playlist** | Extends `frame_end` so the full playlist plays exactly once, accounting for each knot's `cycle_rate`. |
|
||||
| **Fit Playlist to Timeline** | Adjusts `frames_per_knot` so the playlist fills the current `frame_end` exactly. |
|
||||
|
||||
---
|
||||
|
||||
## Bake & Export Options (`KNOT_OT_BakeExport`)
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| **Use Render Resolution** | True | Temporarily sets preview resolution to match render resolution during baking for highest-quality mesh output. |
|
||||
| **Pack Textures** | True | Packs all external textures into the exported `.blend` file. |
|
||||
| **Split Export** | False | Splits the baked animation into multiple `.blend` files. |
|
||||
| **Frames Per File** | 500 | When split export is on, the number of frames included in each file. |
|
||||
|
||||
The bake operator:
|
||||
1. Iterates every frame in `frame_start`–`frame_end`.
|
||||
2. Converts the procedural NURBS curve to a mesh via `new_from_object`.
|
||||
3. Uses a MD5 fingerprint to skip duplicate frames (extends the previous object's visibility window instead).
|
||||
4. Inserts `hide_render` / `hide_viewport` keyframes with CONSTANT interpolation so only the correct mesh shows each frame.
|
||||
5. Optionally bakes camera shake to `delta_location` keyframes.
|
||||
6. Saves a copy of the file, then restores the session completely.
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
knot_animation/__init__.py
|
||||
--------------------------
|
||||
Blender add-on entry-point for the AnimKnots procedural animation system.
|
||||
Blender add-on entry-point for the Pr3tz procedural animation system.
|
||||
|
||||
What this add-on does
|
||||
---------------------
|
||||
1. Registers a VIEW_3D N-panel ("AnimKnots") with:
|
||||
1. Registers a VIEW_3D N-panel ("Pr3tz") with:
|
||||
- A per-scene playlist of KnotItem entries, each specifying topology,
|
||||
geometry, animation rates, transition settings, and a material preset.
|
||||
- Operators for adding, removing, reordering, and randomly generating
|
||||
@@ -40,6 +40,8 @@ scene_setup.py — setup_scene() (camera, light, world, render settings)
|
||||
|
||||
How to use
|
||||
----------
|
||||
Requires Blender 5.0 or later.
|
||||
|
||||
Option A – Blender Text Editor:
|
||||
Open knot_animation/__init__.py, click Run Script (Alt+P).
|
||||
|
||||
@@ -49,7 +51,7 @@ Option B – Command line:
|
||||
Option C – Persistent add-on:
|
||||
Copy the knot_animation/ folder to Blender's addons directory and enable
|
||||
it from Edit → Preferences → Add-ons. The panel appears under the
|
||||
"AnimKnots" tab in the 3-D viewport N-panel.
|
||||
"Pr3tz" tab in the 3-D viewport N-panel.
|
||||
"""
|
||||
|
||||
if "bpy" in locals():
|
||||
@@ -107,11 +109,11 @@ from .constants import KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bl_info = {
|
||||
"name": "AnimKnots",
|
||||
"name": "Pr3tz",
|
||||
"author": "knot_animation project",
|
||||
"version": (2, 0, 0),
|
||||
"blender": (4, 0, 0),
|
||||
"location": "View3D > Sidebar > AnimKnots",
|
||||
"blender": (5, 0, 0),
|
||||
"location": "View3D > Sidebar > Pr3tz",
|
||||
"description": "Procedural torus-knot animation with playlist, transitions, and 20 shader presets",
|
||||
"category": "Animation",
|
||||
}
|
||||
|
||||
+346
-299
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
bake_export.py
|
||||
--------------
|
||||
Operator to bake the AnimKnots procedural animation into a fully
|
||||
Operator to bake the Pr3tz procedural animation into a fully
|
||||
self-contained .blend file suitable for distributed render farms
|
||||
(SheepIt, etc.).
|
||||
|
||||
@@ -26,42 +26,31 @@ from .constants import KNOT_OBJ_NAME
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blender 5.0 compat — Action.fcurves was replaced with layered actions
|
||||
# Blender 5.0 — layered Action API (Action.fcurves removed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_action_fcurves(obj):
|
||||
"""Return an iterable of FCurves for the given object's animation.
|
||||
"""Return an iterable of FCurves for the given object's active action slot.
|
||||
|
||||
Blender 5.0 replaced ``action.fcurves`` with a layered animation
|
||||
system (slots → layers → strips → channelbags → fcurves). This
|
||||
helper abstracts both APIs so the rest of the module doesn't care.
|
||||
Blender 5.0 uses a layered animation system:
|
||||
action → layers → strips → channelbag (per slot) → fcurves
|
||||
"""
|
||||
anim = obj.animation_data
|
||||
if not anim or not anim.action:
|
||||
return []
|
||||
|
||||
action = anim.action
|
||||
|
||||
# ── Blender 5.0+ (layered actions) ────────────────────────────────────
|
||||
# Try the convenience helper first
|
||||
try:
|
||||
from bpy_extras.anim_utils import action_get_channelbag_for_slot # noqa: F401
|
||||
slot = anim.action_slot
|
||||
if slot is not None:
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
cb = strip.channelbag(slot)
|
||||
if cb is not None:
|
||||
return cb.fcurves
|
||||
slot = anim.action_slot
|
||||
if slot is None:
|
||||
return []
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
# ── Blender 4.x (legacy) ──────────────────────────────────────────────
|
||||
try:
|
||||
return action.fcurves
|
||||
except AttributeError:
|
||||
return []
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
cb = strip.channelbag(slot)
|
||||
if cb is not None:
|
||||
return cb.fcurves
|
||||
return []
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,240 +136,20 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
description="Pack all external textures into the .blend file",
|
||||
default=True,
|
||||
)
|
||||
chunk_frames: bpy.props.IntProperty(
|
||||
name="Frames per File",
|
||||
description=(
|
||||
"Split the export into multiple .blend files, each covering this many frames. "
|
||||
"Set to 0 for a single file (no splitting)"
|
||||
),
|
||||
default=0,
|
||||
min=0,
|
||||
max=10000,
|
||||
split_export: bpy.props.BoolProperty(
|
||||
name="Split Export",
|
||||
description="Split the baked animation into multiple .blend files",
|
||||
default=False,
|
||||
)
|
||||
frames_per_file: bpy.props.IntProperty(
|
||||
name="Frames Per File",
|
||||
description="Number of frames to include in each split file",
|
||||
default=500,
|
||||
min=1,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Chunk bake helper
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _bake_chunk(self, context, chunk_start, chunk_end, filepath):
|
||||
"""Bake a single frame range and save to *filepath*.
|
||||
|
||||
Creates mesh objects, sets up visibility keyframes, bakes camera
|
||||
shake, removes the AnimKnot, packs data, saves a copy, then
|
||||
cleans up all baked objects from the session so the next chunk
|
||||
starts fresh.
|
||||
|
||||
Returns the list of baked_objects so the caller can report stats.
|
||||
"""
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 1: Bake per-frame geometry
|
||||
# -----------------------------------------------------------
|
||||
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
|
||||
prev_fingerprint = None
|
||||
prev_transform_fp = None
|
||||
|
||||
bake_coll = bpy.data.collections.new("BakedAnimation")
|
||||
scene.collection.children.link(bake_coll)
|
||||
|
||||
for f in range(chunk_start, chunk_end + 1):
|
||||
scene.frame_set(f)
|
||||
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if not knot_obj:
|
||||
continue
|
||||
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_obj = knot_obj.evaluated_get(depsgraph)
|
||||
|
||||
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
|
||||
mesh.name = f"BakedKnot_Mesh_F{f}"
|
||||
|
||||
mesh_fp = _mesh_fingerprint(mesh)
|
||||
transform_fp = _obj_transform_fingerprint(knot_obj)
|
||||
|
||||
if (mesh_fp == prev_fingerprint
|
||||
and transform_fp == prev_transform_fp
|
||||
and baked_objects):
|
||||
bpy.data.meshes.remove(mesh)
|
||||
_, first_f, _ = baked_objects[-1]
|
||||
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
|
||||
continue
|
||||
|
||||
prev_fingerprint = mesh_fp
|
||||
prev_transform_fp = transform_fp
|
||||
|
||||
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
|
||||
obj.location = knot_obj.location.copy()
|
||||
obj.rotation_euler = knot_obj.rotation_euler.copy()
|
||||
obj.scale = knot_obj.scale.copy()
|
||||
|
||||
for src_slot in knot_obj.material_slots:
|
||||
obj.data.materials.append(src_slot.material)
|
||||
obj.display_type = knot_obj.display_type
|
||||
|
||||
bake_coll.objects.link(obj)
|
||||
baked_objects.append((obj, f, f))
|
||||
|
||||
if not baked_objects:
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
return []
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 2: Visibility keyframes
|
||||
# -----------------------------------------------------------
|
||||
for obj, first_f, last_f in baked_objects:
|
||||
if first_f > chunk_start:
|
||||
_keyframe_visibility(obj, chunk_start, False)
|
||||
if first_f > chunk_start + 1:
|
||||
_keyframe_visibility(obj, first_f - 1, False)
|
||||
|
||||
_keyframe_visibility(obj, first_f, True)
|
||||
if last_f != first_f:
|
||||
_keyframe_visibility(obj, last_f, True)
|
||||
|
||||
if last_f < chunk_end:
|
||||
_keyframe_visibility(obj, last_f + 1, False)
|
||||
if last_f < chunk_end - 1:
|
||||
_keyframe_visibility(obj, chunk_end, False)
|
||||
|
||||
_set_constant_interpolation(obj)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 3: Camera shake
|
||||
# -----------------------------------------------------------
|
||||
cam = scene.camera
|
||||
if cam and glob.camera_shake_amplitude > 0.0:
|
||||
shake = glob.camera_shake_amplitude
|
||||
for f in range(chunk_start, chunk_end + 1):
|
||||
random.seed(int(f * 1000))
|
||||
cam.delta_location = (
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
)
|
||||
cam.keyframe_insert(data_path="delta_location", frame=f)
|
||||
|
||||
for fc in _get_action_fcurves(cam):
|
||||
if fc.data_path == "delta_location":
|
||||
for kp in fc.keyframe_points:
|
||||
kp.interpolation = 'LINEAR'
|
||||
elif cam:
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 4: Remove AnimKnot + handler for clean save
|
||||
# -----------------------------------------------------------
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if knot_obj:
|
||||
curve_data = knot_obj.data
|
||||
bpy.data.objects.remove(knot_obj, do_unlink=True)
|
||||
if curve_data and curve_data.users == 0:
|
||||
bpy.data.curves.remove(curve_data)
|
||||
|
||||
from .handler import knot_frame_handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
for h in list(handlers):
|
||||
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
|
||||
handlers.remove(h)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 5: Set chunk frame range, pack, clean orphans
|
||||
# -----------------------------------------------------------
|
||||
orig_frame_start = scene.frame_start
|
||||
orig_frame_end = scene.frame_end
|
||||
orig_file_format = scene.render.image_settings.file_format
|
||||
orig_color_mode = scene.render.image_settings.color_mode
|
||||
|
||||
scene.frame_start = chunk_start
|
||||
scene.frame_end = chunk_end
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.image_settings.color_mode = 'RGBA'
|
||||
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if self.pack_textures:
|
||||
try:
|
||||
bpy.ops.file.pack_all()
|
||||
except RuntimeError:
|
||||
self.report({'WARNING'}, "Some external files could not be packed.")
|
||||
|
||||
for mat in list(bpy.data.materials):
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
for m in list(bpy.data.meshes):
|
||||
if m.users == 0:
|
||||
bpy.data.meshes.remove(m)
|
||||
for c in list(bpy.data.curves):
|
||||
if c.users == 0:
|
||||
bpy.data.curves.remove(c)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 6: Save as copy
|
||||
# -----------------------------------------------------------
|
||||
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# Phase 7: Clean up — restore session for next chunk
|
||||
# -----------------------------------------------------------
|
||||
scene.frame_start = orig_frame_start
|
||||
scene.frame_end = orig_frame_end
|
||||
scene.render.image_settings.file_format = orig_file_format
|
||||
scene.render.image_settings.color_mode = orig_color_mode
|
||||
|
||||
# Remove baked objects
|
||||
for obj, _, _ in baked_objects:
|
||||
if obj and obj.name in bpy.data.objects:
|
||||
mesh_data = obj.data
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh_data and mesh_data.users == 0:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
|
||||
# Remove camera shake keyframes
|
||||
if cam:
|
||||
cam_fcurves = _get_action_fcurves(cam)
|
||||
fcurves_to_remove = [
|
||||
fc for fc in cam_fcurves
|
||||
if fc.data_path == "delta_location"
|
||||
]
|
||||
for fc in fcurves_to_remove:
|
||||
try:
|
||||
cam_fcurves.remove(fc)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Re-register handler and rebuild the AnimKnot
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
already_registered = any(
|
||||
getattr(h, "__name__", "") == knot_frame_handler.__name__
|
||||
for h in handlers
|
||||
)
|
||||
if not already_registered:
|
||||
handlers.append(knot_frame_handler)
|
||||
|
||||
knot_frame_handler(scene)
|
||||
|
||||
return baked_objects
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# execute
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
# Validate
|
||||
if len(scene.knot_list) == 0:
|
||||
@@ -396,58 +165,341 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
if not filepath.lower().endswith(".blend"):
|
||||
filepath += ".blend"
|
||||
|
||||
frame_start = scene.frame_start
|
||||
frame_end = scene.frame_end
|
||||
total_frames = frame_end - frame_start + 1
|
||||
original_frame = scene.frame_current
|
||||
orig_frame_start = scene.frame_start
|
||||
orig_frame_end = scene.frame_end
|
||||
total_frames = orig_frame_end - orig_frame_start + 1
|
||||
|
||||
# Temporarily override preview resolution to render quality
|
||||
self.report({'INFO'}, f"Baking {total_frames} frames...")
|
||||
|
||||
chunk_size = self.frames_per_file if self.split_export else total_frames
|
||||
|
||||
# We will loop over chunks and bake each as a separate file
|
||||
try:
|
||||
import os
|
||||
first_chunk = True
|
||||
for chunk_start in range(orig_frame_start, orig_frame_end + 1, chunk_size):
|
||||
chunk_end = min(chunk_start + chunk_size - 1, orig_frame_end)
|
||||
|
||||
scene.frame_start = chunk_start
|
||||
scene.frame_end = chunk_end
|
||||
|
||||
chunk_filepath = filepath
|
||||
if self.split_export:
|
||||
base, ext = os.path.splitext(filepath)
|
||||
chunk_filepath = f"{base}_{chunk_start}-{chunk_end}{ext}"
|
||||
|
||||
self.report({'INFO'}, f"Baking chunk {chunk_start}-{chunk_end} to {chunk_filepath}...")
|
||||
# pack_and_clean is True only on the first chunk so that external
|
||||
# textures are packed exactly once. Subsequent chunks already
|
||||
# have the packed images in memory from the first run.
|
||||
result = self._bake_range(
|
||||
context, chunk_filepath,
|
||||
pack_and_clean=first_chunk,
|
||||
)
|
||||
first_chunk = False
|
||||
if result != {'FINISHED'}:
|
||||
self.report({'ERROR'}, f"Failed baking chunk {chunk_start}-{chunk_end}")
|
||||
return {'CANCELLED'}
|
||||
finally:
|
||||
scene.frame_start = orig_frame_start
|
||||
scene.frame_end = orig_frame_end
|
||||
|
||||
self.report({'INFO'}, "Baking complete.")
|
||||
return {'FINISHED'}
|
||||
|
||||
def _bake_range(self, context, filepath, *, pack_and_clean: bool = True):
|
||||
scene = context.scene
|
||||
glob = scene.knot_globals
|
||||
|
||||
frame_start = scene.frame_start
|
||||
frame_end = scene.frame_end
|
||||
total_frames = frame_end - frame_start + 1
|
||||
|
||||
# If "Use Render Resolution" is on, temporarily override the preview
|
||||
# resolution to match render resolution. The handler reads
|
||||
# preview_resolution for viewport and render_resolution for renders,
|
||||
# but frame_set() triggers a viewport-mode handler call. By
|
||||
# equalising them we ensure the baked mesh uses render quality.
|
||||
orig_preview_res = glob.preview_resolution
|
||||
orig_preview_bevel = glob.preview_bevel_resolution
|
||||
if self.use_render_resolution:
|
||||
glob.preview_resolution = glob.render_resolution
|
||||
glob.preview_bevel_resolution = glob.render_bevel_resolution
|
||||
|
||||
# Build the list of (chunk_start, chunk_end, output_path) tuples
|
||||
if self.chunk_frames > 0:
|
||||
base = filepath[:-6] # strip ".blend"
|
||||
chunks = []
|
||||
idx = 1
|
||||
cs = frame_start
|
||||
while cs <= frame_end:
|
||||
ce = min(cs + self.chunk_frames - 1, frame_end)
|
||||
chunks.append((cs, ce, f"{base}_{idx:03d}.blend"))
|
||||
cs = ce + 1
|
||||
idx += 1
|
||||
else:
|
||||
chunks = [(frame_start, frame_end, filepath)]
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 1: Bake per-frame geometry into mesh objects
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
self.report({'INFO'},
|
||||
f"Baking {total_frames} frames into {len(chunks)} file(s)...")
|
||||
# Collect all baked objects so we can keyframe them afterwards
|
||||
baked_objects: list[tuple[bpy.types.Object, int, int]] = []
|
||||
# Each entry: (object, first_visible_frame, last_visible_frame)
|
||||
|
||||
total_unique = 0
|
||||
for ci, (cs, ce, out_path) in enumerate(chunks):
|
||||
self.report({'INFO'},
|
||||
f"Chunk {ci+1}/{len(chunks)}: frames {cs}–{ce} → {out_path}")
|
||||
baked = self._bake_chunk(context, cs, ce, out_path)
|
||||
total_unique += len(baked)
|
||||
prev_fingerprint = None
|
||||
prev_transform_fp = None
|
||||
|
||||
# Restore resolution
|
||||
# Create a dedicated collection to hold baked frames
|
||||
bake_coll = bpy.data.collections.new("BakedAnimation")
|
||||
scene.collection.children.link(bake_coll)
|
||||
|
||||
# Store the original frame so we can restore it later
|
||||
original_frame = scene.frame_current
|
||||
|
||||
for f in range(frame_start, frame_end + 1):
|
||||
# Set the frame — this triggers knot_frame_handler via
|
||||
# the registered frame_change_post handler
|
||||
scene.frame_set(f)
|
||||
|
||||
# Get the knot object (may have been recreated by the handler)
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if not knot_obj:
|
||||
continue
|
||||
|
||||
# Evaluate the depsgraph to get the final geometry
|
||||
depsgraph = context.evaluated_depsgraph_get()
|
||||
eval_obj = knot_obj.evaluated_get(depsgraph)
|
||||
|
||||
# Convert to mesh
|
||||
mesh = bpy.data.meshes.new_from_object(eval_obj, depsgraph=depsgraph)
|
||||
mesh.name = f"BakedKnot_Mesh_F{f}"
|
||||
|
||||
# Compute fingerprints for duplicate detection
|
||||
mesh_fp = _mesh_fingerprint(mesh)
|
||||
transform_fp = _obj_transform_fingerprint(knot_obj)
|
||||
|
||||
if (mesh_fp == prev_fingerprint
|
||||
and transform_fp == prev_transform_fp
|
||||
and baked_objects):
|
||||
# Identical to the previous frame — extend the previous
|
||||
# object's visibility window and discard the duplicate mesh
|
||||
bpy.data.meshes.remove(mesh)
|
||||
_, first_f, _ = baked_objects[-1]
|
||||
baked_objects[-1] = (baked_objects[-1][0], first_f, f)
|
||||
continue
|
||||
|
||||
prev_fingerprint = mesh_fp
|
||||
prev_transform_fp = transform_fp
|
||||
|
||||
# Create a new object for this frame
|
||||
obj = bpy.data.objects.new(f"BakedKnot_F{f}", mesh)
|
||||
|
||||
# Copy transform
|
||||
obj.location = knot_obj.location.copy()
|
||||
obj.rotation_euler = knot_obj.rotation_euler.copy()
|
||||
obj.scale = knot_obj.scale.copy()
|
||||
|
||||
# Copy material slots
|
||||
for src_slot in knot_obj.material_slots:
|
||||
obj.data.materials.append(src_slot.material)
|
||||
|
||||
# Copy display settings
|
||||
obj.display_type = knot_obj.display_type
|
||||
|
||||
bake_coll.objects.link(obj)
|
||||
baked_objects.append((obj, f, f))
|
||||
|
||||
# Restore preview resolution if we overrode it
|
||||
if self.use_render_resolution:
|
||||
glob.preview_resolution = orig_preview_res
|
||||
glob.preview_bevel_resolution = orig_preview_bevel
|
||||
|
||||
# Restore frame
|
||||
# Restore original frame
|
||||
scene.frame_set(original_frame)
|
||||
|
||||
if not baked_objects:
|
||||
self.report({'ERROR'}, "No frames were baked — the handler may not be producing geometry.")
|
||||
# Clean up the empty collection
|
||||
bpy.data.collections.remove(bake_coll)
|
||||
return {'CANCELLED'}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 2: Set up visibility keyframes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
for obj, first_f, last_f in baked_objects:
|
||||
# Default: hidden on all frames
|
||||
# We keyframe "hidden" before and after the active window,
|
||||
# and "visible" for the active range.
|
||||
|
||||
# Hidden before the active range
|
||||
if first_f > frame_start:
|
||||
_keyframe_visibility(obj, frame_start, False)
|
||||
if first_f > frame_start + 1:
|
||||
_keyframe_visibility(obj, first_f - 1, False)
|
||||
|
||||
# Visible during the active range
|
||||
_keyframe_visibility(obj, first_f, True)
|
||||
if last_f != first_f:
|
||||
_keyframe_visibility(obj, last_f, True)
|
||||
|
||||
# Hidden after the active range
|
||||
if last_f < frame_end:
|
||||
_keyframe_visibility(obj, last_f + 1, False)
|
||||
if last_f < frame_end - 1:
|
||||
_keyframe_visibility(obj, frame_end, False)
|
||||
|
||||
_set_constant_interpolation(obj)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 3: Bake camera shake to keyframes
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
cam = scene.camera
|
||||
if cam and glob.camera_shake_amplitude > 0.0:
|
||||
shake = glob.camera_shake_amplitude
|
||||
for f in range(frame_start, frame_end + 1):
|
||||
import random
|
||||
random.seed(int(f * 1000))
|
||||
cam.delta_location = (
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
random.uniform(-shake, shake),
|
||||
)
|
||||
cam.keyframe_insert(data_path="delta_location", frame=f)
|
||||
|
||||
# Set interpolation to LINEAR for smooth shake
|
||||
for fc in _get_action_fcurves(cam):
|
||||
if fc.data_path == "delta_location":
|
||||
for kp in fc.keyframe_points:
|
||||
kp.interpolation = 'LINEAR'
|
||||
|
||||
# If no shake, ensure delta_location is zeroed
|
||||
elif cam:
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 4: Clean up the procedural AnimKnot object
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Remove the original NURBS curve object — we've replaced it with
|
||||
# baked mesh objects
|
||||
knot_obj = bpy.data.objects.get(KNOT_OBJ_NAME)
|
||||
if knot_obj:
|
||||
curve_data = knot_obj.data
|
||||
bpy.data.objects.remove(knot_obj, do_unlink=True)
|
||||
if curve_data and curve_data.users == 0:
|
||||
bpy.data.curves.remove(curve_data)
|
||||
|
||||
# Remove the frame_change_post handler so the baked file is
|
||||
# completely standalone
|
||||
from .handler import knot_frame_handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
for h in list(handlers):
|
||||
if getattr(h, "__name__", "") == knot_frame_handler.__name__:
|
||||
handlers.remove(h)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 5: Pack data and ensure render-farm compatibility
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Set output format to PNG image sequence (required by SheepIt)
|
||||
orig_file_format = scene.render.image_settings.file_format
|
||||
orig_color_mode = scene.render.image_settings.color_mode
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.render.image_settings.color_mode = 'RGBA'
|
||||
|
||||
if pack_and_clean:
|
||||
# Make all paths relative — only needed once; subsequent chunks
|
||||
# operate on the same in-memory data which is already relative.
|
||||
try:
|
||||
bpy.ops.file.make_paths_relative()
|
||||
except RuntimeError:
|
||||
pass # May fail if file has never been saved
|
||||
|
||||
# Pack external data if requested — run once so that all packed
|
||||
# images are available in memory for every subsequent chunk save.
|
||||
if self.pack_textures:
|
||||
try:
|
||||
bpy.ops.file.pack_all()
|
||||
except RuntimeError:
|
||||
self.report({'WARNING'}, "Some external files could not be packed.")
|
||||
|
||||
# Remove unused data blocks to reduce file size. Only safe to
|
||||
# do on the first chunk; later chunks share the same in-memory
|
||||
# materials/images and we do not want to inadvertently purge data
|
||||
# that is still referenced by a subsequent chunk's bake.
|
||||
for mat in list(bpy.data.materials):
|
||||
if mat.users == 0:
|
||||
bpy.data.materials.remove(mat)
|
||||
for m in list(bpy.data.meshes):
|
||||
if m.users == 0:
|
||||
bpy.data.meshes.remove(m)
|
||||
for c in list(bpy.data.curves):
|
||||
if c.users == 0:
|
||||
bpy.data.curves.remove(c)
|
||||
for ng in list(bpy.data.node_groups):
|
||||
if ng.users == 0:
|
||||
bpy.data.node_groups.remove(ng)
|
||||
for img in list(bpy.data.images):
|
||||
if img.users == 0 and not img.use_fake_user:
|
||||
bpy.data.images.remove(img)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 6: Save as a COPY — the original session is untouched
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
bpy.ops.wm.save_as_mainfile(filepath=filepath, copy=True, compress=True)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Phase 7: Restore the original session state
|
||||
# -------------------------------------------------------------------
|
||||
# We just saved a copy, but the in-memory state has been modified
|
||||
# (baked objects added, AnimKnot removed, handler removed).
|
||||
# Restore everything so the user can keep working.
|
||||
|
||||
# Restore render settings
|
||||
scene.render.image_settings.file_format = orig_file_format
|
||||
scene.render.image_settings.color_mode = orig_color_mode
|
||||
|
||||
# Remove all baked objects and their meshes from the current session
|
||||
for obj, _, _ in baked_objects:
|
||||
if obj and obj.name in bpy.data.objects:
|
||||
mesh_data = obj.data
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
if mesh_data and mesh_data.users == 0:
|
||||
bpy.data.meshes.remove(mesh_data)
|
||||
|
||||
# Remove the baked collection
|
||||
bake_coll_check = bpy.data.collections.get("BakedAnimation")
|
||||
if bake_coll_check:
|
||||
bpy.data.collections.remove(bake_coll_check)
|
||||
|
||||
# Remove baked camera shake keyframes (restore static camera)
|
||||
if cam:
|
||||
cam_fcurves = _get_action_fcurves(cam)
|
||||
# cam_fcurves may be a ChannelBag or ActionFCurves — both support .remove()
|
||||
fcurves_to_remove = [
|
||||
fc for fc in cam_fcurves
|
||||
if fc.data_path == "delta_location"
|
||||
]
|
||||
for fc in fcurves_to_remove:
|
||||
try:
|
||||
cam_fcurves.remove(fc)
|
||||
except (TypeError, RuntimeError):
|
||||
pass # Some API versions don't support removal — keyframes will persist
|
||||
cam.delta_location = (0.0, 0.0, 0.0)
|
||||
|
||||
# Re-create the AnimKnot object and re-register the handler
|
||||
from .geometry import _make_torus_knot
|
||||
from .materials import prewarm_materials_and_blends
|
||||
|
||||
# We need to prewarm materials and blends because removing unused materials might have deleted them
|
||||
prewarm_materials_and_blends(scene)
|
||||
|
||||
# Re-register the handler
|
||||
handlers = bpy.app.handlers.frame_change_post
|
||||
already_registered = any(
|
||||
getattr(h, "__name__", "") == knot_frame_handler.__name__
|
||||
for h in handlers
|
||||
)
|
||||
if not already_registered:
|
||||
handlers.append(knot_frame_handler)
|
||||
|
||||
# Trigger the handler once to rebuild the AnimKnot object
|
||||
knot_frame_handler(scene)
|
||||
|
||||
# Restore the frame
|
||||
scene.frame_set(original_frame)
|
||||
|
||||
if len(chunks) == 1:
|
||||
self.report({'INFO'},
|
||||
f"Baked .blend saved to: {chunks[0][2]} "
|
||||
f"({total_unique} unique meshes)")
|
||||
else:
|
||||
self.report({'INFO'},
|
||||
f"Exported {len(chunks)} files "
|
||||
f"({total_unique} unique meshes total)")
|
||||
return {'FINISHED'}
|
||||
|
||||
def draw(self, context):
|
||||
@@ -458,7 +510,9 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
layout.prop(self, "pack_textures")
|
||||
|
||||
layout.separator()
|
||||
layout.prop(self, "chunk_frames")
|
||||
layout.prop(self, "split_export")
|
||||
if self.split_export:
|
||||
layout.prop(self, "frames_per_file")
|
||||
|
||||
scene = context.scene
|
||||
total = scene.frame_end - scene.frame_start + 1
|
||||
@@ -466,10 +520,3 @@ class KNOT_OT_BakeExport(bpy.types.Operator, ExportHelper):
|
||||
layout.label(text=f"Frames: {scene.frame_start} – {scene.frame_end} ({total} total)")
|
||||
layout.label(text=f"Playlist: {len(scene.knot_list)} knots")
|
||||
|
||||
if self.chunk_frames > 0:
|
||||
n_chunks = -(-total // self.chunk_frames) # ceil division
|
||||
layout.label(text=f"Will produce {n_chunks} file(s)",
|
||||
icon='FILE_BLEND')
|
||||
else:
|
||||
layout.label(text="Single file (no splitting)", icon='FILE_BLEND')
|
||||
|
||||
|
||||
+28
-47
@@ -64,27 +64,21 @@ def _inner_NEON_GLOW(nodes, links, y=0, color=(0.0, 1.0, 0.9, 1.0),
|
||||
def _inner_METALLIC(nodes, links, y=0, color=(1.0, 0.78, 0.28, 1.0),
|
||||
roughness=0.05, metallic=1.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
try:
|
||||
prin.inputs["Specular IOR Level"].default_value = 1.0
|
||||
except KeyError:
|
||||
pass # name differs in older Blender versions
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Specular IOR Level"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
def _inner_GLASS(nodes, links, y=0, color=(0.85, 0.95, 1.0, 1.0),
|
||||
roughness=0.0, metallic=0.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.45
|
||||
for key in ("Transmission Weight", "Transmission"):
|
||||
if key in prin.inputs:
|
||||
prin.inputs[key].default_value = 1.0
|
||||
break
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.45
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -169,8 +163,6 @@ def _inner_MATTE_CLAY(nodes, links, y=0, color=(0.6, 0.4, 0.3, 1.0),
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
if "Specular" in prin.inputs:
|
||||
prin.inputs["Specular"].default_value = 0.1
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -192,15 +184,11 @@ def _inner_GHOST(nodes, links, y=0, color=(0.7, 0.8, 1.0, 1.0),
|
||||
def _inner_CAR_PAINT(nodes, links, y=0, color=(0.8, 0.05, 0.1, 1.0),
|
||||
roughness=0.3, metallic=0.8, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
if "Clearcoat" in prin.inputs:
|
||||
prin.inputs["Clearcoat"].default_value = 1.0
|
||||
prin.inputs["Clearcoat Roughness"].default_value = 0.03
|
||||
elif "Coat Weight" in prin.inputs:
|
||||
prin.inputs["Coat Weight"].default_value = 1.0
|
||||
prin.inputs["Coat Roughness"].default_value = 0.03
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Coat Weight"].default_value = 1.0
|
||||
prin.inputs["Coat Roughness"].default_value = 0.03
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -216,28 +204,22 @@ def _inner_CHROME(nodes, links, y=0, color=(0.95, 0.95, 0.95, 1.0),
|
||||
def _inner_RUBY_GLASS(nodes, links, y=0, color=(1.0, 0.05, 0.05, 1.0),
|
||||
roughness=0.02, metallic=0.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.6
|
||||
for key in ("Transmission Weight", "Transmission"):
|
||||
if key in prin.inputs:
|
||||
prin.inputs[key].default_value = 1.0
|
||||
break
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.6
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
def _inner_FROSTED_ICE(nodes, links, y=0, color=(0.9, 0.95, 1.0, 1.0),
|
||||
roughness=0.4, metallic=0.0, emission_strength=1.0, **kwargs):
|
||||
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.31
|
||||
for key in ("Transmission Weight", "Transmission"):
|
||||
if key in prin.inputs:
|
||||
prin.inputs[key].default_value = 1.0
|
||||
break
|
||||
prin.inputs["Base Color"].default_value = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["IOR"].default_value = 1.31
|
||||
prin.inputs["Transmission Weight"].default_value = 1.0
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
|
||||
@@ -375,11 +357,10 @@ def _inner_PEARLESCENT(nodes, links, y=0, color=(1.0, 0.4, 0.7, 1.0),
|
||||
cr.interpolation = 'LINEAR'
|
||||
cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.8, 1.0, 1.0)
|
||||
cr.elements[1].position = 1.0; cr.elements[1].color = color
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
if "Clearcoat" in prin.inputs:
|
||||
prin.inputs["Clearcoat"].default_value = 1.0
|
||||
prin.inputs["Clearcoat Roughness"].default_value = 0.02
|
||||
prin.inputs["Metallic"].default_value = metallic
|
||||
prin.inputs["Roughness"].default_value = roughness
|
||||
prin.inputs["Coat Weight"].default_value = 1.0
|
||||
prin.inputs["Coat Roughness"].default_value = 0.02
|
||||
links.new(lw.outputs["Facing"], ramp.inputs["Fac"])
|
||||
links.new(ramp.outputs["Color"], prin.inputs["Base Color"])
|
||||
return prin.outputs["BSDF"]
|
||||
|
||||
+19
-13
@@ -71,8 +71,9 @@ def _rand_apply_choice(gen, item, prop: str, choices: list) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KNOT_OT_Add(bpy.types.Operator):
|
||||
bl_idname = "knot.add_item"
|
||||
bl_label = "Add Knot"
|
||||
bl_idname = "knot.add_item"
|
||||
bl_label = "Add Knot"
|
||||
bl_description = "Add a new knot entry to the end of the playlist"
|
||||
|
||||
def execute(self, context):
|
||||
item = context.scene.knot_list.add()
|
||||
@@ -85,8 +86,9 @@ class KNOT_OT_Add(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_Remove(bpy.types.Operator):
|
||||
bl_idname = "knot.remove_item"
|
||||
bl_label = "Remove Knot"
|
||||
bl_idname = "knot.remove_item"
|
||||
bl_label = "Remove Knot"
|
||||
bl_description = "Remove the selected knot entry from the playlist"
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -99,9 +101,10 @@ class KNOT_OT_Remove(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_Move(bpy.types.Operator):
|
||||
bl_idname = "knot.move_item"
|
||||
bl_label = "Move Knot"
|
||||
direction: bpy.props.EnumProperty(items=[('UP', 'Up', ''), ('DOWN', 'Down', '')])
|
||||
bl_idname = "knot.move_item"
|
||||
bl_label = "Move Knot"
|
||||
bl_description = "Move the selected knot up or down in the playlist order"
|
||||
direction: bpy.props.EnumProperty(items=[('UP', 'Up', 'Move knot earlier in the playlist'), ('DOWN', 'Down', 'Move knot later in the playlist')])
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -114,8 +117,9 @@ class KNOT_OT_Move(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_Populate(bpy.types.Operator):
|
||||
bl_idname = "knot.populate"
|
||||
bl_label = "Populate Default List"
|
||||
bl_idname = "knot.populate"
|
||||
bl_label = "Populate Default List"
|
||||
bl_description = "Clear the playlist and load the 10 built-in example knots (trefoil, cinquefoil, Möbius, Lissajous, and more)"
|
||||
|
||||
def execute(self, context):
|
||||
context.scene.knot_list.clear()
|
||||
@@ -134,8 +138,9 @@ class KNOT_OT_Populate(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_BakePreview(bpy.types.Operator):
|
||||
bl_idname = "knot.bake_preview"
|
||||
bl_label = "Update Preview"
|
||||
bl_idname = "knot.bake_preview"
|
||||
bl_label = "Update Preview"
|
||||
bl_description = "Immediately regenerate the viewport knot using the currently selected playlist entry's settings, without waiting for a frame change"
|
||||
|
||||
def execute(self, context):
|
||||
idx = context.scene.knot_list_index
|
||||
@@ -196,8 +201,9 @@ class KNOT_OT_SyncGeneratorMaterials(bpy.types.Operator):
|
||||
|
||||
|
||||
class KNOT_OT_GenerateRandom(bpy.types.Operator):
|
||||
bl_idname = "knot.generate_random"
|
||||
bl_label = "Generate Playlist"
|
||||
bl_idname = "knot.generate_random"
|
||||
bl_label = "Generate Playlist"
|
||||
bl_description = "Clear the playlist and fill it with randomly configured knots using the ranges and toggles set in the Randomise Toggles section below"
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
|
||||
+124
-45
@@ -121,17 +121,23 @@ class KnotAllowedMaterial(bpy.types.PropertyGroup):
|
||||
class KnotGlobalSettings(bpy.types.PropertyGroup):
|
||||
"""Scene-level playback and rendering settings."""
|
||||
frames_per_knot: bpy.props.IntProperty(
|
||||
name="Frames per Knot", default=12, min=1)
|
||||
name="Frames per Knot", default=12, min=1,
|
||||
description="Base display duration (in frames) for each playlist entry. Each knot's effective duration is frames_per_knot × cycle_rate")
|
||||
preview_resolution: bpy.props.IntProperty(
|
||||
name="Preview Curve Res", default=64, min=3, max=1024)
|
||||
name="Preview Curve Res", default=64, min=3, max=1024,
|
||||
description="NURBS subdivision used in the viewport. Higher values produce a smoother curve but update more slowly")
|
||||
render_resolution: bpy.props.IntProperty(
|
||||
name="Render Curve Res", default=128, min=3, max=2048)
|
||||
name="Render Curve Res", default=128, min=3, max=2048,
|
||||
description="NURBS subdivision used during renders and baking. Higher values produce higher quality output")
|
||||
preview_bevel_resolution: bpy.props.IntProperty(
|
||||
name="Preview Bevel Res", default=4, min=0, max=64)
|
||||
name="Preview Bevel Res", default=4, min=0, max=64,
|
||||
description="Number of sides on the tube cross-section in the viewport")
|
||||
render_bevel_resolution: bpy.props.IntProperty(
|
||||
name="Render Bevel Res", default=8, min=0, max=64)
|
||||
name="Render Bevel Res", default=8, min=0, max=64,
|
||||
description="Number of sides on the tube cross-section during renders and baking")
|
||||
knot_scale: bpy.props.FloatProperty(
|
||||
name="Global Scale", default=1.0, min=0.01)
|
||||
name="Global Scale", default=1.0, min=0.01,
|
||||
description="Uniform scale multiplier applied to all knots")
|
||||
global_speed: bpy.props.FloatProperty(
|
||||
name="Global Speed", default=1.0, min=0.01,
|
||||
description="Multiplies the effective frame counter for all knots. Keyframeable.")
|
||||
@@ -183,20 +189,29 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
# Shape Type
|
||||
shape_type: bpy.props.EnumProperty(
|
||||
name="Shape",
|
||||
description="Parametric curve type used to generate this knot",
|
||||
items=[
|
||||
('TORUS_KNOT', "Torus Knot", ""),
|
||||
('MOBIUS', "Mobius Strip", ""),
|
||||
('LISSAJOUS', "Lissajous 3D", ""),
|
||||
('SPIRAL', "Spherical Spiral", ""),
|
||||
('TORUS_KNOT', "Torus Knot", "Classic (p,q) torus knot wound around a torus surface"),
|
||||
('MOBIUS', "Mobius Strip", "One-sided surface with a half-twist — non-orientable loop"),
|
||||
('LISSAJOUS', "Lissajous 3D", "3-axis frequency-ratio figure trace"),
|
||||
('SPIRAL', "Spherical Spiral", "Spherical spiral (loxodrome) with configurable turns and radius"),
|
||||
],
|
||||
default='TORUS_KNOT'
|
||||
)
|
||||
|
||||
# Topology (Torus Knot)
|
||||
torus_p: bpy.props.IntProperty(name="Revolutions (p)", default=2, min=1)
|
||||
mod_torus_p: bpy.props.FloatProperty(name="Mod p", default=0.0)
|
||||
torus_q: bpy.props.IntProperty(name="Spins (q)", default=3, min=1)
|
||||
mod_torus_q: bpy.props.FloatProperty(name="Mod q", default=0.0)
|
||||
torus_p: bpy.props.IntProperty(
|
||||
name="Revolutions (p)", default=2, min=1,
|
||||
description="Number of revolutions around the torus axis. Must be coprime with q for a true knot")
|
||||
mod_torus_p: bpy.props.FloatProperty(
|
||||
name="Mod p", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to p before rendering")
|
||||
torus_q: bpy.props.IntProperty(
|
||||
name="Spins (q)", default=3, min=1,
|
||||
description="Number of spins around the torus tube. Must be coprime with p for a true knot")
|
||||
mod_torus_q: bpy.props.FloatProperty(
|
||||
name="Mod q", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to q before rendering")
|
||||
|
||||
# Topology (Mobius)
|
||||
mobius_twists: bpy.props.IntProperty(name="Half Twists", default=1, min=1)
|
||||
@@ -221,25 +236,48 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
mod_spiral_R: bpy.props.FloatProperty(name="Mod Radius", default=0.0)
|
||||
|
||||
# Radii (Torus Knot)
|
||||
torus_R: bpy.props.FloatProperty(name="Major", default=2.0, min=0.0)
|
||||
mod_torus_R: bpy.props.FloatProperty(name="Mod Major", default=0.0)
|
||||
torus_r: bpy.props.FloatProperty(name="Minor", default=1.0, min=0.0)
|
||||
mod_torus_r: bpy.props.FloatProperty(name="Mod Minor", default=0.0)
|
||||
torus_R: bpy.props.FloatProperty(
|
||||
name="Major", default=2.0, min=0.0,
|
||||
description="Distance from the centre of the torus to the centre of the tube")
|
||||
mod_torus_R: bpy.props.FloatProperty(
|
||||
name="Mod Major", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to the major radius")
|
||||
torus_r: bpy.props.FloatProperty(
|
||||
name="Minor", default=1.0, min=0.0,
|
||||
description="Radius of the tube itself")
|
||||
mod_torus_r: bpy.props.FloatProperty(
|
||||
name="Mod Minor", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to the minor radius")
|
||||
|
||||
# Colors (legacy TKP path)
|
||||
multiple_links: bpy.props.BoolProperty(name="Multiple Links", default=False)
|
||||
use_colors: bpy.props.BoolProperty(name="Use Colors", default=False)
|
||||
multiple_links: bpy.props.BoolProperty(
|
||||
name="Multiple Links", default=False,
|
||||
description="Render all gcd(p,q) link components as separate curves")
|
||||
use_colors: bpy.props.BoolProperty(
|
||||
name="Use Colors", default=False,
|
||||
description="Apply per-link vertex colors to the curve (legacy TKP color mode)")
|
||||
colorSet: bpy.props.EnumProperty(
|
||||
name="Color Set",
|
||||
items=[('1', "RGBish", ""), ('2', "Rainbow", "")],
|
||||
description="Which built-in vertex color palette to use",
|
||||
items=[('1', "RGBish", "Red/Green/Blue-based palette"), ('2', "Rainbow", "Full-spectrum rainbow palette")],
|
||||
default='1')
|
||||
random_colors: bpy.props.BoolProperty(name="Randomize Colors", default=False)
|
||||
random_colors: bpy.props.BoolProperty(
|
||||
name="Randomize Colors", default=False,
|
||||
description="Shuffle the vertex color assignment randomly each frame")
|
||||
|
||||
# Multipliers & phases
|
||||
torus_u: bpy.props.IntProperty( name="Rev. Multiplier", default=1, min=1)
|
||||
torus_v: bpy.props.IntProperty( name="Spin Multiplier", default=1, min=1)
|
||||
torus_rP: bpy.props.FloatProperty(name="Rev. Phase", default=0.0)
|
||||
torus_sP: bpy.props.FloatProperty(name="Spin Phase", default=0.0)
|
||||
torus_u: bpy.props.IntProperty(
|
||||
name="Rev. Multiplier", default=1, min=1,
|
||||
description="Repeats the revolution pattern — creates multiple overlapping copies of the knot")
|
||||
torus_v: bpy.props.IntProperty(
|
||||
name="Spin Multiplier", default=1, min=1,
|
||||
description="Repeats the spin pattern around the tube")
|
||||
torus_rP: bpy.props.FloatProperty(
|
||||
name="Rev. Phase", default=0.0,
|
||||
description="Revolution phase offset in radians (orbit rotation)")
|
||||
torus_sP: bpy.props.FloatProperty(
|
||||
name="Spin Phase", default=0.0,
|
||||
description="Spin phase offset in radians (tube rotation)")
|
||||
|
||||
# Animation rates
|
||||
spin_phase_rate: bpy.props.FloatProperty(
|
||||
@@ -268,36 +306,69 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
update=_update_knot_material_cb)
|
||||
transition_easing: bpy.props.EnumProperty(
|
||||
name="Easing",
|
||||
description="Interpolation curve applied during the morph window",
|
||||
items=[
|
||||
('LINEAR', "Linear", ""),
|
||||
('QUAD_IN_OUT', "Quadratic In/Out", ""),
|
||||
('SMOOTHSTEP', "Smoothstep", ""),
|
||||
('LINEAR', "Linear", "Constant-rate blend between the two knots"),
|
||||
('QUAD_IN_OUT', "Quadratic In/Out", "Slow start and end, faster in the middle"),
|
||||
('SMOOTHSTEP', "Smoothstep", "S-curve blend — smooth at both endpoints"),
|
||||
],
|
||||
default='QUAD_IN_OUT')
|
||||
|
||||
# Geometry
|
||||
geo_extrude: bpy.props.FloatProperty(name="Extrude", default=0.0, min=0.0)
|
||||
mod_geo_extrude: bpy.props.FloatProperty(name="Mod Extrude", default=0.0)
|
||||
geo_offset: bpy.props.FloatProperty(name="Offset", default=0.0)
|
||||
mod_geo_offset: bpy.props.FloatProperty(name="Mod Offset", default=0.0)
|
||||
geo_bDepth: bpy.props.FloatProperty(name="Bevel Depth", default=0.04, min=0.0)
|
||||
mod_geo_bDepth: bpy.props.FloatProperty(name="Mod BDepth", default=0.0)
|
||||
geo_extrude: bpy.props.FloatProperty(
|
||||
name="Extrude", default=0.0, min=0.0,
|
||||
description="Extrude the curve profile outward — creates a ribbon effect when combined with Offset")
|
||||
mod_geo_extrude: bpy.props.FloatProperty(
|
||||
name="Mod Extrude", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Extrude")
|
||||
geo_offset: bpy.props.FloatProperty(
|
||||
name="Offset", default=0.0,
|
||||
description="Offset the extruded profile from the curve centreline")
|
||||
mod_geo_offset: bpy.props.FloatProperty(
|
||||
name="Mod Offset", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Offset")
|
||||
geo_bDepth: bpy.props.FloatProperty(
|
||||
name="Bevel Depth", default=0.04, min=0.0,
|
||||
description="Tube thickness — controls how wide the knot strand is")
|
||||
mod_geo_bDepth: bpy.props.FloatProperty(
|
||||
name="Mod BDepth", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Bevel Depth")
|
||||
|
||||
# Dimensions mode
|
||||
mode: bpy.props.EnumProperty(
|
||||
name="Dimensions Mode",
|
||||
items=[('MAJOR_MINOR', "Major/Minor", ""), ('EXT_INT', "Exterior/Interior", "")],
|
||||
description="Choose how to specify the torus radii",
|
||||
items=[
|
||||
('MAJOR_MINOR', "Major/Minor", "Specify inner and outer radius as Major and Minor"),
|
||||
('EXT_INT', "Exterior/Interior", "Specify the outer edge and the hole as Exterior and Interior radii"),
|
||||
],
|
||||
default='MAJOR_MINOR')
|
||||
torus_eR: bpy.props.FloatProperty(name="Exterior", default=3.0, min=0.0)
|
||||
mod_torus_eR: bpy.props.FloatProperty(name="Mod Ext", default=0.0)
|
||||
torus_iR: bpy.props.FloatProperty(name="Interior", default=1.0, min=0.0)
|
||||
mod_torus_iR: bpy.props.FloatProperty(name="Mod Int", default=0.0)
|
||||
torus_h: bpy.props.FloatProperty(name="Height", default=1.0, min=0.0)
|
||||
mod_torus_h: bpy.props.FloatProperty(name="Mod Height", default=0.0)
|
||||
torus_eR: bpy.props.FloatProperty(
|
||||
name="Exterior", default=3.0, min=0.0,
|
||||
description="Outer edge radius of the torus (Exterior/Interior mode)")
|
||||
mod_torus_eR: bpy.props.FloatProperty(
|
||||
name="Mod Ext", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Exterior radius")
|
||||
torus_iR: bpy.props.FloatProperty(
|
||||
name="Interior", default=1.0, min=0.0,
|
||||
description="Inner hole radius of the torus (Exterior/Interior mode)")
|
||||
mod_torus_iR: bpy.props.FloatProperty(
|
||||
name="Mod Int", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Interior radius")
|
||||
torus_h: bpy.props.FloatProperty(
|
||||
name="Height", default=1.0, min=0.0,
|
||||
description="Vertical stretch of the torus — values above 1.0 elongate the knot")
|
||||
mod_torus_h: bpy.props.FloatProperty(
|
||||
name="Mod Height", default=0.0,
|
||||
description="Per-frame attenuverter offset applied to Height")
|
||||
|
||||
# Direction
|
||||
flip_p: bpy.props.BoolProperty(name="Flip p", default=False)
|
||||
flip_q: bpy.props.BoolProperty(name="Flip q", default=False)
|
||||
flip_p: bpy.props.BoolProperty(
|
||||
name="Flip p", default=False,
|
||||
description="Reverse the direction of the p (revolution) winding")
|
||||
flip_q: bpy.props.BoolProperty(
|
||||
name="Flip q", default=False,
|
||||
description="Reverse the direction of the q (spin) winding")
|
||||
|
||||
# UI expand state (display-only, not serialised to animation)
|
||||
ui_show_shape: bpy.props.BoolProperty(name="Shape", default=True)
|
||||
@@ -311,7 +382,11 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
# Material
|
||||
material_mode: bpy.props.EnumProperty(
|
||||
name="Material Mode",
|
||||
items=[('PRESET', "Shader Preset", ""), ('PROJECT', "Project Material", "")],
|
||||
description="Choose between a built-in shader preset or an existing material from the project",
|
||||
items=[
|
||||
('PRESET', "Shader Preset", "Use one of the 20 built-in procedural shader presets"),
|
||||
('PROJECT', "Project Material", "Use any material already in this .blend file"),
|
||||
],
|
||||
default='PRESET',
|
||||
update=_update_knot_material_cb)
|
||||
project_material: bpy.props.PointerProperty(
|
||||
@@ -327,15 +402,19 @@ class KnotItem(bpy.types.PropertyGroup):
|
||||
preset_color: bpy.props.FloatVectorProperty(
|
||||
name="Preset Color", subtype='COLOR', size=3,
|
||||
default=(0.2, 0.6, 1.0), min=0.0, max=1.0,
|
||||
description="Base colour tint passed into the selected shader preset",
|
||||
update=_update_knot_material_cb)
|
||||
preset_roughness: bpy.props.FloatProperty(
|
||||
name="Preset Roughness", default=0.1, min=0.0, max=1.0,
|
||||
description="Roughness override for the active preset (0 = mirror-smooth, 1 = fully diffuse)",
|
||||
update=_update_knot_material_cb)
|
||||
preset_metallic: bpy.props.FloatProperty(
|
||||
name="Preset Metallic", default=0.0, min=0.0, max=1.0,
|
||||
description="Metallic factor override for the active preset (0 = dielectric, 1 = metal)",
|
||||
update=_update_knot_material_cb)
|
||||
preset_emission_strength: bpy.props.FloatProperty(
|
||||
name="Preset Emission Strength", default=1.0, min=0.0, max=100.0,
|
||||
description="Emission strength override for presets that emit light",
|
||||
update=_update_knot_material_cb)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
|
||||
+13
-8
@@ -6,7 +6,7 @@ All Blender UI classes for knot_animation:
|
||||
KNOT_UL_List — playlist UIList
|
||||
KNOT_UL_AllowedMaterialsList— generator filter UIList
|
||||
draw_knot_properties() — shared property layout helper (collapsible sections)
|
||||
KNOT_PT_Panel — main N-panel (VIEW_3D > UI > AnimKnots)
|
||||
KNOT_PT_Panel — main N-panel (VIEW_3D > UI > Pr3tz)
|
||||
|
||||
Every major block is collapsible via per-item or per-settings ui_show_* flags.
|
||||
KnotItem flags are per-item so each playlist entry remembers its own state.
|
||||
@@ -182,11 +182,11 @@ def draw_knot_properties(layout, item) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class KNOT_PT_Panel(bpy.types.Panel):
|
||||
bl_label = "AnimKnots"
|
||||
bl_label = "Pr3tz"
|
||||
bl_idname = "KNOT_PT_panel"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = 'AnimKnots'
|
||||
bl_category = 'Pr3tz'
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
@@ -209,8 +209,10 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
col.operator("knot.add_item", text="", icon='ADD')
|
||||
col.operator("knot.remove_item", text="", icon='REMOVE')
|
||||
col.separator()
|
||||
col.operator("knot.move_item", text="", icon='TRIA_UP').direction = 'UP'
|
||||
col.operator("knot.move_item", text="", icon='TRIA_DOWN').direction = 'DOWN'
|
||||
col.operator("knot.move_item", text="", icon='TRIA_UP'
|
||||
).direction = 'UP'
|
||||
col.operator("knot.move_item", text="", icon='TRIA_DOWN'
|
||||
).direction = 'DOWN'
|
||||
pl_box.operator("knot.populate", icon='FILE_REFRESH')
|
||||
|
||||
# ── Selected Knot ─────────────────────────────────────────────────────
|
||||
@@ -223,7 +225,7 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
icon_only=True, emboss=False)
|
||||
ke_row.label(text=f"Knot: {item.name}", icon='CURVE_PATH')
|
||||
if glob.ui_show_knot_edit:
|
||||
ke_box.prop(item, "name", text="")
|
||||
ke_box.prop(item, "name", text="",)
|
||||
draw_knot_properties(ke_box, item)
|
||||
ke_box.operator("knot.bake_preview", icon='FILE_TICK')
|
||||
|
||||
@@ -400,8 +402,11 @@ class KNOT_PT_Panel(bpy.types.Panel):
|
||||
gen, "allowed_materials",
|
||||
gen, "allowed_materials_index",
|
||||
rows=5)
|
||||
row_sync.column(align=True).operator(
|
||||
"knot.sync_generator_materials", icon='FILE_REFRESH', text="")
|
||||
sync_col = row_sync.column(align=True)
|
||||
sync_col.operator(
|
||||
"knot.sync_generator_materials",
|
||||
icon='FILE_REFRESH', text=""
|
||||
)
|
||||
if len(gen.allowed_materials) == 0:
|
||||
filter_box.label(
|
||||
text="Click Sync to load project materials & presets",
|
||||
|
||||
Reference in New Issue
Block a user