Initial public release
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-19 21:31:36 +08:00
commit 198fcbf55b
837 changed files with 67239 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
---
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **Select the change**
If a name is provided, use it. Otherwise:
- Infer from conversation context if the user mentioned a change
- Auto-select if only one active change exists
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
2. **Check status to understand the schema**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to understand:
- `schemaName`: The workflow being used (e.g., "spec-driven")
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
3. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
- If `state: "all_done"`: congratulate, suggest archive
- Otherwise: proceed to implementation
4. **Read context files**
Read every file path listed under `contextFiles` from the apply instructions output.
The files depend on the schema being used:
- **spec-driven**: proposal, specs, design, tasks
- Other schemas: follow the contextFiles from CLI output
5. **Show current progress**
Display:
- Schema being used
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
6. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
7. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
**Output During Implementation**
```
## Implementing: <change-name> (schema: <schema-name>)
Working on task 3/7: <task description>
[...implementation happening...]
✓ Task complete
Working on task 4/7: <task description>
[...implementation happening...]
✓ Task complete
```
**Output On Completion**
```
## Implementation Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Task 1
- [x] Task 2
...
All tasks complete! Ready to archive this change.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,114 @@
---
name: openspec-archive-change
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
Show only active changes (not already archived).
Include the schema used for each change if available.
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
2. **Check artifact completion status**
Run `openspec status --change "<name>" --json` to check artifact completion.
Parse the JSON to understand:
- `schemaName`: The workflow being used
- `artifacts`: List of artifacts with their status (`done` or other)
**If any artifacts are not `done`:**
- Display warning listing incomplete artifacts
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
3. **Check task completion status**
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Use **AskUserQuestion tool** to confirm user wants to proceed
- Proceed if user confirms
**If no tasks file exists:** Proceed without task-related warning.
4. **Assess delta spec sync state**
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
**If delta specs exist:**
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
- Determine what changes would be applied (adds, modifications, removals, renames)
- Show a combined summary before prompting
**Prompt options:**
- If changes needed: "Sync now (recommended)", "Archive without syncing"
- If already synced: "Archive now", "Sync anyway", "Cancel"
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
5. **Perform the archive**
Create the archive directory if it doesn't exist:
```bash
mkdir -p openspec/changes/archive
```
Generate target name using current date: `YYYY-MM-DD-<change-name>`
**Check if target already exists:**
- If yes: Fail with error, suggest renaming existing archive or using different date
- If no: Move the change directory to archive
```bash
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
```
6. **Display summary**
Show archive completion summary including:
- Change name
- Schema that was used
- Archive location
- Whether specs were synced (if applicable)
- Note about any warnings (incomplete artifacts/tasks)
**Output On Success**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
All artifacts complete. All tasks complete.
```
**Guardrails**
- Always prompt for change selection if not provided
- Use artifact graph (openspec status --json) for completion checking
- Don't block archive on warnings - just inform and confirm
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
- Show clear summary of what happened
- If sync is requested, use openspec-sync-specs approach (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,288 @@
---
name: openspec-explore
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
---
## The Stance
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
- **Adaptive** - Follow interesting threads, pivot when new information emerges
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
---
## What You Might Do
Depending on what the user brings, you might:
**Explore the problem space**
- Ask clarifying questions that emerge from what they said
- Challenge assumptions
- Reframe the problem
- Find analogies
**Investigate the codebase**
- Map existing architecture relevant to the discussion
- Find integration points
- Identify patterns already in use
- Surface hidden complexity
**Compare options**
- Brainstorm multiple approaches
- Build comparison tables
- Sketch tradeoffs
- Recommend a path (if asked)
**Visualize**
```
┌─────────────────────────────────────────┐
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
│ dependency graphs, comparison tables │
│ │
└─────────────────────────────────────────┘
```
**Surface risks and unknowns**
- Identify what could go wrong
- Find gaps in understanding
- Suggest spikes or investigations
---
## OpenSpec Awareness
You have full context of the OpenSpec system. Use it naturally, don't force it.
### Check for context
At the start, quickly check what exists:
```bash
openspec list --json
```
This tells you:
- If there are active changes
- Their names, schemas, and status
- What the user might be working on
### When no change exists
Think freely. When insights crystallize, you might offer:
- "This feels solid enough to start a change. Want me to create a proposal?"
- Or keep exploring - no pressure to formalize
### When a change exists
If the user mentions a change or you detect one is relevant:
1. **Read existing artifacts for context**
- `openspec/changes/<name>/proposal.md`
- `openspec/changes/<name>/design.md`
- `openspec/changes/<name>/tasks.md`
- etc.
2. **Reference them naturally in conversation**
- "Your design mentions using Redis, but we just realized SQLite fits better..."
- "The proposal scopes this to premium users, but we're now thinking everyone..."
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | `specs/<capability>/spec.md` |
| Requirement changed | `specs/<capability>/spec.md` |
| Design decision made | `design.md` |
| Scope changed | `proposal.md` |
| New work identified | `tasks.md` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
- "This is a new requirement. Add it to specs?"
- "This changes scope. Update the proposal?"
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
---
## What You Don't Have To Do
- Follow a script
- Ask the same questions every time
- Produce a specific artifact
- Reach a conclusion
- Stay on topic if a tangent is valuable
- Be brief (this is thinking time)
---
## Handling Different Entry Points
**User brings a vague idea:**
```
User: I'm thinking about adding real-time collaboration
You: Real-time collab is a big space. Let me think about this...
COLLABORATION SPECTRUM
════════════════════════════════════════════
Awareness Coordination Sync
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Presence│ │Cursors │ │ CRDT │
│ "3 │ │ Multi │ │Conflict│
│online" │ │ select │ │ free │
└────────┘ └────────┘ └────────┘
│ │ │
trivial moderate complex
Where's your head at?
```
**User brings a specific problem:**
```
User: The auth system is a mess
You: [reads codebase]
┌─────────────────────────────────────────────┐
│ CURRENT AUTH FLOW │
└─────────────────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Google │ │ GitHub │ │ Email │
│ OAuth │ │ OAuth │ │ Magic │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└─────────────┼─────────────┘
▼
┌───────────┐
│ Session │
└─────┬─────┘
│
▼
┌───────────┐
│ Perms │
└───────────┘
I see three tangles. Which one's burning?
```
**User is stuck mid-implementation:**
```
User: /opsx:explore add-auth-system
The OAuth integration is more complex than expected
You: [reads change artifacts]
You're on task 4: "Implement OAuth flow"
Let me trace what's involved...
[draws diagram, explores options, suggests paths]
Want to update the design to reflect this?
Or add a spike task to investigate?
```
**User wants to compare options:**
```
User: Should we use Postgres or SQLite?
You: Generic answer is boring. What's the context?
User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
• No daemon running
• Must work offline
• Single user
SQLite Postgres
Deployment embedded ✓ needs server ✗
Offline yes ✓ no ✗
Single file yes ✓ no ✗
SQLite. Not even close.
Unless... is there a sync component?
```
---
## Ending Discovery
There's no required ending. Discovery might:
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
- **Result in artifact updates**: "Updated design.md with these decisions"
- **Just provide clarity**: User has what they need, moves on
- **Continue later**: "We can pick this up anytime"
When it feels like things are crystallizing, you might summarize:
```
## What We Figured Out
**The problem**: [crystallized understanding]
**The approach**: [if one emerged]
**Open questions**: [if any remain]
**Next steps** (if ready):
- Create a change proposal
- Keep exploring: just keep talking
```
But this summary is optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,110 @@
---
name: openspec-propose
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
license: MIT
compatibility: Requires openspec CLI.
metadata:
author: openspec
version: "1.0"
generatedBy: "1.3.1"
---
Propose a new change - create the change and generate all artifacts in one step.
I'll create a change with artifacts:
- proposal.md (what & why)
- design.md (how)
- tasks.md (implementation steps)
When ready to implement, run /opsx:apply
---
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
**Steps**
1. **If no clear input provided, ask what they want to build**
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
> "What change do you want to work on? Describe what you want to build or fix."
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
2. **Create the change directory**
```bash
openspec new change "<name>"
```
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
3. **Get the artifact build order**
```bash
openspec status --change "<name>" --json
```
Parse the JSON to get:
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
- `artifacts`: list of all artifacts with their status and dependencies
4. **Create artifacts in sequence until apply-ready**
Use the **TodoWrite tool** to track progress through the artifacts.
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
a. **For each artifact that is `ready` (dependencies satisfied)**:
- Get instructions:
```bash
openspec instructions <artifact-id> --change "<name>" --json
```
- The instructions JSON includes:
- `context`: Project background (constraints for you - do NOT include in output)
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
- `template`: The structure to use for your output file
- `instruction`: Schema-specific guidance for this artifact type
- `outputPath`: Where to write the artifact
- `dependencies`: Completed artifacts to read for context
- Read any completed dependency files for context
- Create the artifact file using `template` as the structure
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
- Show brief progress: "Created <artifact-id>"
b. **Continue until all `applyRequires` artifacts are complete**
- After creating each artifact, re-run `openspec status --change "<name>" --json`
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
- Stop when all `applyRequires` artifacts are done
c. **If an artifact requires user input** (unclear context):
- Use **AskUserQuestion tool** to clarify
- Then continue with creation
5. **Show final status**
```bash
openspec status --change "<name>"
```
**Output**
After completing all artifacts, summarize:
- Change name and location
- List of artifacts created with brief descriptions
- What's ready: "All artifacts created! Ready for implementation."
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
**Artifact Creation Guidelines**
- Follow the `instruction` field from `openspec instructions` for each artifact type
- The schema defines what each artifact should contain - follow it
- Read dependency artifacts for context before creating new ones
- Use `template` as the structure for your output file - fill in its sections
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
- These guide what you write, but should never appear in the output
**Guardrails**
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
- Always read dependency artifacts before creating a new one
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
- If a change with that name already exists, ask if user wants to continue it or create a new one
- Verify each artifact file exists after writing before proceeding to next

131
.gitattributes vendored Normal file
View File

@@ -0,0 +1,131 @@
# Define macros (only works in top-level gitattributes files)
[attr]lfs filter=lfs diff=lfs merge=lfs -text
# 3D models
*.collada lfs
*.dae lfs
*.dxf lfs
*.FBX lfs
*.fbx lfs
*.jas lfs
*.lws lfs
*.lxo lfs
*.obj lfs
*.ply lfs
*.skp lfs
*.stl lfs
*.stp lfs
*.usd lfs
*.usda lfs
*.usdc lfs
*.usdz lfs
*.glb lfs
*.gltf lfs
# Audio
*.aif lfs
*.aiff lfs
*.it lfs
*.mod lfs
*.mp3 lfs
*.ogg lfs
*.s3m lfs
*.wav lfs
*.xm lfs
# Video
*.asf lfs
*.avi lfs
*.flv lfs
*.mov lfs
*.mp4 lfs
*.mpeg lfs
*.mpg lfs
*.ogv lfs
*.wmv lfs
*.mkv lfs
# Images
*.bmp lfs
*.exr lfs
*.gif lfs
*.hdr lfs
*.iff lfs
*.jpeg lfs
*.jpg lfs
*.pict lfs
*.png lfs
*.psd lfs
*.tga lfs
*.tif lfs
*.tiff lfs
*.webp lfs
# Compressed Archive
*.7z lfs
*.bz2 lfs
*.gz lfs
*.rar lfs
*.tar lfs
*.zip lfs
# Compiled Dynamic Library
*.dll lfs
*.pdb lfs
*.so lfs
# Fonts
*.otf lfs
*.ttf lfs
*.woff lfs
*.woff2 lfs
# Executable/Installer
*.apk lfs
*.exe lfs
# Documents
*.pdf lfs
*.doc lfs
*.xls lfs
*.ppt lfs
*.docx lfs
*.xlsx lfs
*.pptx lfs
# Adobe
*.psd lfs
*.ai lfs
*.aep lfs
*.prproj lfs
*.spp lfs
# Zbrush
*.zbr lfs
*.zpr lfs
*.ztl lfs
*.c4d lfs
# Autodesk
*.max lfs
*.ma lfs
*.mb lfs
*.3dm lfs
*.3ds lfs
# Blender
*.blend lfs
*.blend1 lfs
*.c4d lfs
# Imaging
*.dicom lfs
*.dcm lfs
*.nii lfs
*.ome lfs
*.map lfs
*.mrc lfs
# Others
*.abr lfs
*.tpl lfs

73
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Release
on:
push:
tags:
# Publish on any tag starting with a `v`, e.g., v1.0.1
- "v*"
permissions:
contents: write
jobs:
check_version:
name: Check Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.read_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.11"
- name: Check tag matches project version
id: read_version
run: |
version="$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
echo "Tag $GITHUB_REF_NAME does not match pyproject.toml version $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
build_extensions:
name: Build Extensions
needs: check_version
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.11"
- name: Build node assets
run: uv run --group blender node-group build --skip-tests
- name: Run tests
run: uv run --group blender pytest tests/anyimage tests/nodes tests/tools
- name: Pack extensions
run: uv run pack
- name: Check extension archives
run: |
for platform in windows-x64 macos-arm64 linux-x64; do
archive="dist/AnyImage.v${{ needs.check_version.outputs.version }}.${platform}.zip"
if [ ! -f "$archive" ]; then
echo "Missing archive: $archive" >&2
exit 1
fi
done
- name: Publish release
uses: https://gitea.com/actions/gitea-release-action@v1.3.6
with:
files: |-
dist/*.zip

29
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Test
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.11"
- name: Build node assets
run: uv run --group blender node-group build --skip-tests
- name: Run tests
run: uv run --group blender pytest tests/anyimage tests/nodes tests/tools

395
.gitignore vendored Normal file
View File

@@ -0,0 +1,395 @@
# Tempary files
temp
sandbox.*
/notes/
# Environments
.env
.envrc
.venv
.env.*
!.env.example
env/
venv/
ENV/
env.bak/
venv.bak/
.secrets
.secrets.*
!.secrets.example
secrets/
SECRETS/
secrets.bak/
# Claude Code
.claude/settings.local.json
.claudian
# Obsidian
.obsidian/*
!.obsidian/snippets
!.obsidian/app.json
*.canvas
# Keep odox for extract to openwiki
!.obsidian/plugins
.obsidian/plugins/*
!.obsidian/plugins/odox
# Blender
*.blend1
blender_assets.cats.txt~
blendcache_*
/src/anyimage/assets/O_AnyImage.blend
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
models/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

111
AGENTS.md Normal file
View File

@@ -0,0 +1,111 @@
# AnyImage 项目规范
## 总则
**不管是代码还是文档,遵循「少就是多」的原则,以好理解为首要要求**
- 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文
- 修改代码、依赖或执行方式后,
- 不保留旧路径、转发函数、兼容层或胶水代码
- 仅更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展
- 优先选择依赖少、直接、易验证的实现
- 复用来自 `common` 的函数,避免重复造轮子
- 确保文件名/文件夹名和内容边界一致
## 全面整理代码
当用户说「全面整理代码」时,依据本文件规范,直接执行以下整理:
- 按业务职责梳理代码结构与命名;简单功能用单文件,复杂 Operator 功能包统一以 `operators.py` 为入口,`__init__.py` 负责导出与注册清单。
- 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。
- 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。
- 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。
- 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或打包扩展。
## 查询文档
下列是重要文档
- [总览](docs/internals/index.md):内部实现的总揽
- [程序架构](docs/internals/architecture.md):进程边界、职责与调用主链
- [Operator 索引](docs/internals/operators/index.md):Operator 实现功能和对应源码
- [节点资产](docs/internals/node-assets.md):节点组的构建与验证(节点组不在运行时新建)
## 常见命令
```bash
# 安装或同步开发依赖
uv sync
# 本地 BlendJob wheel 内容变化后刷新锁文件
uv lock --refresh
# 运行全部测试
uv run --group blender --group ai pytest
# 构建 Windows、macOS、Linux 扩展包到 dist/
uv run pack
# 本地构建多版本静态页面(只提交到本地 gh-pages 分支,不推送)
uv run --group docs docs build
# 本地热更新预览当前文档
uv run --group docs docs dev
# 用 pyproject.toml 中的 major.minor.x 发布文档并让 latest 指向它
uv run --group docs docs deploy
# 构建并验证 Blender 节点资产
uv run --group blender node-group build
# 准备、校验并同步 ONNX 模型到 R2
uv run --group models model sync
```
## 命名规范
- 变量、函数、Docstring 必须使用英文;同一概念在代码和文档中使用统一表达
- 函数的动作前缀应表达产物边界:`generate_*` 表示 AI 生成。`build_*` 表示代码层构建。`create_*` 表示 Blender 内创建
- 名称应说明业务职责,并与用户概念、公开协议和界面术语保持一致;不要使用脱离上下文后无法判断用途的宽泛名称
- 文件和目录名应表达稳定的业务分类和模块职责,不直接沿用函数名,也不按临时执行步骤命名
- 集合层使用复数,单项分类使用单数
- Operator Class 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground`
- Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `AnyImageImageMenu`、`ServerPanel`、`AnyImagePreferences`、`AnyImageSettings`
- `bl_idname` 使用 `anyimage` 项目前缀:
- Operator 如 `anyimage.convert_to_depth_plane`
- Menu 如 `ANYIMAGE_MT_image_context`
- Panel 如 `ANYIMAGE_PT_server`
- 公共 Property 如 `Scene.anyimage_settings`
- Operator 自身 Property 不加项目前缀,使用业务名称,如 `input_path`、`process_res`、`resolution_level`
- 扩展包名使用 Manifest 中的真实名称、版本和平台,例如 `AnyImage.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称
## 文档规范
- `README.md` 面向用户,不在其中堆叠内部细节。实现原理放入 `docs/internals` 对应主题
- 文档只正面描述当前主题的职责与实现,不记录无关模块、未采用路径、历史差异、边界辩解、“不做什么”的排除性信息。
- 根据内容选择最清晰的表达方式,优先简洁文字;复杂流程或关系在有助于理解时使用图表。Mermaid 流程图默认从上到下。
- 文档版本只使用 `pyproject.toml` 中的 major.minor,写成 `x.y.x` 系列标识,不写具体 patch;`uv run docs build` 和 `uv run docs deploy` 都构建到该版本并更新 `latest`
- mike 默认只提交不推送,`--push` 不能省;内容没有变化时 mike 会跳过提交并连带跳过推送,所以 `uv run docs deploy` 额外带 `--allow-empty`;mike 默认推送到 `origin`,本仓库的远端名是 `omoolab`
## Blender 开发规范
- 扩展级设置及其读取入口统一放在 `preferences.py`;使用 `addon_preferences()` 读取
- Job 参数使用可序列化的普通字典;Blender 数据只能在主线程的 Operator 响应阶段读写
- 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试
- 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试
- `server/` 是 blendjob 后端代码,无法引用来自它上级文件夹的内容。共享函数需单独实现。
## Blender 节点组规范
- 节点组源码位于顶层 `nodes/`,`tools/nodes` 只负责构建、校验、布局和预览。
- `src/anyimage/assets/O_AnyImage.blend` 是发布构建产物,由节点源码生成且不得提交到 Git。
- 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。
- `nodes/` 构建代码和节点测试允许通过代码创建节点组。
- 字段默认直接连接,按消费位置的几何、域、类型及输入属性版本验证求值等价性;多个消费者可复用同一输出。
- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute。域转换显式表达;跨几何变化优先调整消费顺序或在明确源几何上采样。
- Store Named Attribute 仅用于实际命名协议或经论证仍需跨几何变化保存的最少数据;明确名称、类型、域、Selection、写入阶段及消费者。多个消费者或跨区域连线不构成存值理由。
- 内部临时命名属性使用专用名称,在最后消费者之后精确清理,保留用户属性;源码和保存资产均检查 Capture 为零。
- 节点布局通过区域内输入、字段排列和必要的 Reroute 保持可读;存储须有数据生命周期依据,构建阶段通过明确的节点或 socket 引用接线。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run --group blender node-group build`,无需另行确认;生成资产并验证通过才算完成。
- 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。

8
LICENSE Normal file
View File

@@ -0,0 +1,8 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
This project is licensed under the GNU General Public License,
version 3 or any later version.
The complete license text is available at:
https://www.gnu.org/licenses/gpl-3.0.txt

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# AnyImage
把图片里的空间感带进 Blender。
AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。
- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片粘贴到当前编辑器
- 用 **Plane** 创建可调厚度和细分的图片网格
- 用 **Depth Plane** 把静态图片转换为可调节的立体面片
- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape
- 用 **Frame** 从当前视角重新取景并合并 Image Empty,用 **Mask** 的 Lasso、Brush 或 Polyline 编辑 Alpha;Brush 拖动时以红色或灰色填充预览准确范围,释放后提交;用 **Rectify** 校正透视
- 用 **Remove Background / Upscale** 处理单图、视频和序列帧

BIN
docs/assets/favicon.png LFS Normal file

Binary file not shown.

BIN
docs/assets/fonts/fontutti.woff2 LFS Normal file

Binary file not shown.

BIN
docs/assets/logo.png LFS Normal file

Binary file not shown.

View File

@@ -0,0 +1,80 @@
# 安装与首次配置
页面会按当前系统显示对应的安装包,按住按钮拖到 Blender 窗口任意位置即可安装。首次拖入会提示添加 AnyImage 扩展仓库,确认后重新拖入一次,之后升级同样直接拖入。
<div class="anyimage-install">
<div class="anyimage-install-item" data-platform="windows-x64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.windows-x64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=windows-x64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>Windows</strong> – 78.3&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.windows-x64.zip" download="AnyImage.v1.0.1.windows-x64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
<div class="anyimage-install-item" data-platform="macos-arm64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.macos-arm64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=macos-arm64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>macOS<span class="anyimage-install-platform-rest"> - Apple Silicon</span></strong> – 57.5&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.macos-arm64.zip" download="AnyImage.v1.0.1.macos-arm64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
<div class="anyimage-install-item" data-platform="linux-x64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.linux-x64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=linux-x64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>Linux</strong> – 71.0&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.linux-x64.zip" download="AnyImage.v1.0.1.linux-x64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
</div>
## 检查扩展偏好设置
AnyImage 的扩展偏好设置按功能分组。Environment 区包含:
- **Storage Root**:保存运行环境、模型和任务文件的位置。默认是用户目录下的 `.anyimage`,一般无需修改。
- **Device**:正式转换任务使用的计算设备。Apple Silicon Mac 默认使用 CPU,Windows 默认使用 DirectML;也可手动选择其他可用设备。
macOS arm64 提供 **CPU** 与 **CoreML**。CoreML 由系统在 Apple CPU、GPU 与 Neural Engine 之间调度,不是 PyTorch MPS backend,也不表示只使用 GPU;它的实际速度取决于模型。
Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中:
- **Default Upscale Model**:Upscale 使用的模型,默认是 General WDN x4v3。
- **Maximum AI Input Size**:Upscale、Depth 与 Normal Map 的 AI 输入长边上限,默认 2048 px;颜色贴图保留原始分辨率。
- **Maximum Cutout Mesh Resolution**:所有 Cutout Shape 的网格采样长边上限,默认 1024,独立于 AI 输入尺寸。
Depth & Normal 提供三档 MoGe-2,Depth Plane 与 Cutout Depth / Normal Map 共用当前选择。可选模型的 **Ready** 状态或 **Download** 按钮位于选择项下方;Cutout 区提供独立网格上限与 Balloon Profile。
如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。
## 配置 AI Environment
选中 Image Empty,在右键 **AnyImage** 子菜单中点击 **Set Up AI Environment…**;也可以在 AnyImage 扩展偏好设置中点击同名按钮。
确认安装前,对话框会说明以下内容:
- 安装独立 Python、ONNX Runtime 与支持包,并下载 **MoGe-2 ViT-S Normal**、**BEN2 Base** 和 **Real-ESRGAN General WDN x4v3**。
- 默认模型合计约 360 MB;连同 Environment 预计需要约 600 MB 下载量和约 1 GB 磁盘空间。
- 根据网络速度,首次安装通常需要 5–20 分钟。
- **Use Mirror** 只让 uv、Python 和 Python 包使用国内镜像;模型下载源保持不变。
安装过程中请保持 Blender 打开。状态栏会显示当前阶段、进度和下载速度。
如果 Environment 已安装但任一默认模型缺失,入口会改为 **Download Required Models…**;对话框列出缺失模型和下载体积,确认后只下载缺失模型,不会重装 Environment 或重复下载已就绪内容。
## 确认安装完成
完成后可以用以下方式确认:
- AnyImage 扩展偏好设置中的 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3 显示 **Ready**。
- 选中 Image Empty 后,右键菜单底部显示 **AI Environment Settings…**。
点击 **Convert to Depth Plane**、**Remove Background** 或 **Upscale** 时,AnyImage 会根据状态进入 **Set Up AI Environment…** 或 **Download Required Models…**。普通 Plane、Image 编辑和基础 Cutout Surface / Balloon 直接在 Blender 中执行。
## 下一步
继续阅读[五分钟上手](quick-start.md),用一张图片完成第一次转换。

60
docs/index.md Normal file
View File

@@ -0,0 +1,60 @@
# 🖼️ AnyImage
**IMAGE is All You Need**
*图,就够了~*
AnyImage 是 OmooLab 为 Blender 准备的图片工具。它让图片不再只是参考图:粘贴进来,选出需要的部分,再把它变成带材质、轮廓与深度的场景元素。
## 从图片直接开始
在 3D View 或 Node Editor 中按 **Ctrl+V**(macOS 为 **Cmd+V**),剪贴板里的图片会直接进入当前工作区。不必先保存文件,也不用中断正在进行的搭建。
已有图片同样可以继续使用。选中 Image Empty 后,右键打开 **AnyImage** 菜单,常用操作都在这里。
## 一张图,多种空间表达
- **Plane / Depth Plane**:把图片变成平面或立体面片。
- **Cutout**:圈选主体,创建 Surface、Balloon 或 Depth Shape。
- **图片工具**:重新取景、编辑透明区域、校正透视、去背景和高清放大。
## 快速开始
按住下方按钮并拖到 Blender 窗口即可安装。首次拖入会提示添加 AnyImage 扩展仓库,确认后再拖入一次。
<div class="anyimage-install">
<div class="anyimage-install-item" data-platform="windows-x64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.windows-x64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=windows-x64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>Windows</strong> – 78.3&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.windows-x64.zip" download="AnyImage.v1.0.1.windows-x64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
<div class="anyimage-install-item" data-platform="macos-arm64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.macos-arm64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=macos-arm64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>macOS<span class="anyimage-install-platform-rest"> - Apple Silicon</span></strong> – 57.5&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.macos-arm64.zip" download="AnyImage.v1.0.1.macos-arm64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
<div class="anyimage-install-item" data-platform="linux-x64">
<div class="anyimage-install-group">
<button type="button" draggable="true" class="anyimage-install-button" data-install-url="https://extensions.omoolab.xyz/AnyImage.v1.0.1.linux-x64.zip?repository=.%2Findex.json&amp;blender_version_min=4.5.0&amp;platforms=linux-x64&amp;python_versions=3.11%2C3.13">
<i class="i-move"></i><span>拖入 Blender 安装</span>
</button>
<div class="anyimage-install-platform"><strong>Linux</strong> – 71.0&nbsp;MB</div>
</div>
<small class="anyimage-install-note">…或<a href="https://extensions.omoolab.xyz/AnyImage.v1.0.1.linux-x64.zip" download="AnyImage.v1.0.1.linux-x64.zip">下载</a>后通过 <a href="https://docs.blender.org/manual/zh-hans/latest/editors/preferences/extensions.html#install" target="_blank">Install from Disk</a> 安装</small>
</div>
</div>
复制一张图片。在 Blender 的 3D View 中按 **Ctrl+V**(macOS 为 **Cmd+V**)。
选中图片并右键,打开 **AnyImage**,选择 **Convert to Plane**。
使用 Depth Plane、Remove Background 或 Upscale 时,按提示安装 AI Environment 即可。

View File

@@ -0,0 +1,34 @@
# 程序架构
| 模块 | 职责 |
| --- | --- |
| `__init__.py` | 注册类型、工具、菜单、快捷键与 Runtime |
| `menu.py`、`panel.py` | Image Empty 与 Shader Image Texture 上下文菜单与 Job Server 面板 |
| `preferences.py` | 扩展设置及读取入口 |
| `properties.py` | Scene 手势状态、模型状态查询、枚举与 Operator 属性构造 |
| `common/` | Blender 侧公共能力 |
| `operators/` | 交互、任务参数、结果导入和 Undo |
| `server/` | 独立环境内的媒体处理、推理和产物写出 |
## 注册与配置
注销先关闭 Runtime、快捷键与工具,再移除菜单和 Scene 属性,按 `CLASSES` 逆序注销类型。设备、存储目录、模型、材质显示适配和功能上限统一通过 `preferences.py` 读取。
**Maximum AI Input Size** 限制模型分析输入和 Upscale 可提交源图的长边;**Maximum Frame Resolution** 限制 Frame 输出长边;**Minimum Relative Edge Length** 与 **Boundary Padding** 控制 Cutout 的网格密度和轮廓边界采样。
## 执行边界
```mermaid
flowchart TD
A[Operator 在主线程读取 Blender 数据] --> B[准备输入文件]
B --> C[request 返回普通字典]
C --> D[BlendJob 队列执行 Server Job]
D --> E[模型与媒体处理写入 Job 目录]
E --> F[主线程 response 加载结果]
F --> G[提交 Image 或 Object 修改及 Undo]
G --> H[清理临时输入]
```
Plane、Frame、Mask、Rectify 和无需 AI 的 Cutout 在 Blender 内执行。Server 的 `jobs/` 编排任务,`models/` 负责模型适配,`geometry/` 处理深度与法线产物。Server 模块通过内部相对导入共享能力,Blender 侧通过 `common/` 复用。
MoGe-2 与 MoGe-3 输出统一为 `GeometryFrame`:包含 Depth、Validity、Intrinsics,以及按需提供的 Normal 和 Points。产物写入器消费同一个 Frame,保持深度、法线和相机数据一致。参见 [Runtime](runtime.md) 与 [产物协议](outputs.md)。

44
docs/internals/common.md Normal file
View File

@@ -0,0 +1,44 @@
# Blender 公共能力
`src/anyimage/common/` 按业务职责提供公共函数,调用方从对应模块直接导入。
| 模块 | 职责与主要入口 |
| --- | --- |
| `ai.py` | 环境和模型就绪检查、输入路径校验、`production_device()`、`moge2_parameters()` 与安装 UI |
| `image.py` | `image_rgba()`、Image User 帧设置读取、Job 输入准备、像素采样、结果 Image 和 `replace_empty_image()` |
| `color_image.py` | 材质颜色与 AI 分析输入的准备和清理 |
| `hdr_image.py` | HDR 去背景分析输入及浮点颜色保留 |
| `image_target.py` | Image Empty 与 Shader Image Texture 编辑目标 |
| `selection.py` | `SelectionPath`、`SelectionMask`、non-zero winding 栅格化与 Alpha 修整 |
| `viewport.py` | 原生点选、屏幕投影、`ImageGesture`、Lasso / Brush / Polyline 与 Overlay |
| `depth.py` | 深度图片和元数据加载、相机坐标与显示范围的尺度换算 |
| `material.py` | Image Layer、Normal、Alpha、Shadeless 与 Depth 材质 |
| `object.py` | Modifier 输入设置与结果对象激活、源对象替换 |
| `node.py` | `node_asset_path()` 与 `load_node_group()` |
## 图像生命周期
业务像素采用 Top-down RGBA,`image_pixels()` 使用 Blender 像素顺序。预乘采样用于投影插值;文件或 Packed 图像的业务读取保留隐藏 RGB。`ImageEditTarget` 校验目标身份后经 `replace_empty_image()` 或 `replace_texture_image()` 提交:未共享的 Image 原地替换内容,共享时改用新的 Image 数据块,失败时回滚源数据。
`prepare_image_input()` 返回路径及临时标记;`cleanup_image_input()` 与创建方配对。`color_image.py` 的 `prepare_material_color_input()` 准备材质颜色,`material_analysis_input()` 提供分析输入;Cutout 按几何 bounds 裁切。
## 手势与 Selection
```mermaid
flowchart TD
A[原生对象点选] --> B[ImageGesture 冻结源图与交互状态]
B --> C[屏幕路径与 Overlay]
C --> D[提交时投影到图片空间]
D --> E[SelectionPath]
E --> F[局部 non-zero winding 栅格化]
F --> G[SelectionMask values 与 bounds]
G --> H[Mask Alpha 运算或 Cutout 内容轮廓]
```
Brush 的圆形印记和连接条由 `brush_footprint_polygons()` 生成,供预览与提交共用。拖动时维护屏幕路径与局部 Overlay,提交时生成覆盖值。Mask、Rectify 与 Cutout 的空命中保留 active Image Empty;点中另一对象时完成原生选择。
## 材质、节点与对象
`create_image_material()` 按 Shadeless、Normal 和 Depth 组合节点、连接颜色与 Alpha;材质显示适配读取当前 Preferences。`set_modifier_input()` 按实际 RNA 输入形式设置值,`finalize_object_result()` 完成结果对象激活和源对象处理。Image 替换路径保留 Empty 的矩阵、显示尺寸、偏移和 Image User 帧设置。
Geometry Nodes 从 `src/anyimage/assets/O_AnyImage.blend` 按公开名称加载。构建与布局职责见[节点资产](node-assets.md),深度字段见[Job 产物](outputs.md)。

View File

@@ -0,0 +1,48 @@
# 开发与发布
仓库使用 CPython 3.11 与 `bpy==4.5.3` 测试 Blender 代码;AI Server 使用 `runtime.py` 声明的独立 Python 3.12 Environment。
```bash
uv sync
uv run pytest
```
## 测试组织
| 测试范围 | 文件 |
| --- | --- |
| 扩展注册、设置、安装和 Job 交互 | `test_addon_registration.py`、`test_preferences.py`、`test_ai_setup.py`、`test_job_operator.py` |
| Frame、Mask、Rectify、Plane、Cutout | 对应 `test_*_tool.py`、`test_*_operators.py` 与对象、投影、网格测试 |
| 图像身份、Alpha、材质与 Undo | 图像编辑集成、共享替换、材质适配与 Undo 测试 |
| Server | `test_job_server.py`、`test_server_jobs.py`、`test_input_validation.py`、`test_geometry_artifacts.py` |
| 模型 | `test_model_manager.py`、下载、推理与各 ONNX 模型测试 |
| 节点资产 | 布局与接口等价、几何求值、Cutout 与临时属性清理 |
| 工具与包协议 | 扩展 Manifest、打包、模型同步与节点启动器 |
Blender Stub 共用 `tests/support/blender.py`;Server 和实际 Blender 几何 fixture 按使用范围存放在对应 support 模块。测试命名描述功能、触发条件与可观察结果。
## 发布命令
| 命令 | 职责 |
| --- | --- |
| `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 |
| `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest |
| `uv run --group blender node-group build` | 构建、验证节点资产并运行节点测试 |
| `uv run model sync` | 准备、校验并上传模型声明的文件 |
| `uv run docs -dev` | 本地预览文档 |
| `uv run docs` | 通过 Mike 发布项目版本并更新 latest |
`tools/pack.py` 从 `pyproject.toml` 读取依赖,按平台与 Blender Python 版本解析 wheel,使用 Blender 提供的 NumPy。产物命名为 `AnyImage.v<version>.<platform>.zip`,写入 `dist/`。本地 BlendJob wheel 内容变化后运行 `uv lock --refresh`,并核对锁文件、Manifest 与打包测试。
`tools/models/cli.py` 根据模型目录选择上游 ONNX 或本地导出流程;`tools/models/exports.py` 处理对应 PyTorch 权重导出。文件校验依据声明大小和 SHA-256,上传使用逐文件复制。`uv run model prepare` 用于本地准备。
## CI 与文档导航
```mermaid
flowchart TD
A[main push、Pull Request 或手动触发] --> B[test.yml 运行测试]
C[v 前缀 Tag] --> D[build.yml 测试、打包并发布 ZIP]
E[main 文档路径变化或手动触发] --> F[docs.yml 构建站点并部署到 Cloudflare Workers]
```
站点导航由 `mkdocs.yml` 显式维护,页面移动时同步导航和相对链接。节点资产的脚本职责及检查入口见[节点资产](node-assets.md)。

15
docs/internals/index.md Normal file
View File

@@ -0,0 +1,15 @@
# 内部实现总览
AnyImage 在 Blender 主线程处理交互、像素编辑和数据创建,通过 BlendJob 把模型推理交给独立 Job Server。
| 主题 | 对应职责 |
| --- | --- |
| [程序架构](architecture.md) | 进程、注册、配置与主线程边界 |
| [Common](common.md) | Blender 侧图像、手势、几何与 AI 公共能力 |
| [Runtime 与 Server](runtime.md) | Environment、Job、模型下载与 Session 生命周期 |
| [Job 产物](outputs.md) | 文件协议、相机坐标和 Blender 消费方式 |
| [Operator 索引](operators/index.md) | 按实际用户功能查找实现 |
| [节点资产](node-assets.md) | 资产、源码职责、构建和验证 |
| [开发与发布](development.md) | 测试组织、工具命令与发布流程 |
源码位于 `src/anyimage/`,开发工具位于 `tools/`,自动化测试位于 `tests/`。扩展级设置由 `preferences.py` 定义,运行时资源由 `runtime.py` 接入 BlendJob。

View File

@@ -0,0 +1,50 @@
# 节点资产
## 资产清单
节点组由 `nodes/groups/` 中的构建模块定义,共用构建逻辑位于 `nodes/common/`。
| 节点组 | 构建模块 |
| ------------------------- | --------------------------- |
| `O Image Plane` | `image_plane.py` |
| `O Image Depth Plane` | `image_depth_plane.py` |
| `O Image Relief Plane` | `image_relief_plane.py` |
| `O Image Cutout` | `image_cutout.py` |
| `O Image Depth Cutout` | `image_depth_cutout.py` |
| `O Image Cutout Symmetry` | `image_cutout_symmetry.py` |
| `O Image Layer` | `image_layer.py` |
| `O Image Depth Layer` | `image_depth_layer.py` |
| `O Shadeless` | `shadeless.py` |
| `O Image Depth Panorama` | `image_depth_panorama.py` |
## 构建节点资产
生产节点组由 `uv run --group blender node-group build` 构建到 `src/anyimage/assets/O_AnyImage.blend`。该文件是 Git 忽略的发布构建产物;CI 与本地发布都从节点源码重新生成。
节点组规则:
- 字段默认直接连接,多个消费者可复用同一输出;按消费位置的几何、域、类型和输入属性版本验证求值等价性
- 所有几何节点、嵌套组及测试辅助图禁止使用 Capture Attribute;构建定义和保存资产递归检查 Capture 为零
- 域转换使用 Evaluate on Domain 或明确域的采样节点表达;跨几何变化优先调整消费顺序或在源几何上采样,明确索引对应关系
- Store Named Attribute 用于实际命名协议或仍需跨几何变化保存的最少数据,逐项明确名称、类型、域、Selection、写入阶段、消费者及必要性;内部临时属性使用 `_o_` 前缀,在最后消费者之后用通配符 `_o_*` 清理,保留 UV、用户属性和外部协议属性。清理必须走 `remove_attribute_pattern`:按精确名称删除时,若某个几何分支从未写入该属性,Remove Named Attribute 会报出缺失警告
- Group Input 按区域重复放置,通过字段排列和必要的 Reroute 保持连线可读,隐藏未使用输出;存储须有数据生命周期依据
- 构建阶段通过明确的节点或 socket 引用接线
- 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域
- 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见
- 比较统一用 Compare 节点并按操作数数据类型选择输入,布尔运算统一用 Boolean Math,不用 Math 节点做比较、也不用数值乘法代替布尔运算;材质树不支持这两个节点,仍用 Math
- 修改器 Geometry Nodes 的输入 socket 默认使用 `SINGLE`
- socket `subtype` 按业务语义设置:长度用 `DISTANCE`,`0–1` 比例用 `FACTOR`,`0–100` 用 `PERCENTAGE`;无对应范围语义时保留默认
- 接口或布局变更须验证 socket 顺序与结构、未使用输出隐藏及几何求值;纯布局变更比较运算、默认值和实际连接,确认功能不变
## 构建资产脚本
`tools/nodes/build.py` 按依赖顺序加载 `nodes/groups/` 的定义,调用 `arrangement/` 统一排列几何节点组后保存。`nodes/common/` 提供共用构建函数:`nodes.py` 创建接口与节点,`depth_surface.py`、`cutout.py` 等组装几何,`shader.py` 构建材质着色节点,`material_slots.py` 继承几何材质槽;`tools/nodes/preview/` 导出交互预览。
命令通过当前 uv Python 的独立子进程执行构建、检查和预览,`bpy` 由 `blender` dependency group 提供。保存后,`check.py` 重新加载资产并做基本几何求值;`tests/nodes/` 覆盖节点定义行为,`tests/tools/nodes/` 覆盖工具行为。CI 使用同一入口先生成资产,再运行常规测试和打包。
```bash
uv run --group blender node-group build
uv run --group blender node-group build --skip-tests
uv run --group blender node-group check
uv run --group blender node-group preview
```

View File

@@ -0,0 +1,42 @@
# AI 环境管理
AI 环境管理从当前安装状态出发,补齐独立运行环境和必需模型,并提供设置入口与模型缓存释放操作。
## 执行过程
```mermaid
flowchart TD
Entry["Set Up AI Server"] --> Status["读取环境与必需模型状态"]
Status --> Ready{"是否全部就绪?"}
Ready -- "是" --> Done["结束"]
Ready -- "否" --> Environment{"运行环境是否就绪?"}
Environment -- "否" --> Install["安装隔离 Python 环境"]
Environment -- "是" --> Download["下载缺失的必需模型"]
Install --> Download --> Done
```
`SetupAIEnvironment` 是统一入口。首次使用时先安装运行环境,再下载默认模型;环境已经存在时,只补齐缺失模型。环境安装由 Blender Operator 启动,模型下载通过 Job Server 执行。
## 节点说明
### 读取状态,选择下一步
入口位于 `src/anyimage/operators/ai_setup.py`。`SetupAIEnvironment` 通过 `properties.py` 中的 `ai_status()` 读取环境和模型状态,并根据结果结束、安装环境或调用 `DownloadRequiredModels`。对话框同时显示预计下载量和缺失模型名称。
`runtime.py` 提供当前 Environment 与 Server 状态;Server 正忙时不会启动新的安装流程。`OpenAIEnvironmentSettings` 打开 AnyImage 的扩展首选项。
### 安装独立环境
环境尚未就绪时,`SetupAIEnvironment` 调用 Runtime 注册的安装 Operator。`use_mirror` 决定 uv、Python 和安装包的下载源,模型使用模型目录中声明的来源。
安装完成后通过 `post_install()` 下载默认模型,模型依赖加载于独立 Server 环境。
### 下载模型
`ai_setup.py` 中的 `DownloadModel` 处理单个模型,`DownloadRequiredModels` 处理默认必需模型集合。两者先检查 Environment、Blender Online Access 和模型记录,再提交 `download-model` 或 `download-required-models` Job。
Job 在 `server/app.py` 注册;`server/model_catalog.py` 保存模型名称、文件和来源,`server/model_download.py` 下载并校验文件,`server/model_manager.py` 管理模型目录与已加载 Session。下载进度和错误通过同一套 BlendJob Runtime 返回 Blender。
### 释放模型缓存
`ai_setup.py` 中的 `ClearModels` 调用 `runtime.clear_resource("model_manager")`,释放 Server 中已加载的模型 Session。下次推理从模型目录重新加载 Session。

View File

@@ -0,0 +1,41 @@
# 剪贴板图片
剪贴板图片从 `Ctrl/Cmd + V` 开始,把操作系统中的图片变成 Packed Blender Image,并放到当前编辑器支持的位置。
## 执行过程
```mermaid
flowchart TD
Paste["Ctrl/Cmd + V"] --> Priority{"刚执行过 Blender Copy?"}
Priority -- "是" --> Native["交回原生 Paste"]
Priority -- "否" --> Read["读取系统图片"]
Read --> Image["复用或创建 Packed Image"]
Image --> Target{"当前编辑器"}
Target --> View["Reference / Plane"]
Target --> Node["Image Node"]
Target --> Paint["Brush Texture"]
```
Windows 快捷键优先处理 Blender 自己的 Copy/Paste,系统剪贴板出现新的图片内容时 AnyImage 接管粘贴;macOS 和 Linux 没有系统剪贴板序列号,始终由 AnyImage 处理粘贴。
## 节点说明
### 判断是否交给 Blender
`operators/clipboard_image/operators.py` 定义 `TrackNativeCopy`、`PasteClipboardImage` 和 3D View、Node Editor 快捷键。`TrackNativeCopy` 记录 Blender Copy 时的系统剪贴板序列号;粘贴时若序列号没有变化,`PasteClipboardImage` 返回 `PASS_THROUGH`,让 Blender 继续处理对象或节点粘贴。
同一文件随后调用 `actions.target_for_context()` 判断当前编辑器是否支持图片落点。
### 读取系统图片
`clipboard.py` 负责平台差异。Windows 依次尝试 PNG、DIBV5 和 DIB,DIB 补成 BMP;macOS 通过 `osascript` 导出 PNG;Linux 依次尝试 `wl-paste` 和 `xclip` 提供的图片格式。各平台输出统一为图片字节和扩展名,再交给后续步骤。
### 创建或复用 Packed Image
`actions.py` 对图片内容计算 SHA-256。相同内容已经粘贴过时会复用现有 Packed Image,否则创建新 Image 并 Pack 到当前 `.blend`,避免依赖临时剪贴板文件。
### 放到当前编辑器
落点仍在 `actions.py` 中完成:3D View 创建 Reference 或 Plane,Node Editor 创建与当前节点树匹配的 Image 节点,绘制模式把 Image 配置为当前画笔的 Texture。鼠标位置和视图位置也在这里换算。
Plane 路径调用 `convert_to_plane/image_plane.py` 创建中心原点基础网格,并从节点资产加载 `O Image Plane`。`actions.py` 创建图像材质并放入 Mesh 第 `0` 个材质槽,节点组只生成几何。

View File

@@ -0,0 +1,14 @@
# Panorama 转换
`operators/convert_to_panorama/` 将静态等距柱状 Image Empty 转换为径向深度表面,提交前裁剪出最大居中 2:1 区域。`operators.py` 提交任务,`object.py` 导入对象,`__init__.py` 汇总导出与注册清单。
```mermaid
flowchart TD
A[准备颜色与分析图] --> B[generate-panorama-geometry]
B --> C[采样 12 个透视视图并推理]
C --> D[融合径向距离与有效性]
D --> E[写出 depth.exr 与 depth.json]
E --> F[创建材质与 O Image Depth Panorama]
```
任务使用 Preferences 选择的几何模型。源颜色由 Blender 侧保存,Server 的 `geometry/panorama.py` 负责视图采样与距离融合,`jobs/panorama.py` 编排推理和产物写入。

View File

@@ -0,0 +1,21 @@
# Plane 转换
`operators/convert_to_plane/operators.py` 提供 `ConvertToPlane`、`ConvertToDepthPlane`、`ConvertToReliefPlane` 与 `GenerateDepthPlane`。`object.py` 导入对象和材质,`image_plane.py` 构建基础矩形网格,`__init__.py` 汇总导出与注册清单。
```mermaid
flowchart TD
A[Image Empty] --> B[准备材质颜色]
B --> C{转换类型}
C -- Plane --> D[四边形与 O Image Plane]
C -- Depth 或 Relief --> E[静态图提交几何 Job]
E --> F[加载深度、元数据与法线]
F --> G[对应节点修改器与材质]
D --> H[创建对象并替换源 Empty]
G --> H
```
三种转换都只接受静态图片。基础网格以局部中心为原点,对象矩阵补偿源 Empty 的显示范围。
Depth 与 Relief 从 Preferences 读取几何模型、分辨率级别和 AI 输入上限。`create_depth_plane_from_result()` 导入产物并创建修改器。`O Image Depth Plane` 使用相机 XYZ 与对象空间法线构建深度表面;`O Image Relief Plane` 使用深度 Z 与切线空间法线构建浮雕。
节点构建见[节点资产](../node-assets.md),文件协议见[Job 产物](../outputs.md)。

View File

@@ -0,0 +1,43 @@
# Cutout
Cutout 在静态 Image Empty 上圈定 Selection,按轮廓创建 Flat、Solid、Depth Symmetry 或 Depth Solid。入口位于 `operators/cutout_tool/operators.py`,Shape 菜单位于 `interaction.py`,预设标识与节点组对应关系位于 `shape.py`。
```mermaid
flowchart TD
A[Lasso 或 Polyline 提交 SelectionPath] --> B[选择 Shape]
B --> C[栅格化 SelectionMask 并读取源 RGBA]
C --> D{需要 Depth 或 Normal?}
D -- 是 --> E[准备 bounds 矩形 RGBA]
E --> F[generate-cutout-artifacts]
F --> G[加载 Depth、Metadata 与 Normal]
D -- 否 --> H[源 Alpha 与 SelectionMask 组合]
G --> H
H --> I[边界映射、轮廓提取与质量三角化]
I --> J[BaseShape 与 o_balloon]
J --> K[创建材质、对象与对应节点修改器]
```
| 预设 | 节点组 | AI 产物 |
| --- | --- | --- |
| Flat | `O Image Cutout`,Thickness 0 | 可选切线空间法线 |
| Solid | `O Image Cutout`,Thickness 1 | 可选切线空间法线 |
| Depth Symmetry | `O Image Depth Cutout` 与 `O Image Cutout Symmetry` | 深度与对象空间法线 |
| Depth Solid | `O Image Depth Cutout` | 深度与对象空间法线 |
Flat 与 Solid 可以不带 AI 产物生成;Depth 预设和 Normal Map 需要就绪的几何模型,Shape 菜单按该状态提供选项。
## 轮廓与网格
`geometry.py` 从内容 Alpha 提取连通轮廓与孔洞,按 Edge Length 生成 `BaseShape`。Edge Length 是世界空间的网格目标边长,Minimum Relative Edge Length 会按内容范围提高其下界。
`mesh.py` 负责轮廓分离、孔洞处理、质量三角化和边界支撑,`polygon.py` 提供轮廓简化,`tangle.py` 是随包分发的 Delaunay 三角化核心。Fine Outline 展开时保留小孔与细节轮廓,关闭时填洞并简化。
`balloon.py` 负责 Balloon Profile,按 Preferences 选择 Poisson 或 Teddy,生成所有预设共用的 POINT 属性 `o_balloon`。`boundary_padding.py` 按 Boundary Padding 沿结构内法线重采样轮廓附近的颜色与深度。
## AI 与对象创建
Server 接收 Selection bounds 内保留源 Alpha 的 RGBA,一次 MoGe 推理按 `generate_depth` 与 `normal_mode` 返回产物。深度参考区域使用所选 Alpha Threshold,Normal 使用原预测;Maximum AI Input Size 只影响分析分辨率。
`object.py` 的 `create_shape_object()` 消费 BaseShape、Color、Depth 和 Normal,加载对应节点组。基础网格位于局部 XZ 平面,厚度沿 Y 展开;对象矩阵把网格放入 Image Empty 的图片平面,Depth Symmetry 额外叠加对称节点组与方向参数。Object Normal 随方向同步转换。
材质使用 IOR 1.2,颜色与法线接入 Image Layer,法线按切线或对象空间解释。节点计算见[节点资产](../node-assets.md)。

View File

@@ -0,0 +1,21 @@
# Frame 多图合成
`operators/frame_tool/operators.py` 定义 `FrameImages` 和 `FrameTool`。`projection.py` 负责视图投影、交叠和结果平面定位,`compositing.py` 负责像素深度排序与边界覆盖采样,`__init__.py` 导出类型及注册清单。Frame 将选中的静态 Image Empty 按当前视图合成为一张图片。
```mermaid
flowchart TD
A[拖出屏幕矩形] --> B[冻结视图矩阵并收集选中静态图片]
B --> C[检查矩形与 active 图片的有效投射交集]
C --> D[按 Region 与 Maximum Frame Resolution 计算输出尺寸]
D --> E[观察射线投影到各图片平面]
E --> F[按样本深度从远到近合成,等深度 active 优先]
F --> G[边界覆盖采样与独立 Alpha]
G --> H[创建 Packed Image 并复用 active Object]
H --> I[删除其他参与图片对象并提交 Undo]
```
正交视图接受有限深度,透视视图采样观察方向前方的交点。交叠检查采用连续投影范围;输出尺寸受 Region、最长边上限和总像素上限约束。
RGB 与 Alpha 分别累计,保留被 Mask 隐藏的有效颜色。边界使用覆盖采样,内部复用直接采样,避免黑边和不必要的模糊。结果平面朝向观察者,位置由 active 图片的有效深度确定。
Frame 保留 active 对象身份、名称和 Collection;图像替换复用 [Common](../common.md) 的共享数据与失败恢复逻辑。

View File

@@ -0,0 +1,16 @@
# Operator 实现索引
| 源码(相对 `src/anyimage/`) | 功能 |
| --- | --- |
| `operators/ai_setup.py` | [AI 环境管理](ai-setup.md) |
| `operators/clipboard_image/` | [剪贴板图片](clipboard-image.md) |
| `operators/convert_to_plane/` | [Plane 与 Depth Plane](convert-to-plane.md) |
| `operators/convert_to_panorama/` | [Panorama](convert-to-panorama.md) |
| `operators/cutout_tool/` | [Cutout](cutout-tool.md) |
| `operators/frame_tool/operators.py` | [Frame 多图合成](frame.md) |
| `operators/mask_tool.py` | [Mask Alpha 编辑](mask.md) |
| `operators/rectify_tool/operators.py` | [Rectify 透视校正](rectify.md) |
| `operators/remove_background.py` | [Remove Background](remove-background.md) |
| `operators/upscale.py` | [Upscale](upscale.md) |
单文件功能按业务命名;复杂功能包在 `operators.py` 定义 Operator 和入口协调逻辑,交互式工具同时定义 WorkSpaceTool,其余模块承载具体实现。包的 `__init__.py` 汇总导出与 `CLASSES` 注册清单,类型在 `operators/__init__.py` 和扩展 `__init__.py` 汇总注册。AI Operator 使用普通字典提交任务,并在主线程响应中读取文件和修改 Blender 数据。

View File

@@ -0,0 +1,23 @@
# Mask Alpha 编辑
`operators/mask_tool.py` 定义 `EditImageAlpha` 和 `MaskTool`,使用 `common/viewport.py` 的 `ImageGesture` 管理 Lasso、Brush 与 Polyline。
```mermaid
flowchart TD
A[冻结 Scene 的 Gesture、Mode、Radius] --> B[屏幕手势与 Overlay]
B --> C[完成后投影并生成覆盖 M]
C --> D[读取源 RGBA]
D --> E[按模式计算 Alpha]
E --> F[保留 RGB 和源画布尺寸]
F --> G[替换 Packed Image 并提交一个 Undo]
```
| 模式 | 源 Alpha A 与覆盖 M 的结果 |
| --- | --- |
| Set | `A × M` |
| Extend | `max(A, M)` |
| Subtract | `A × (1 − M)` |
Brush 拖动时维护简化屏幕路径、圆形印记与连接条的并集预览;释放时一次生成图片覆盖。Polyline 使用逐点路径,至少三个点后通过闭合圈、双击或 Enter 完成,Backspace 删除末点。
结果保留 Empty 的身份、矩阵、显示尺寸和偏移。全透明结果有效,取消或切换工具清理未提交的 Overlay;共享 Image 通过统一替换入口分流。

View File

@@ -0,0 +1,17 @@
# Rectify 透视校正
`operators/rectify_tool/operators.py` 定义 `RectifyImagePerspective` 与 `RectifyTool`,将图片内的四角区域校正为矩形。`geometry.py` 负责四角验证与像素校正,`preview.py` 负责预览图、Overlay 和宽高比控制,`__init__.py` 导出类型及注册清单。
```mermaid
flowchart TD
A[点选四角] --> B[规范为凸 Quad 并检查图片交叠]
B --> C[512×512 预览与宽高比调整]
C --> D[按像素密度和输出上限确定尺寸]
D --> E[分块 Homography 与预乘 Alpha 插值]
E --> F[按可见 Alpha 裁剪]
F --> G[映射 placement bounds 并替换 Packed Image]
```
四角允许任意点击顺序,退化或非凸输入在提交前校验。预览根据源颜色空间配置显示,像素重采样复用 `common/image.py`。输出最长边上限 8192,总像素上限 16,777,216。
`replace_empty_image()` 根据校正区域的 placement bounds 调整画框,保留源 Image 名称、对象身份和共享引用语义。

View File

@@ -0,0 +1,19 @@
# Remove Background
`operators/remove_background.py` 从 Image Empty 或当前 Shader Image Texture 准备输入。`RemoveImageBackground` 协调设置与输入,`RunBackgroundRemoval` 提交 Job 并应用结果。
```mermaid
flowchart TD
A[ImageEditTarget 取得编辑目标] --> B[准备图像输入]
B --> C[remove-background Job]
C --> D[所选背景模型生成前景 Alpha]
D --> E{输入类型}
E -- 普通图片 --> F[透明 PNG 前景]
E -- HDR --> G[alpha.npy]
F --> H[应用结果与 Undo]
G --> H
```
`server/jobs/remove_background.py` 读取单张输入并调用 `server/models/background.py`,支持 BiRefNet 和 BEN2。模型前景 Alpha 与源 Alpha 合成。
普通结果通过 `ImageEditTarget` 保留共享图像引用与帧设置。HDR 使用 `common/hdr_image.py` 的 `HdrBackgroundInput` 准备分析输入,并将返回 Alpha 应用于原浮点 RGB。Operator 负责临时输入清理。

View File

@@ -0,0 +1,15 @@
# Upscale
`operators/upscale.py` 从 Image Empty 或当前 Shader Image Texture 生成 2× 结果,源图长边必须小于 Maximum AI Input Size。
```mermaid
flowchart TD
A[检查源图尺寸并冻结模型设置] --> B[准备图片输入]
B --> C[upscale-image 单张处理]
C --> D[分块执行 4× RGB 推理]
D --> E[统一缩放为源图 2×]
E --> F[原 Alpha 独立 Lanczos 缩放]
F --> G[ImageEditTarget 应用结果并提交 Undo]
```
`server/models/upscale.py` 处理输入上限和最终尺寸,`onnx_upscale.py` 执行 Real-ESRGAN 与 HAT 模型。ModelManager 管理 Session;Provider 资源错误会释放当前 Session 并返回处理建议。

40
docs/internals/outputs.md Normal file
View File

@@ -0,0 +1,40 @@
# Job 产物
Server 将结果写入 Job 目录,返回以相对文件名为值的字典。Blender 主线程通过 JobResult 读取文件,创建或更新 Image、Mesh、Material 和 Object。
| Job | 文件 | 消费方 |
| --- | --- | --- |
| `remove-background` | `foreground.png` 或 `alpha.npy` | ImageEditTarget 或 HDR Alpha 编辑 |
| `upscale-image` | `upscale.png` | 图像替换 |
| `generate-depth-plane-geometry` | `depth.exr`、`depth.json` 和法线 | Depth / Relief Plane |
| `generate-cutout-artifacts` | 按请求提供深度、元数据和法线 | Cutout |
| `generate-panorama-geometry` | `depth.exr`、`depth.json` | Panorama |
## 深度与法线协议
| 返回字段 | 内容 |
| --- | --- |
| `depth` | float32 EXR,透视产物 RGB 保存相机空间 XYZ,Alpha 保存源 Alpha × 模型有效性 |
| `depth_metadata` | JSON,包含 `image_size` 与 3×3 `intrinsics` |
| `tangent_normal` | `tangent-normal.png`,切线空间法线 |
| `object_normal` | `object-normal.png`,对象空间法线 |
```mermaid
flowchart TD
A[源 RGB 与 Alpha] --> B[限尺寸 RGB 推理]
B --> C[GeometryFrame]
C --> D[写出深度与法线]
A --> E[源 Alpha 写入有效通道]
E --> D
D --> F[Blender 加载并 Pack]
```
参考深度由 `common/depth.py` 从产物计算:先按 Alpha 阈值和有限正深度筛选样本,剔除超过样本中位数 1.1 倍的远处背景,再取 95 分位数;无可用样本时返回基准值。加载后设置 Non-Color 和 CHANNEL_PACKED。Depth Plane 使用对象空间法线,Relief Plane 使用切线空间法线。
Panorama 的 EXR RGB 保存径向距离,Alpha 保存源 Alpha 与融合有效性;元数据包含 `projection: equirectangular` 和 `image_size`。
## 颜色与图像编辑
`common/color_image.py` 为 Plane、Panorama 和 Cutout 准备独立的材质颜色与 AI 分析输入。Cutout 使用 Selection bounds 内的源图;网格创建时组合源 Alpha 与 SelectionMask。颜色分辨率与分析尺寸分别处理。
`ImageEditTarget` 统一 Image Empty 和 Shader Image Texture 的编辑目标及共享引用。HDR 去背景通过 `HdrBackgroundInput` 保留 Blender 浮点 RGB,并应用 Server 返回的连续 Alpha。

36
docs/internals/runtime.md Normal file
View File

@@ -0,0 +1,36 @@
# Job Runtime 与 Server
`runtime.py` 配置 BlendJob `JobRuntime` 和独立 Python 3.12 Environment。`server/app.py` 注册 Job,`model_manager.py` 管理模型文件与缓存 Session。
```mermaid
flowchart TD
A[Operator request] --> B[JobRuntime 队列]
B --> C[Server Job 调用媒体与模型模块]
C --> D[写入 Job 目录]
D --> E[主线程 response 导入结果]
```
| Job | 实现与用途 |
| --- | --- |
| `download-model`、`download-required-models` | `app.py` 下载单个或缺失模型 |
| `remove-background` | `jobs/remove_background.py` 生成透明前景或连续 Alpha |
| `upscale-image` | `jobs/upscale.py` 生成 2× 图像 |
| `generate-depth-plane-geometry` | `jobs/depth_plane.py` 生成深度、元数据和法线 |
| `generate-cutout-artifacts` | `jobs/cutout.py` 按请求生成深度和法线 |
| `generate-panorama-geometry` | `jobs/panorama.py` 生成全景径向深度 |
`media/` 校验输入图片并限制分析尺寸,Server 只处理静态图片。Job 在阶段边界检查取消,通过 `context.progress()` 返回进度,Operator 清理 Blender 导出的临时输入。
`geometry/prediction_artifacts.py` 缩放源 RGB 后运行一次 MoGe,将同一预测交给深度、法线和元数据写入器;源 Alpha 写入深度与法线产物的有效通道。参考深度由 Blender 侧按产物 Alpha 计算,文件协议见 [Job 产物](outputs.md)。
## 模型与缓存
| 用途 | 模型 | Session 分类 |
| --- | --- | --- |
| Depth 与 Normal | MoGe-2 ViT-S / ViT-B Normal、MoGe-3 ViT-L | geometry |
| Remove Background | BiRefNet Lite、BEN2、BiRefNet HR Matting | background |
| Upscale | Real-ESRGAN WDN、Real-ESRGAN x4plus、HAT Sharper | upscale |
`model_catalog.py` 声明文件来源、许可、大小和 SHA-256;`model_download.py` 下载并校验。ModelManager 按模型路径和设备缓存 Session,切换模型或设备时释放对应类别。Clear Models 或关闭 Server 时释放已加载资源。
首次安装通过 `post_install()` 下载默认 MoGe-2 ViT-S Normal、BiRefNet Lite 与 Real-ESRGAN General WDN x4v3。Storage Root 默认 `~/.anyimage`,保存环境、模型、Job 产物和日志。设置入口见 [AI 环境管理](operators/ai-setup.md)。

41
docs/javascripts/extra.js Normal file
View File

@@ -0,0 +1,41 @@
/**
* 首页的扩展安装按钮:按系统只留一个平台,并把下载地址拖进 Blender。
*/
const INSTALL_PLATFORMS = { win: "windows", mac: "macos", lin: "linux" };
function initInstallDrag() {
const container = document.querySelector(".anyimage-install");
if (!container) {
return;
}
// navigator.platform 的取值由浏览器决定,只取前三个字符做粗略匹配。
const detectedPlatform = INSTALL_PLATFORMS[navigator.platform.toLowerCase().slice(0, 3)];
container.querySelectorAll(".anyimage-install-item").forEach(function (item) {
const matches = !detectedPlatform || item.dataset.platform.startsWith(detectedPlatform);
item.classList.toggle("is-active", matches);
item.hidden = !matches;
});
container.querySelectorAll(".anyimage-install-button").forEach(function (button) {
const group = button.closest(".anyimage-install-group");
button.addEventListener("dragstart", function (event) {
event.dataTransfer.setData("text/plain", button.dataset.installUrl);
event.dataTransfer.effectAllowed = "copy";
group.classList.add("is-dragging");
});
button.addEventListener("dragend", function () {
group.classList.remove("is-dragging");
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initInstallDrag);
} else {
initInstallDrag();
}

133
docs/stylesheets/extra.css Normal file
View File

@@ -0,0 +1,133 @@
@import url("https://fontsapi.zeoseven.com/292/main/result.css");
@import url("https://fontsapi.zeoseven.com/293/main/result.css");
/* [data-md-color-scheme="slate"] {
--md-code-hl-string-color: #8b8b8b;
--md-code-hl-keyword-color: #ffe8a3;
--md-code-hl-number-color: #ffe8a3;
--md-typeset-a-color: #ffe8a3;
}
[data-md-color-scheme="default"] {
--md-code-hl-string-color: #444444;
--md-code-hl-keyword-color: #bd9100;
--md-code-hl-number-color: #bd9100;
}
[data-md-color-scheme=slate][data-md-color-primary=black] {
--md-typeset-a-color: #ffe8a3;
}
[data-md-color-scheme=default][data-md-color-primary=black] {
--md-typeset-a-color: #bd9100;
} */
:root {
--md-text-font: "LXGW WenKai";
--md-code-font: "LXGW WenKai Mono";
}
/* 首页的扩展安装拖拽组件,数值取自 extensions.blender.org 的按钮。 */
@font-face {
font-family: "fontutti";
src: url("../assets/fonts/fontutti.woff2") format("woff2");
font-style: normal;
font-weight: normal;
}
.anyimage-install {
font-size: 14px;
line-height: 1.4286;
}
.anyimage-install-item {
margin-bottom: 1.6rem;
}
.anyimage-install [class^="i-"]:before,
.anyimage-install [class*=" i-"]:before {
display: inline-block;
width: 1em;
margin: 0 0.2em;
font-family: "fontutti";
font-style: normal;
font-variant: normal;
font-weight: normal;
line-height: 1em;
text-align: center;
text-decoration: inherit;
text-transform: none;
-webkit-font-smoothing: antialiased;
}
.anyimage-install .i-move:before {
content: "\e8d5";
}
.anyimage-install-item.is-active ~ .anyimage-install-item.is-active {
border-top: 1px solid var(--md-default-fg-color--lightest);
padding-top: 1.6rem;
}
.anyimage-install-group {
border: 2px solid transparent;
border-radius: 6px;
margin-bottom: 8px;
}
.anyimage-install-group.is-dragging {
opacity: 0.5;
}
.anyimage-install-button {
display: inline-block;
width: 100%;
margin-bottom: 8px;
padding: 8px 16px;
border: none;
border-radius: 6px;
background-color: rgba(0, 153, 255, 0.1);
color: #0099ff;
font-family: inherit;
font-size: 14px;
font-weight: 500;
line-height: 24px;
outline: 2px dashed currentColor;
outline-offset: -2px;
text-align: center;
text-decoration: none;
transition: background-color 150ms, color 150ms ease-out;
user-select: none;
vertical-align: middle;
white-space: nowrap;
}
.anyimage-install-button:hover {
background-color: rgba(0, 153, 255, 0.2);
cursor: move;
}
.anyimage-install-platform {
text-align: center;
}
.anyimage-install-platform strong {
font-weight: 600;
}
.anyimage-install-platform-rest {
font-weight: 400;
}
.anyimage-install small.anyimage-install-note {
display: block;
width: 100%;
font-size: 12px;
opacity: 1;
text-align: center;
}
.anyimage-install-note a {
color: #0099ff;
text-decoration: underline;
}

120
mkdocs.yml Normal file
View File

@@ -0,0 +1,120 @@
site_name: AnyImage 文档
site_url: https://docs.omoolab.xyz/anyimage/
site_author: OmooLab
site_description: AnyImage 是一款将图片、视频和序列帧转换为立体面片的 Blender 扩展。
repo_name: OmooLab/AnyImage
repo_url: https://git.omoolab.xyz/icrdr/AnyImage
nav:
- AnyImage:
- index.md
- 开始使用:
- 安装与首次配置: getting-started/installation.md
- 内部实现:
- 总览: internals/index.md
- 程序架构: internals/architecture.md
- Common 公共函数: internals/common.md
- 基础设施:
- Job Runtime 与 Server: internals/runtime.md
- Job 产物: internals/outputs.md
- Operators:
- 总览: internals/operators/index.md
- AI 环境管理: internals/operators/ai-setup.md
- Clipboard Image: internals/operators/clipboard-image.md
- Convert to Plane: internals/operators/convert-to-plane.md
- Convert to Panorama: internals/operators/convert-to-panorama.md
- Cutout Tool: internals/operators/cutout-tool.md
- Frame: internals/operators/frame.md
- Mask: internals/operators/mask.md
- Rectify: internals/operators/rectify.md
- Remove Background: internals/operators/remove-background.md
- Upscale: internals/operators/upscale.md
- 开发维护:
- 开发与发布: internals/development.md
- 节点资产: internals/node-assets.md
theme:
name: material
favicon: assets/favicon.png
logo: assets/logo.png
language: zh
font:
text: Open Sans
code: Cascadia Code
palette:
# Palette toggle for automatic mode
- media: "(prefers-color-scheme)"
primary: black
toggle:
icon: fontawesome/solid/circle-half-stroke
name: Switch to light mode
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: black
toggle:
icon: fontawesome/regular/moon
name: Switch to dark mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: black
toggle:
icon: fontawesome/solid/moon
name: Switch to system preference
features:
- header.autohide
- navigation.footer
- navigation.indexes
- navigation.sections
- navigation.tabs
- navigation.tabs.sticky
- content.code.copy
- content.code.select
extra:
version:
provider: mike
extra_css:
- stylesheets/extra.css
extra_javascript:
- javascripts/extra.js
copyright: Copyright &copy; 2020 - 2024 Omoolab
plugins:
- search
- mkdocstrings
- i18n:
docs_structure: suffix
languages:
- locale: zh
name: 中文
default: true
- locale: en
name: English
nav_translations:
快捷键: Keymap
markdown_extensions:
- mkdocs-click
- admonition
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.details
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
- pymdownx.tabbed:
alternate_style: true

1
nodes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Node group definitions used to build the bundled asset."""

1
nodes/common/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Shared node graph builders."""

View File

@@ -0,0 +1,116 @@
"""Apply boundary-preserving smoothing within a narrow cut-edge band."""
from .nodes import boolean_node, compare_node, evaluate_field, group_input
from .depth_surface import _math
from .smoothing import edge_boundary_field, pinned_smooth
def _gated(group, value, blocked, *, default):
"""Return the value unless the blocked control is true."""
if blocked is False:
return value
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = "FLOAT"
node.inputs["True"].default_value = default
if isinstance(blocked, bool):
node.inputs["Switch"].default_value = blocked
else:
group.links.new(blocked, node.inputs["Switch"])
group.links.new(value, node.inputs["False"])
return node.outputs[0]
def boundary_influence(group, protected=False, *, boundary=None):
"""Fade a boundary field through two rings while excluding protected points."""
nodes, links = group.nodes, group.links
if boundary is None:
boundary = edge_boundary_field(nodes, links)
boundary = evaluate_field(group, boundary, "FLOAT", "POINT")
cut = _gated(group, boundary, protected, default=0.0)
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT"
blur.inputs["Iterations"].default_value = 2
blur.inputs["Weight"].default_value = 1
links.new(cut, blur.inputs["Value"])
mixed = _math(group, "MAXIMUM", cut, blur.outputs["Value"])
return _gated(group, mixed, protected, default=0.0)
def smooth_boundary_depth(group, depth, protected=False):
"""Blur point depth on the current mesh and apply it only in the edge band."""
nodes, links = group.nodes, group.links
depth = evaluate_field(group, depth, "FLOAT", "POINT")
iterations = group_input(nodes, {"Boundary Smooth"}).outputs["Boundary Smooth"]
influence = boundary_influence(group, protected)
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT"
blur.inputs["Weight"].default_value = 0.5
links.new(depth, blur.inputs["Value"])
links.new(iterations, blur.inputs["Iterations"])
mix = nodes.new("ShaderNodeMix")
mix.data_type = "FLOAT"
links.new(influence, mix.inputs[0])
links.new(depth, mix.inputs[2])
links.new(blur.outputs["Value"], mix.inputs[3])
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "FLOAT"
links.new(influence, choose.inputs["Switch"])
links.new(depth, choose.inputs["False"])
links.new(mix.outputs[0], choose.inputs["True"])
enabled = nodes.new("GeometryNodeSwitch")
enabled.input_type = "FLOAT"
links.new(iterations, enabled.inputs["Switch"])
links.new(depth, enabled.inputs["False"])
links.new(choose.outputs[0], enabled.inputs["True"])
return enabled.outputs[0]
def smooth_boundary_camera(group, camera, protected=False):
"""Reconstruct blurred camera depth along each unchanged projection ray."""
nodes, links = group.nodes, group.links
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(camera, axes.inputs[0])
depth = axes.outputs["Z"]
smoothed = smooth_boundary_depth(group, depth, protected)
return reconstruct_camera_depth(group, camera, depth, smoothed)
def reconstruct_camera_depth(group, camera, depth, smoothed):
"""Move each valid camera point along its original projection ray."""
nodes, links = group.nodes, group.links
changed = compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", _math(group, "SUBTRACT", smoothed, depth)), 0)
enabled = boolean_node(group, "AND", changed, compare_node(group, "GREATER_THAN", depth, 1e-8))
projected = nodes.new("ShaderNodeVectorMath")
projected.operation = "SCALE"
links.new(camera, projected.inputs[0])
links.new(_math(group, "DIVIDE", smoothed, depth), projected.inputs[3])
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "VECTOR"
links.new(enabled, choose.inputs["Switch"])
links.new(camera, choose.inputs["False"])
links.new(projected.outputs[0], choose.inputs["True"])
return choose.outputs[0]
def cut_boundary_influence(group, split_boundary, protected=False, *, outline_scale=0.1, boundary=None):
"""Give split edges full influence and outline edges a configured share."""
nodes, links = group.nodes, group.links
if boundary is None:
boundary = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
split = _gated(group, boundary, boolean_node(group, "NOT", split_boundary), default=0.0)
outline = _math(group, "MAXIMUM", _math(group, "SUBTRACT", boundary, split), 0)
split_influence = boundary_influence(group, protected, boundary=split)
outline_influence = _math(
group, "MULTIPLY", boundary_influence(group, protected, boundary=outline), outline_scale,
)
return _math(group, "MAXIMUM", split_influence, outline_influence)
def smooth_cut_boundary(group, geometry, split_boundary=False, *, protected=False, outline_scale=0.1, boundary=None):
"""Move boundary vertices toward the pinned position through split and outline influence."""
influence = cut_boundary_influence(
group, split_boundary, protected, outline_scale=outline_scale, boundary=boundary,
)
# The support band depends on topology, which stays unchanged in the loop.
iterations = group_input(group.nodes, {"Boundary Smooth"}).outputs["Boundary Smooth"]
return pinned_smooth(group, geometry, iterations, influence)

139
nodes/common/cutout.py Normal file
View File

@@ -0,0 +1,139 @@
"""Build geometry calculations shared by Cutout assets."""
from .nodes import compare_node, math_node
from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME
def _profile_field(group, geometry):
nodes = group.nodes
links = group.links
profile = nodes.new("GeometryNodeInputNamedAttribute")
profile.data_type = "FLOAT"
profile.inputs["Name"].default_value = BALLOON_ATTRIBUTE_NAME
profile_max = nodes.new("GeometryNodeAttributeStatistic")
profile_max.data_type = "FLOAT"
profile_max.domain = "POINT"
links.new(geometry, profile_max.inputs["Geometry"])
links.new(profile.outputs["Attribute"], profile_max.inputs["Attribute"])
safe_max = math_node(nodes, "MAXIMUM")
safe_max.inputs[1].default_value = 1e-6
links.new(profile_max.outputs["Max"], safe_max.inputs[0])
normalized = math_node(nodes, "DIVIDE")
links.new(profile.outputs["Attribute"], normalized.inputs[0])
links.new(safe_max.outputs[0], normalized.inputs[1])
smooth = nodes.new("ShaderNodeMapRange")
smooth.data_type = "FLOAT"
smooth.interpolation_type = "SMOOTHERSTEP"
smooth.clamp = True
smooth.inputs["From Min"].default_value = 0.0
smooth.inputs["From Max"].default_value = 1.0
smooth.inputs["To Min"].default_value = 0.0
smooth.inputs["To Max"].default_value = 1.0
links.new(normalized.outputs[0], smooth.inputs["Value"])
return profile.outputs["Attribute"], smooth.outputs["Result"]
def _extrude_layers(group, geometry):
node = group.nodes.new("GeometryNodeExtrudeMesh")
node.mode = "FACES"
node.inputs["Individual"].default_value = False
node.inputs["Offset"].default_value = (0.0, 1.0, 0.0)
# Keep distinct front and back vertices before overriding their Y positions.
node.inputs["Offset Scale"].default_value = 1.0
group.links.new(geometry, node.inputs["Mesh"])
return node
def _offset_geometry_y(group, geometry, y_value):
nodes = group.nodes
links = group.links
offset = nodes.new("ShaderNodeCombineXYZ")
links.new(y_value, offset.inputs["Y"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(geometry, set_position.inputs["Geometry"])
links.new(offset.outputs["Vector"], set_position.inputs["Offset"])
return set_position.outputs["Geometry"]
def _set_layer_y(group, geometry, extrude, front_y, back_y):
nodes = group.nodes
links = group.links
choose_y = nodes.new("GeometryNodeSwitch")
choose_y.input_type = "FLOAT"
links.new(extrude.outputs["Top"], choose_y.inputs["Switch"])
links.new(front_y, choose_y.inputs["False"])
links.new(back_y, choose_y.inputs["True"])
position = nodes.new("GeometryNodeInputPosition")
separate = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], separate.inputs["Vector"])
combine = nodes.new("ShaderNodeCombineXYZ")
links.new(separate.outputs["X"], combine.inputs["X"])
links.new(choose_y.outputs["Output"], combine.inputs["Y"])
links.new(separate.outputs["Z"], combine.inputs["Z"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"])
links.new(combine.outputs["Vector"], set_position.inputs["Position"])
side = nodes.new("GeometryNodeSeparateGeometry")
side.domain = "FACE"
links.new(set_position.outputs["Geometry"], side.inputs["Geometry"])
links.new(extrude.outputs["Side"], side.inputs["Selection"])
flip_side = nodes.new("GeometryNodeFlipFaces")
links.new(side.outputs["Selection"], flip_side.inputs["Mesh"])
front = _offset_geometry_y(
group, geometry, front_y
)
back = _offset_geometry_y(
group, geometry, back_y
)
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back, flip_back.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
links.new(front, join.inputs["Geometry"])
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 1e-6
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _remove_zero_thickness(
group,
zero_geometry,
shaped_geometry,
thickness,
):
nodes = group.nodes
links = group.links
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(zero, choose.inputs["Switch"])
links.new(shaped_geometry, choose.inputs["False"])
links.new(zero_geometry, choose.inputs["True"])
return choose.outputs["Output"]
def _position_field(group, depth_image):
nodes = group.nodes
links = group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
position_texture = nodes.new("GeometryNodeImageTexture")
position_texture.interpolation = "Linear"
position_texture.extension = "EXTEND"
links.new(depth_image, position_texture.inputs["Image"])
links.new(uv.outputs["Attribute"], position_texture.inputs["Vector"])
return position_texture.outputs["Color"]
def _shade_output(group, geometry):
shade = group.nodes.new("GeometryNodeSetShadeSmooth")
shade.domain = "FACE"
shade.inputs["Shade Smooth"].default_value = True
output = group.nodes.new("NodeGroupOutput")
group.links.new(geometry, shade.inputs["Geometry"])
group.links.new(shade.outputs["Geometry"], output.inputs["Geometry"])

View File

@@ -0,0 +1,29 @@
"""Clean cutout boundary triangles and taper the split thickness profile."""
from .depth_surface import _math
from .nodes import boolean_node, compare_node, evaluate_field, read_float_attribute, store_float_attribute
from .smoothing import edge_boundary_field
from .boundary_smoothing import boundary_influence
def remove_boundary_triangles(group, geometry):
"""Remove all-boundary triangles once on the current topology."""
nodes, links = group.nodes, group.links
rim = evaluate_field(group, edge_boundary_field(nodes, links), "FLOAT", "POINT")
all_boundary = compare_node(group, "GREATER_THAN", evaluate_field(group, rim, "FLOAT", "FACE"), 0.99999)
count = nodes.new("GeometryNodeInputMeshFaceNeighbors").outputs["Vertex Count"]
selected = boolean_node(group, "AND", all_boundary, compare_node(group, "EQUAL", count, 3, data_type="INT"))
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(selected, delete.inputs["Selection"])
return delete.outputs["Geometry"]
def taper_split_profile(group, geometry, cut_boundary):
"""Taper the balloon profile to zero across the Depth Split cuts."""
nodes = group.nodes
links = group.links
retained = _math(group, "SUBTRACT", 1, boundary_influence(group, boundary=cut_boundary))
profile = _math(group, "MULTIPLY", read_float_attribute(group, "o_balloon"), retained)
return store_float_attribute(group, geometry, "o_balloon", profile)

View File

@@ -0,0 +1,473 @@
"""Face separation and connected thickness for Depth Surface assets."""
from .nodes import (
boolean_node, compare_node, evaluate_field, store_boolean_attribute,
remove_attribute_pattern, sample_field,
)
from .smoothing import edge_boundary_field
CUT_ATTRIBUTE = "_o_depth_cut"
def _math(group, operation, a, b=None):
node = group.nodes.new("ShaderNodeMath")
node.operation = operation
for index, value in enumerate((a, b)):
if value is None:
continue
if isinstance(value, (int, float)):
node.inputs[index].default_value = value
else:
group.links.new(value, node.inputs[index])
return node.outputs[0]
def _remove_strip_faces(group, geometry, *, triangles=False):
"""Delete boundary strips from one unchanged topology snapshot."""
if triangles:
return _remove_triangle_strip_faces(group, geometry)
return _remove_quad_strip_faces(group, geometry)
def _remove_quad_strip_faces(group, geometry):
"""Delete boundary quad strips while preserving open boundary edges."""
nodes, links = group.nodes, group.links
def equals(value, count):
return compare_node(group, "EQUAL", value, count, data_type="INT")
boundaries = []
for offset in range(4):
index = nodes.new("GeometryNodeInputIndex")
corners = nodes.new("GeometryNodeCornersOfFace")
links.new(index.outputs[0], corners.inputs["Face Index"])
corner = nodes.new("GeometryNodeOffsetCornerInFace")
corner.inputs["Offset"].default_value = offset
links.new(corners.outputs["Corner Index"], corner.inputs["Corner Index"])
edge = nodes.new("GeometryNodeEdgesOfCorner")
links.new(corner.outputs["Corner Index"], edge.inputs["Corner Index"])
sample = nodes.new("GeometryNodeFieldAtIndex")
sample.data_type, sample.domain = "INT", "EDGE"
links.new(edge.outputs["Next Edge Index"], sample.inputs["Index"])
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
links.new(neighbors.outputs["Face Count"], sample.inputs["Value"])
boundaries.append(equals(sample.outputs["Value"], 1))
opposite = boolean_node(
group, "OR",
boolean_node(group, "AND", boundaries[0], boundaries[2]),
boolean_node(group, "AND", boundaries[1], boundaries[3]),
)
face = nodes.new("GeometryNodeInputMeshFaceNeighbors")
selected = boolean_node(group, "AND", equals(face.outputs["Vertex Count"], 4), opposite)
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(selected, delete.inputs["Selection"])
return delete.outputs["Geometry"]
def _remove_triangle_strip_faces(group, geometry):
"""Delete symmetric triangle strip pairs and all-boundary ears."""
nodes, links = group.nodes, group.links
def equals(value, count):
return compare_node(group, "EQUAL", value, count, data_type="INT")
def sample(value, index, domain, data_type="INT"):
node = nodes.new("GeometryNodeFieldAtIndex")
node.data_type, node.domain = data_type, domain
links.new(value, node.inputs["Value"])
links.new(index, node.inputs["Index"])
return node.outputs["Value"]
def offset_corner(corner, offset):
node = nodes.new("GeometryNodeOffsetCornerInFace")
links.new(corner, node.inputs["Corner Index"])
node.inputs["Offset"].default_value = offset
return node.outputs["Corner Index"]
def vertex(corner):
node = nodes.new("GeometryNodeVertexOfCorner")
links.new(corner, node.inputs["Corner Index"])
return node.outputs["Vertex Index"]
def boundary(corner):
edge = nodes.new("GeometryNodeEdgesOfCorner")
links.new(corner, edge.inputs["Corner Index"])
count = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1)
edge_index = nodes.new("GeometryNodeInputIndex")
sides = []
for side in (0, 1):
corners = nodes.new("GeometryNodeCornersOfEdge")
links.new(edge_index.outputs[0], corners.inputs["Edge Index"])
corners.inputs["Sort Index"].default_value = side
corner = corners.outputs["Corner Index"]
face_index = nodes.new("GeometryNodeFaceOfCorner")
links.new(corner, face_index.inputs["Corner Index"])
face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors")
triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3)
end, tip = offset_corner(corner, 1), offset_corner(corner, 2)
sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip)))
a, b = sides
same_direction = equals(a[1], b[1])
same_order = boolean_node(
group, "OR",
boolean_node(group, "AND", a[3], b[3]),
boolean_node(group, "AND", a[4], b[4]),
)
reverse_order = boolean_node(
group, "OR",
boolean_node(group, "AND", a[3], b[4]),
boolean_node(group, "AND", a[4], b[3]),
)
pair = boolean_node(
group, "OR",
boolean_node(group, "AND", same_direction, reverse_order),
boolean_node(group, "AND", boolean_node(group, "NOT", same_direction), same_order),
)
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
for condition in (
equals(neighbors.outputs["Face Count"], 2),
a[0],
b[0],
boolean_node(group, "NOT", equals(a[2], b[2])),
):
pair = boolean_node(group, "AND", pair, condition)
face_index = nodes.new("GeometryNodeInputIndex")
corners = nodes.new("GeometryNodeCornersOfFace")
links.new(face_index.outputs[0], corners.inputs["Face Index"])
boundary_count, paired = None, None
for offset in range(3):
corner = offset_corner(corners.outputs["Corner Index"], offset)
edge = nodes.new("GeometryNodeEdgesOfCorner")
links.new(corner, edge.inputs["Corner Index"])
match = sample(pair, edge.outputs["Next Edge Index"], "EDGE", "BOOLEAN")
paired = match if paired is None else boolean_node(group, "OR", paired, match)
rim = boundary(corner)
if boundary_count is None:
boundary_count = rim
else:
add = nodes.new("FunctionNodeIntegerMath")
add.operation = "ADD"
links.new(boundary_count, add.inputs[0])
links.new(rim, add.inputs[1])
boundary_count = add.outputs[0]
selected = boolean_node(
group, "AND",
equals(corners.outputs["Total"], 3),
boolean_node(group, "OR", paired, equals(boundary_count, 3)),
)
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(selected, delete.inputs["Selection"])
return delete.outputs["Geometry"]
def sample_face_camera(group, image):
"""Sample camera XYZ at each planar face's UV center."""
nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
face_uv = nodes.new("GeometryNodeFieldOnDomain")
face_uv.data_type = "FLOAT_VECTOR"
face_uv.domain = "FACE"
links.new(uv.outputs["Attribute"], face_uv.inputs["Value"])
texture = nodes.new("GeometryNodeImageTexture")
texture.interpolation = "Linear"
texture.extension = "EXTEND"
links.new(image, texture.inputs["Image"])
links.new(face_uv.outputs["Value"], texture.inputs["Vector"])
return texture.outputs["Color"]
def ray_distance(group, camera):
"""Measure the distance from the camera origin to a camera-space point."""
length = group.nodes.new("ShaderNodeVectorMath")
length.operation = "LENGTH"
group.links.new(camera, length.inputs[0])
return length.outputs["Value"]
def split_depth_surface(group, geometry, face_camera, strength, reference_depth, depth_scale, *,
face_distance=None, face_center=None, triangles=False):
"""Split relative ray-distance jumps over the base face-center spacing.
The jump is scaled by Depth Scale, so a flat projection keeps its faces.
"""
nodes, links = group.nodes, group.links
if face_center is None:
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
face_camera = evaluate_field(group, face_camera, "FLOAT_VECTOR", "FACE")
if face_distance is None:
face_distance = ray_distance(group, face_camera)
centers, depths = [], []
for side in (0, 1):
corner = nodes.new("GeometryNodeCornersOfEdge")
corner.inputs["Sort Index"].default_value = side
face = nodes.new("GeometryNodeFaceOfCorner")
links.new(corner.outputs["Corner Index"], face.inputs["Corner Index"])
for data_type, field, results in (
("FLOAT_VECTOR", face_center, centers),
("FLOAT", face_distance, depths),
):
sample = nodes.new("GeometryNodeFieldAtIndex")
sample.data_type = data_type
sample.domain = "FACE"
links.new(face.outputs["Face Index"], sample.inputs["Index"])
links.new(field, sample.inputs["Value"])
results.append(sample.outputs["Value"])
distance = nodes.new("ShaderNodeVectorMath")
distance.operation = "DISTANCE"
for index, center in enumerate(centers):
links.new(center, distance.inputs[index])
delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths))
# Depth Scale is the display amplitude of that jump on the projected surface.
delta = _math(group, "MULTIPLY", delta, depth_scale)
local_distance = _math(group, "MAXIMUM", _math(group, "MULTIPLY", _math(group, "ADD", *depths), 0.5), 1e-8)
reference = _math(group, "MAXIMUM", reference_depth, 1e-8)
# Uniform Scale cancels between the distance jump and local spacing correction.
# Cross multiplication also avoids dividing by the local distance.
selected = compare_node(
group,
"GREATER_THAN",
_math(group, "MULTIPLY", _math(group, "MULTIPLY", delta, reference), strength),
_math(group, "MULTIPLY", _math(group, "MULTIPLY", distance.outputs["Value"], local_distance), 2.5),
)
selected = boolean_node(
group,
"AND",
selected,
compare_node(group, "GREATER_THAN", distance.outputs["Value"], 0.0),
)
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
selected = boolean_node(
group,
"AND",
selected,
compare_node(group, "EQUAL", neighbors.outputs["Face Count"], 2, data_type="INT"),
)
cut_edges = nodes.new("GeometryNodeFieldOnDomain")
cut_edges.data_type = "FLOAT"
cut_edges.domain = "EDGE"
links.new(selected, cut_edges.inputs["Value"])
cut_vertices = nodes.new("GeometryNodeFieldOnDomain")
cut_vertices.data_type = "FLOAT"
cut_vertices.domain = "POINT"
links.new(cut_edges.outputs["Value"], cut_vertices.inputs["Value"])
corners, cut = store_boolean_attribute(
group, geometry, CUT_ATTRIBUTE,
compare_node(group, "GREATER_THAN", cut_vertices.outputs["Value"], 0.0), domain="CORNER",
)
split = nodes.new("GeometryNodeSplitEdges")
links.new(corners, split.inputs["Mesh"])
links.new(selected, split.inputs["Selection"])
enabled = boolean_node(
group,
"AND",
compare_node(group, "GREATER_THAN", strength, 0.0),
compare_node(group, "GREATER_THAN", depth_scale, 0.0),
)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(enabled, choose.inputs["Switch"])
links.new(geometry, choose.inputs["False"])
links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"])
return choose.outputs["Output"], face_camera, cut
def build_surface_camera(group, original, separated, cut_vertex, strength):
"""Take cut depth from the adjacent face sample along each unchanged ray.
Depth Split edges and every open boundary left by Depth Mask deletion share
one replacement, so each cut edge sits on the surface it belongs to.
"""
nodes, links = group.nodes, group.links
cut_boundary = evaluate_field(group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT")
enabled = nodes.new("GeometryNodeSwitch")
enabled.input_type = "BOOLEAN"
links.new(compare_node(group, "GREATER_THAN", strength, 0.0), enabled.inputs["Switch"])
enabled.inputs["False"].default_value = False
links.new(cut_vertex, enabled.inputs["True"])
combined = nodes.new("FunctionNodeBooleanMath")
combined.operation = "OR"
links.new(enabled.outputs["Output"], combined.inputs[0])
links.new(cut_boundary, combined.inputs[1])
enabled = combined.outputs["Boolean"]
original_axes = group.nodes.new("ShaderNodeSeparateXYZ")
separated_axes = group.nodes.new("ShaderNodeSeparateXYZ")
group.links.new(original, original_axes.inputs[0])
group.links.new(separated, separated_axes.inputs[0])
ratio = _math(
group, "DIVIDE", separated_axes.outputs["Z"], original_axes.outputs["Z"]
)
projected = group.nodes.new("ShaderNodeVectorMath")
projected.operation = "SCALE"
group.links.new(original, projected.inputs[0])
group.links.new(ratio, projected.inputs[3])
choose = group.nodes.new("GeometryNodeSwitch")
choose.input_type = "VECTOR"
group.links.new(enabled, choose.inputs["Switch"])
group.links.new(original, choose.inputs["False"])
group.links.new(projected.outputs[0], choose.inputs["True"])
return evaluate_field(group, choose.outputs["Output"], "FLOAT_VECTOR", "POINT")
def thicken_depth_surface(group, geometry, thickness, normal_smooth=None):
"""Connect caps with single quads by source vertex and cap identity."""
nodes, links = group.nodes, group.links
position = nodes.new("GeometryNodeInputPosition")
index = nodes.new("GeometryNodeInputIndex")
# Encode identity in the construction geometry before extrusion. The source
# surface remains a separate sampling input; no position or ID is stored.
identity = nodes.new("ShaderNodeCombineXYZ")
for operation, axis in (("MODULO", "X"), ("DIVIDE", "Z")):
part = nodes.new("FunctionNodeIntegerMath")
part.operation = operation
part.inputs[1].default_value = 4096
links.new(index.outputs[0], part.inputs[0])
links.new(part.outputs[0], identity.inputs[axis])
sheet = nodes.new("GeometryNodeSetPosition")
links.new(geometry, sheet.inputs["Geometry"])
links.new(identity.outputs[0], sheet.inputs["Position"])
saved = sheet.outputs["Geometry"]
layer_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs[0], layer_axes.inputs[0])
row = nodes.new("FunctionNodeIntegerMath")
row.operation = "MULTIPLY"
row.inputs[1].default_value = 4096
links.new(layer_axes.outputs["Z"], row.inputs[0])
vertex = nodes.new("FunctionNodeIntegerMath")
vertex.operation = "ADD"
links.new(layer_axes.outputs["X"], vertex.inputs[0])
links.new(row.outputs[0], vertex.inputs[1])
source_index = vertex.outputs[0]
source_position = sample_field(group, geometry, position.outputs[0], "FLOAT_VECTOR", index=source_index)
source_thickness = sample_field(group, geometry, thickness, "FLOAT", index=source_index)
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
extrusion_offset = nodes.new("ShaderNodeCombineXYZ")
extrusion_offset.inputs["Y"].default_value = -1.0
links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"])
links.new(saved, extrude.inputs["Mesh"])
sides = nodes.new("GeometryNodeSeparateGeometry")
sides.domain = "FACE"
links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"])
links.new(extrude.outputs["Side"], sides.inputs["Selection"])
offset = nodes.new("ShaderNodeCombineXYZ")
offset.inputs["Y"].default_value = -1.0
back = nodes.new("GeometryNodeSetPosition")
links.new(saved, back.inputs["Geometry"])
links.new(offset.outputs[0], back.inputs["Offset"])
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back.outputs["Geometry"], flip_back.inputs["Mesh"])
flip_sides = nodes.new("GeometryNodeFlipFaces")
links.new(sides.outputs["Selection"], flip_sides.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
rear = flip_back.outputs["Mesh"]
rim = flip_sides.outputs["Mesh"]
back_parts = nodes.new("GeometryNodeJoinGeometry")
links.new(rear, back_parts.inputs["Geometry"])
links.new(rim, back_parts.inputs["Geometry"])
rear = back_parts.outputs["Geometry"]
for value in (
saved,
rear,
):
links.new(value, join.inputs["Geometry"])
construction = join.outputs["Geometry"]
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 0.1
links.new(construction, merge.inputs["Geometry"])
displacement = nodes.new("ShaderNodeCombineXYZ")
distance = _math(group, "MULTIPLY", layer_axes.outputs["Y"], source_thickness)
links.new(distance, displacement.inputs["Y"])
restored = nodes.new("ShaderNodeVectorMath")
restored.operation = "ADD"
links.new(source_position, restored.inputs[0])
links.new(displacement.outputs[0], restored.inputs[1])
result = nodes.new("GeometryNodeSetPosition")
links.new(merge.outputs["Geometry"], result.inputs["Geometry"])
links.new(restored.outputs[0], result.inputs["Position"])
if normal_smooth is not None:
normal = nodes.new("GeometryNodeInputNormal")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
blur.inputs["Iterations"].default_value = normal_smooth
links.new(normal.outputs[0], blur.inputs["Value"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(blur.outputs[0], normalize.inputs[0])
source_normal = sample_field(group, geometry, normalize.outputs[0], "FLOAT_VECTOR", index=source_index)
normal_offset = nodes.new("ShaderNodeVectorMath")
normal_offset.operation = "SCALE"
links.new(source_normal, normal_offset.inputs[0])
links.new(distance, normal_offset.inputs[3])
links.new(normal_offset.outputs[0], restored.inputs[1])
nodes.remove(displacement)
return {
"geometry": result.outputs["Geometry"],
"front": source_position,
"source_index": source_index,
"position": restored.outputs[0],
"position_input": result.inputs["Position"],
}
def _projection_math(nodes, operation):
node = nodes.new("ShaderNodeMath")
node.operation = operation
return node
def project_depth_surface(
group, geometry, camera_position, uniform_scale, reference_depth, depth_scale
):
"""Interpolate the rectangular plane into the model camera coordinates."""
nodes, links = group.nodes, group.links
scaled = nodes.new("ShaderNodeVectorMath")
scaled.operation = "SCALE"
links.new(camera_position, scaled.inputs[0])
links.new(uniform_scale, scaled.inputs[3])
separate = nodes.new("ShaderNodeSeparateXYZ")
links.new(scaled.outputs["Vector"], separate.inputs["Vector"])
relative_depth = _projection_math(nodes, "SUBTRACT")
links.new(separate.outputs["Z"], relative_depth.inputs[0])
links.new(reference_depth, relative_depth.inputs[1])
front = _projection_math(nodes, "MULTIPLY")
links.new(relative_depth.outputs[0], front.inputs[0])
links.new(depth_scale, front.inputs[1])
negative_z = _projection_math(nodes, "MULTIPLY")
negative_z.inputs[1].default_value = -1.0
links.new(separate.outputs["Y"], negative_z.inputs[0])
projected = nodes.new("ShaderNodeCombineXYZ")
links.new(separate.outputs["X"], projected.inputs["X"])
links.new(front.outputs[0], projected.inputs["Y"])
links.new(negative_z.outputs[0], projected.inputs["Z"])
current_position = nodes.new("GeometryNodeInputPosition")
blend = nodes.new("ShaderNodeMix")
blend.data_type = "VECTOR"
blend.clamp_factor = False
links.new(depth_scale, blend.inputs[0])
links.new(current_position.outputs["Position"], blend.inputs[4])
links.new(projected.outputs["Vector"], blend.inputs[5])
blended_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(blend.outputs["Result"], blended_axes.inputs["Vector"])
surface_position = nodes.new("ShaderNodeCombineXYZ")
links.new(blended_axes.outputs["X"], surface_position.inputs["X"])
links.new(front.outputs[0], surface_position.inputs["Y"])
links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"])
project_geometry = nodes.new("GeometryNodeSetPosition")
links.new(geometry, project_geometry.inputs["Geometry"])
links.new(surface_position.outputs["Vector"], project_geometry.inputs["Position"])
return remove_attribute_pattern(group, project_geometry.outputs["Geometry"], "_o_*")

33
nodes/common/direction.py Normal file
View File

@@ -0,0 +1,33 @@
"""Build nodes that decode legacy Direction parameters into canonical space."""
def legacy_direction_to_canonical(group, legacy):
nodes = group.nodes
links = group.links
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(legacy, axes.inputs[0])
negative_x = nodes.new("ShaderNodeMath")
negative_x.operation = "MULTIPLY"
negative_x.inputs[1].default_value = -1.0
links.new(axes.outputs["X"], negative_x.inputs[0])
negative_y = nodes.new("ShaderNodeMath")
negative_y.operation = "MULTIPLY"
negative_y.inputs[1].default_value = -1.0
links.new(axes.outputs["Y"], negative_y.inputs[0])
direction = nodes.new("ShaderNodeCombineXYZ")
links.new(negative_x.outputs[0], direction.inputs["X"])
links.new(axes.outputs["Z"], direction.inputs["Y"])
links.new(negative_y.outputs[0], direction.inputs["Z"])
return direction.outputs["Vector"]
def symmetry_legacy_direction_to_canonical(group, legacy):
nodes = group.nodes
links = group.links
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(legacy, axes.inputs[0])
direction = nodes.new("ShaderNodeCombineXYZ")
links.new(axes.outputs["X"], direction.inputs["X"])
links.new(axes.outputs["Z"], direction.inputs["Y"])
links.new(axes.outputs["Y"], direction.inputs["Z"])
return direction.outputs["Vector"]

View File

@@ -0,0 +1,25 @@
"""Carry the input mesh's material slots onto generated geometry."""
def inherit_material_slots(group, geometry, source):
nodes, links = group.nodes, group.links
size = nodes.new("GeometryNodeAttributeDomainSize")
size.component = "MESH"
links.new(source, size.inputs["Geometry"])
index = nodes.new("GeometryNodeInputIndex")
source_vertices = nodes.new("FunctionNodeCompare")
source_vertices.data_type, source_vertices.operation = "INT", "LESS_THAN"
links.new(index.outputs["Index"], source_vertices.inputs[2])
links.new(size.outputs["Point Count"], source_vertices.inputs[3])
join = nodes.new("GeometryNodeJoinGeometry")
# Multi-input links are prepended: source occupies the first point range.
links.new(geometry, join.inputs["Geometry"])
links.new(source, join.inputs["Geometry"])
material = nodes.new("GeometryNodeSetMaterialIndex")
material.inputs["Material Index"].default_value = 0
links.new(join.outputs["Geometry"], material.inputs["Geometry"])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain = "POINT"
links.new(material.outputs["Geometry"], delete.inputs["Geometry"])
links.new(source_vertices.outputs["Result"], delete.inputs["Selection"])
return delete.outputs["Geometry"]

213
nodes/common/nodes.py Normal file
View File

@@ -0,0 +1,213 @@
"""Build interfaces and basic nodes shared by asset definitions."""
from types import SimpleNamespace
def prepare_node_group(node_group):
"""Hide unused outputs and discard disconnected interface nodes."""
for node in list(node_group.nodes):
for socket in node.outputs:
socket.hide = not socket.is_linked
if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs):
node_group.nodes.remove(node)
def replace_link_source(node_group, link, source):
"""Replace a source while preserving multi-input socket order."""
multi = link.to_socket.is_multi_input
replacement = node_group.links.new(source, link.to_socket)
if multi:
replacement.swap_multi_input_sort_id(link)
node_group.links.remove(link)
def interface_socket(group, name, in_out, socket_type, parent=None):
socket = group.interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
parent=parent,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def group_input(nodes, visible_names):
node = nodes.new("NodeGroupInput")
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def float_input(
group,
name,
default,
minimum=0.0,
maximum=None,
subtype=None,
parent=None,
):
socket = interface_socket(group, name, "INPUT", "NodeSocketFloat", parent)
socket.default_value = default
socket.min_value = minimum
if maximum is not None:
socket.max_value = maximum
if subtype is not None:
socket.subtype = subtype
return socket
def bool_input(group, name, default=False, parent=None):
socket = interface_socket(group, name, "INPUT", "NodeSocketBool", parent)
socket.default_value = default
return socket
def vector_input(group, name, default, subtype=None, parent=None):
socket = interface_socket(group, name, "INPUT", "NodeSocketVector", parent)
socket.default_value = default
if subtype is not None:
socket.subtype = subtype
return socket
def math_node(nodes, operation):
node = nodes.new("ShaderNodeMath")
node.operation = operation
return node
_COMPARE_INPUTS = {
"FLOAT": ("A", "B"),
"INT": ("A_INT", "B_INT"),
"VECTOR": ("A_VEC3", "B_VEC3"),
"RGBA": ("A_COL", "B_COL"),
}
def compare_node(group, operation, a, b, data_type="FLOAT"):
"""Compare two inputs on the given data type and return the boolean result."""
node = group.nodes.new("FunctionNodeCompare")
node.data_type, node.operation = data_type, operation
for value, identifier in zip((a, b), _COMPARE_INPUTS[data_type]):
socket = next(item for item in node.inputs if item.identifier == identifier)
if isinstance(value, (bool, int, float)):
socket.default_value = value
else:
group.links.new(value, socket)
return node.outputs["Result"]
def boolean_node(group, operation, a=None, b=None):
"""Combine boolean inputs and return the boolean result."""
node = group.nodes.new("FunctionNodeBooleanMath")
node.operation = operation
for value, index in ((a, 0), (b, 1)):
if value is None:
continue
if isinstance(value, bool):
node.inputs[index].default_value = value
else:
group.links.new(value, node.inputs[index])
return node.outputs["Boolean"]
def menu_switch(group, control, names, kind):
selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None)
if selector is None:
selector = group.nodes.new("GeometryNodeMenuSwitch")
selector.data_type = "BOOLEAN"
for item, name in zip(selector.enum_items, names):
item.name = name
selector.inputs[names[0]].default_value = False
selector.inputs[names[1]].default_value = True
local = group_input(group.nodes, {control})
group.links.new(local.outputs[control], selector.inputs["Menu"])
node = group.nodes.new("GeometryNodeSwitch")
node.input_type = kind
group.links.new(selector.outputs[0], node.inputs["Switch"])
return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs)
def read_float_attribute(group, name):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT"
node.inputs["Name"].default_value = name
return node.outputs["Attribute"]
def read_vector_attribute(group, name):
node = group.nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
return node.outputs["Attribute"]
def store_float_attribute(group, geometry, name, value, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT"
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
if isinstance(value, (int, float)):
node.inputs["Value"].default_value = value
else:
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def store_vector_attribute(group, geometry, name, value, domain="POINT"):
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def evaluate_field(group, value, data_type, domain):
"""Evaluate a field on an explicit domain before downstream adaptation."""
node = group.nodes.new("GeometryNodeFieldOnDomain")
node.data_type, node.domain = data_type, domain
group.links.new(value, node.inputs["Value"])
return node.outputs["Value"]
def sample_field(group, geometry, value, data_type, domain="POINT", index=None):
"""Evaluate a field on an explicit source geometry and index domain."""
node = group.nodes.new("GeometryNodeSampleIndex")
node.data_type, node.domain = data_type, domain
node.clamp = False
if index is None:
index = group.nodes.new("GeometryNodeInputIndex").outputs["Index"]
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
group.links.new(index, node.inputs["Index"])
return node.outputs["Value"]
def store_boolean_attribute(group, geometry, name, value, domain="POINT"):
"""Store a topology marker needed after destructive mesh operations."""
node = group.nodes.new("GeometryNodeStoreNamedAttribute")
node.domain, node.data_type = domain, "BOOLEAN"
node.inputs["Name"].default_value = name
group.links.new(geometry, node.inputs["Geometry"])
group.links.new(value, node.inputs["Value"])
read = group.nodes.new("GeometryNodeInputNamedAttribute")
read.data_type = "BOOLEAN"
read.inputs["Name"].default_value = name
return node.outputs["Geometry"], read.outputs["Attribute"]
def remove_attribute_pattern(group, geometry, pattern):
"""Remove every attribute matching an internal wildcard pattern.
Always clean internal attributes with a pattern: naming an attribute that a
branch never stored makes the Remove Named Attribute node warn.
"""
node = group.nodes.new("GeometryNodeRemoveAttribute")
node.inputs["Name"].default_value = pattern
node.pattern_mode = "WILDCARD"
group.links.new(geometry, node.inputs["Geometry"])
return node.outputs["Geometry"]

View File

@@ -0,0 +1,82 @@
"""Share persistent face attributes between depth symmetry and image materials.
Rotation stores pre-inversion Euler angles, face kind is 1/2/3 for front/back/wall,
and axis is 1 for the final +X orientation. Missing attributes mean identity.
These FACE attributes stay on the output mesh for the material consumer.
"""
from mathutils import Matrix
from anyimage.common.object import blender_frame_rotation
ROTATION_ATTRIBUTE = 'o_depth_rotation'
FACE_ATTRIBUTE = 'o_depth_face'
AXIS_ATTRIBUTE = 'o_depth_axis'
def transform_object_normal_color(group, color, strength):
"""Rotate encoded object normals, reflect backs and disable wall normal maps."""
nodes, links = group.nodes, group.links
def attribute(name):
node = nodes.new('ShaderNodeAttribute')
node.attribute_type = 'GEOMETRY'
node.attribute_name = name
return node
def math(operation, value, constant):
node = nodes.new('ShaderNodeMath')
node.operation = operation
links.new(value, node.inputs[0])
node.inputs[1].default_value = constant
return node.outputs[0]
rotation = attribute(ROTATION_ATTRIBUTE)
face = attribute(FACE_ATTRIBUTE)
axis = attribute(AXIS_ATTRIBUTE)
decode = nodes.new('ShaderNodeVectorMath')
decode.operation = 'MULTIPLY_ADD'
decode.inputs[1].default_value = (2., 2., 2.)
decode.inputs[2].default_value = (-1., -1., -1.)
links.new(color, decode.inputs[0])
base = nodes.new('ShaderNodeVectorRotate')
base.rotation_type = 'EULER_XYZ'
base.inputs['Rotation'].default_value = Matrix(blender_frame_rotation()).to_euler()
links.new(decode.outputs[0], base.inputs['Vector'])
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(base.outputs[0], rotate.inputs['Vector'])
links.new(rotation.outputs['Vector'], rotate.inputs['Rotation'])
separate = nodes.new('ShaderNodeSeparateXYZ')
links.new(rotate.outputs[0], separate.inputs[0])
back = math('COMPARE', face.outputs['Fac'], 2.)
# Reflect the normal's Y component on the back.
difference = math('MULTIPLY', separate.outputs['Y'], -2.)
correction = nodes.new('ShaderNodeMath')
correction.operation = 'MULTIPLY_ADD'
links.new(back, correction.inputs[0])
links.new(difference, correction.inputs[1])
links.new(separate.outputs['Y'], correction.inputs[2])
combine = nodes.new('ShaderNodeCombineXYZ')
links.new(separate.outputs['X'], combine.inputs['X'])
links.new(correction.outputs[0], combine.inputs['Y'])
links.new(separate.outputs['Z'], combine.inputs['Z'])
axis_rotation = nodes.new('ShaderNodeVectorMath')
axis_rotation.operation = 'SCALE'
axis_rotation.inputs[0].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
links.new(axis.outputs['Fac'], axis_rotation.inputs['Scale'])
orient = nodes.new('ShaderNodeVectorRotate')
orient.rotation_type = 'EULER_XYZ'
links.new(combine.outputs[0], orient.inputs['Vector'])
links.new(axis_rotation.outputs[0], orient.inputs['Rotation'])
encode = nodes.new('ShaderNodeVectorMath')
encode.operation = 'MULTIPLY_ADD'
encode.inputs[1].default_value = (.5, .5, .5)
encode.inputs[2].default_value = (.5, .5, .5)
links.new(orient.outputs[0], encode.inputs[0])
mapped = math('LESS_THAN', face.outputs['Fac'], 2.5)
weight = nodes.new('ShaderNodeMath')
weight.operation = 'MULTIPLY'
links.new(mapped, weight.inputs[0])
links.new(strength, weight.inputs[1])
return encode.outputs[0], weight.outputs[0]

View File

@@ -0,0 +1,33 @@
"""Build the direction-adjusted reference depth field."""
from .nodes import math_node
from .direction import legacy_direction_to_canonical
def reference_plane_depth(group, direction, reference_depth):
nodes = group.nodes
links = group.links
normal = nodes.new("ShaderNodeVectorMath")
normal.operation = "NORMALIZE"
links.new(direction, normal.inputs[0])
remapped = legacy_direction_to_canonical(group, normal.outputs["Vector"])
normal_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(remapped, normal_components.inputs["Vector"])
position = nodes.new("GeometryNodeInputPosition")
position_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], position_components.inputs["Vector"])
x_term = math_node(nodes, "MULTIPLY")
links.new(position_components.outputs["X"], x_term.inputs[0])
links.new(normal_components.outputs["X"], x_term.inputs[1])
z_term = math_node(nodes, "MULTIPLY")
links.new(position_components.outputs["Z"], z_term.inputs[0])
links.new(normal_components.outputs["Z"], z_term.inputs[1])
point_dot = math_node(nodes, "ADD")
links.new(x_term.outputs[0], point_dot.inputs[0])
links.new(z_term.outputs[0], point_dot.inputs[1])
divide = math_node(nodes, "DIVIDE")
links.new(point_dot.outputs[0], divide.inputs[0])
links.new(normal_components.outputs["Y"], divide.inputs[1])
plane = math_node(nodes, "ADD")
links.new(reference_depth, plane.inputs[0])
links.new(divide.outputs[0], plane.inputs[1])
return plane.outputs[0]

34
nodes/common/shader.py Normal file
View File

@@ -0,0 +1,34 @@
"""Build shared material inputs, roughness, and bump outputs."""
BUMP_BASE_DISTANCE = 0.001
def build_material_nodes(group):
nodes = group.nodes
group_input = nodes.new("NodeGroupInput")
group_output = nodes.new("NodeGroupOutput")
roughness_curve = nodes.new("ShaderNodeFloatCurve")
roughness_curve.mapping.initialize()
curve_points = roughness_curve.mapping.curves[0].points
curve_points[0].location = (0.0, 0.5)
curve_points[-1].location = (1.0, 0.1)
roughness_curve.mapping.update()
bump = nodes.new("ShaderNodeBump")
return group_input, group_output, roughness_curve, bump
def connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha):
nodes, links = group.nodes, group.links
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"])
links.new(
roughness_curve.outputs["Value"],
group_output.inputs["Roughness"],
)
links.new(processed_alpha, group_output.inputs["Alpha"])
links.new(group_input.outputs["Color"], bump.inputs["Height"])
bump_distance = nodes.new("ShaderNodeMath")
bump_distance.operation = "MULTIPLY"
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0])
links.new(bump_distance.outputs[0], bump.inputs["Distance"])
links.new(bump.outputs["Normal"], group_output.inputs["Normal"])

173
nodes/common/smoothing.py Normal file
View File

@@ -0,0 +1,173 @@
"""Build shared boundary fields and pinned position smoothing."""
from .nodes import evaluate_field, sample_field
def edge_boundary_field(nodes, links):
"""Report the points sitting on an open or loose boundary."""
vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors")
loose_vertex = nodes.new("FunctionNodeCompare")
loose_vertex.data_type = "INT"
loose_vertex.operation = "EQUAL"
loose_vertex_a = next(
socket for socket in loose_vertex.inputs if socket.identifier == "A_INT"
)
loose_vertex_b = next(
socket for socket in loose_vertex.inputs if socket.identifier == "B_INT"
)
loose_vertex_b.default_value = 1
links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a)
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
boundary_edge = nodes.new("FunctionNodeCompare")
boundary_edge.data_type = "INT"
boundary_edge.operation = "EQUAL"
boundary_edge_a = next(
socket for socket in boundary_edge.inputs if socket.identifier == "A_INT"
)
boundary_edge_b = next(
socket for socket in boundary_edge.inputs if socket.identifier == "B_INT"
)
boundary_edge_b.default_value = 1
links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a)
edge_domain = nodes.new("GeometryNodeFieldOnDomain")
edge_domain.data_type = "BOOLEAN"
edge_domain.domain = "EDGE"
links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"])
is_boundary = nodes.new("FunctionNodeBooleanMath")
is_boundary.operation = "OR"
links.new(edge_domain.outputs["Value"], is_boundary.inputs[0])
links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1])
return is_boundary.outputs["Boolean"]
def _multiply(group, a, b):
node = group.nodes.new("ShaderNodeMath")
node.operation = "MULTIPLY"
for index, value in enumerate((a, b)):
if isinstance(value, (int, float)):
node.inputs[index].default_value = value
else:
group.links.new(value, node.inputs[index])
return node.outputs[0]
def _boundary_target(group, geometry, current, boundary_points):
"""Blur the boundary strip alone and sample it back onto every point."""
nodes = group.nodes
links = group.links
boundary_geometry = nodes.new("GeometryNodeSeparateGeometry")
boundary_geometry.domain = "POINT"
links.new(current, boundary_geometry.inputs["Geometry"])
links.new(boundary_points, boundary_geometry.inputs["Selection"])
boundary_position = nodes.new("GeometryNodeInputPosition")
smooth_boundary = nodes.new("GeometryNodeBlurAttribute")
smooth_boundary.data_type = "FLOAT_VECTOR"
smooth_boundary.inputs["Iterations"].default_value = 1
smooth_boundary.inputs["Weight"].default_value = 0.5
links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"])
nearest_boundary = nodes.new("GeometryNodeSampleNearest")
nearest_boundary.domain = "POINT"
# Both boundary meshes keep the same compact index order: only positions change,
# so the lookup can stay anchored on the untouched input geometry.
source_boundary = nodes.new("GeometryNodeSeparateGeometry")
source_boundary.domain = "POINT"
links.new(geometry, source_boundary.inputs["Geometry"])
links.new(boundary_points, source_boundary.inputs["Selection"])
links.new(source_boundary.outputs["Selection"], nearest_boundary.inputs["Geometry"])
fixed_position = sample_field(
group, geometry, boundary_position.outputs["Position"], "FLOAT_VECTOR"
)
links.new(fixed_position, nearest_boundary.inputs["Sample Position"])
sample_boundary = nodes.new("GeometryNodeSampleIndex")
sample_boundary.data_type = "FLOAT_VECTOR"
sample_boundary.domain = "POINT"
sample_boundary.clamp = False
links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"])
links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"])
links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"])
return sample_boundary.outputs["Value"]
def pinned_smooth(
group,
geometry,
iterations,
region,
weight=1.0,
pin_boundary=True,
pin_sharp=True,
):
"""Smooth positions toward a pinned target inside an influence region.
``weight`` is the smoothing weight: each point moves that share of the way
to the target every iteration, scaled further by the sharpness of the
normal field and the influence region.
``pin_boundary`` sends boundary points to a boundary-only blur so an outline
is never dragged inward. ``pin_sharp`` lowers the weight of creases through
the sharpness of the normal field. Junction bands keep the sharpness weight
without the boundary rule, where the influenced points are not a boundary.
"""
nodes, links = group.nodes, group.links
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
links.new(iterations, repeat_input.inputs["Iterations"])
links.new(geometry, repeat_input.inputs["Geometry"])
current = repeat_input.outputs["Geometry"]
position = nodes.new("GeometryNodeInputPosition")
smooth_all = nodes.new("GeometryNodeBlurAttribute")
smooth_all.data_type = "FLOAT_VECTOR"
smooth_all.inputs["Iterations"].default_value = 1
smooth_all.inputs["Weight"].default_value = 1.0
links.new(position.outputs["Position"], smooth_all.inputs["Value"])
target = smooth_all.outputs["Value"]
if pin_boundary:
boundary_points = evaluate_field(
group, edge_boundary_field(nodes, links), "BOOLEAN", "POINT"
)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "VECTOR"
links.new(boundary_points, choose.inputs["Switch"])
links.new(smooth_all.outputs["Value"], choose.inputs["False"])
links.new(
_boundary_target(group, geometry, current, boundary_points),
choose.inputs["True"],
)
target = choose.outputs["Output"]
if pin_sharp:
normal = nodes.new("GeometryNodeInputNormal")
smooth_normal = nodes.new("GeometryNodeBlurAttribute")
smooth_normal.data_type = "FLOAT_VECTOR"
smooth_normal.inputs["Iterations"].default_value = 10
smooth_normal.inputs["Weight"].default_value = 1.0
links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"])
coherence = nodes.new("ShaderNodeVectorMath")
coherence.operation = "LENGTH"
links.new(smooth_normal.outputs["Value"], coherence.inputs[0])
weight = _multiply(group, weight, coherence.outputs["Value"])
weight = _multiply(group, weight, region)
difference = nodes.new("ShaderNodeVectorMath")
difference.operation = "SUBTRACT"
links.new(target, difference.inputs[0])
links.new(position.outputs["Position"], difference.inputs[1])
offset = nodes.new("ShaderNodeVectorMath")
offset.operation = "SCALE"
links.new(difference.outputs[0], offset.inputs[0])
links.new(weight, offset.inputs[3])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(current, set_position.inputs["Geometry"])
links.new(offset.outputs[0], set_position.inputs["Offset"])
links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"])
enabled = nodes.new("GeometryNodeSwitch")
enabled.input_type = "GEOMETRY"
links.new(iterations, enabled.inputs["Switch"])
links.new(geometry, enabled.inputs["False"])
links.new(repeat_output.outputs["Geometry"], enabled.inputs["True"])
return enabled.outputs["Output"]

10
nodes/common/validity.py Normal file
View File

@@ -0,0 +1,10 @@
"""Delete the faces the depth mask excludes."""
def delete_invalid_faces(group, geometry, invalid):
nodes, links = group.nodes, group.links
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(invalid, delete.inputs["Selection"])
return delete.outputs["Geometry"]

1
nodes/groups/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Named node group definitions."""

View File

@@ -0,0 +1,205 @@
"""Build O Image Cutout."""
import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, compare_node, math_node, float_input,
menu_switch, read_float_attribute, store_float_attribute, prepare_node_group,
)
from ..common.cutout import _extrude_layers, _offset_geometry_y, _set_layer_y, _shade_output
NODE_GROUP_NAME = "O Image Cutout"
def _set_segmented_layer_y(
group,
geometry,
front_z,
back_z,
thickness,
):
nodes = group.nodes
links = group.links
edge_vertices = nodes.new("GeometryNodeInputMeshEdgeVertices")
edge_length = nodes.new("ShaderNodeVectorMath")
edge_length.operation = "DISTANCE"
links.new(edge_vertices.outputs["Position 1"], edge_length.inputs[0])
links.new(edge_vertices.outputs["Position 2"], edge_length.inputs[1])
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
boundary = compare_node(group, "EQUAL", edge_neighbors.outputs["Face Count"], 1, data_type="INT")
statistics = nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type = "FLOAT"
statistics.domain = "EDGE"
links.new(geometry, statistics.inputs["Geometry"])
links.new(boundary, statistics.inputs["Selection"])
links.new(edge_length.outputs["Value"], statistics.inputs["Attribute"])
safe_edge = math_node(nodes, "MAXIMUM")
safe_edge.inputs[1].default_value = 1e-6
links.new(statistics.outputs["Median"], safe_edge.inputs[0])
segment_ratio = math_node(nodes, "DIVIDE")
links.new(thickness, segment_ratio.inputs[0])
links.new(safe_edge.outputs[0], segment_ratio.inputs[1])
segment_count = nodes.new("FunctionNodeFloatToInt")
segment_count.rounding_mode = "CEILING"
links.new(segment_ratio.outputs[0], segment_count.inputs["Float"])
at_least_one = nodes.new("FunctionNodeIntegerMath")
at_least_one.operation = "MAXIMUM"
at_least_one.inputs[1].default_value = 1
links.new(segment_count.outputs["Integer"], at_least_one.inputs[0])
segment_limit = nodes.new("FunctionNodeIntegerMath")
segment_limit.operation = "MINIMUM"
segment_limit.inputs[1].default_value = 128
links.new(at_least_one.outputs[0], segment_limit.inputs[0])
step = math_node(nodes, "DIVIDE")
links.new(thickness, step.inputs[0])
links.new(segment_limit.outputs[0], step.inputs[1])
front = _offset_geometry_y(group, geometry, front_z)
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
repeat_output.repeat_items.new("GEOMETRY", "Sides")
links.new(segment_limit.outputs[0], repeat_input.inputs["Iterations"])
links.new(geometry, repeat_input.inputs["Geometry"])
negative_step = math_node(nodes, "MULTIPLY")
negative_step.inputs[1].default_value = -1.0
links.new(step.outputs[0], negative_step.inputs[0])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
extrude.inputs["Selection"].default_value = True
extrude.inputs["Offset"].default_value = (0.0, 0.0, 0.0)
links.new(negative_step.outputs[0], extrude.inputs["Offset Scale"])
links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"])
split_extrude = nodes.new("GeometryNodeSeparateGeometry")
split_extrude.domain = "FACE"
links.new(extrude.outputs["Mesh"], split_extrude.inputs["Geometry"])
links.new(extrude.outputs["Side"], split_extrude.inputs["Selection"])
join_sides = nodes.new("GeometryNodeJoinGeometry")
links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"])
links.new(split_extrude.outputs["Selection"], join_sides.inputs["Geometry"])
links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"])
links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"])
positioned_side = _offset_geometry_y(
group,
repeat_output.outputs["Sides"],
front_z,
)
flip_side = nodes.new("GeometryNodeFlipFaces")
links.new(positioned_side, flip_side.inputs["Mesh"])
back = _offset_geometry_y(group, geometry, back_z)
flip_back = nodes.new("GeometryNodeFlipFaces")
links.new(back, flip_back.inputs["Mesh"])
join = nodes.new("GeometryNodeJoinGeometry")
links.new(front, join.inputs["Geometry"])
links.new(flip_back.outputs["Mesh"], join.inputs["Geometry"])
links.new(flip_side.outputs["Mesh"], join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
merge.inputs["Distance"].default_value = 1e-6
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _build_shell(group, geometry, thickness):
nodes = group.nodes
links = group.links
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(zero, choose.inputs["Switch"])
links.new(geometry, choose.inputs["True"])
front = math_node(nodes, "MULTIPLY")
front.inputs[1].default_value = 0.0
links.new(thickness, front.inputs[0])
back = math_node(nodes, "MULTIPLY")
back.inputs[1].default_value = 1.0
links.new(thickness, back.inputs[0])
shaped = _set_segmented_layer_y(
group,
geometry,
front.outputs[0],
back.outputs[0],
thickness,
)
links.new(shaped, choose.inputs["False"])
return choose.outputs[0]
def _build_balloon(group, geometry, thickness):
nodes = group.nodes
links = group.links
zero = compare_node(group, "LESS_THAN", thickness, 1e-6)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(zero, choose.inputs["Switch"])
links.new(geometry, choose.inputs["True"])
profile = read_float_attribute(group, 'o_balloon')
front_scale = math_node(nodes, "MULTIPLY")
links.new(profile, front_scale.inputs[0])
links.new(thickness, front_scale.inputs[1])
negative_front = math_node(nodes, "MULTIPLY")
negative_front.inputs[1].default_value = -1.0
links.new(front_scale.outputs[0], negative_front.inputs[0])
extrude = _extrude_layers(group, geometry)
shaped = _set_layer_y(
group,
geometry,
extrude,
negative_front.outputs[0],
front_scale.outputs[0],
)
links.new(shaped, choose.inputs["False"])
return choose.outputs[0]
def build_image_cutout_group():
name = NODE_GROUP_NAME
existing = bpy.data.node_groups.get(name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 1.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
balloon = _build_balloon(group, controls.outputs['Geometry'], controls.outputs['Thickness'])
shell = _build_shell(group, controls.outputs['Geometry'], controls.outputs['Shell Thickness'])
choose = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'GEOMETRY')
group.links.new(balloon, choose.inputs['Balloon'])
group.links.new(shell, choose.inputs['Shell'])
geometry = choose.outputs[0]
profile = read_float_attribute(group, 'o_balloon')
amount = group_input(group.nodes, {'Thickness'})
enabled = compare_node(group, 'GREATER_THAN', amount.outputs['Thickness'], 1e-6)
inflated = group.nodes.new('GeometryNodeSwitch')
inflated.input_type = 'FLOAT'
inflated.inputs['False'].default_value = 1
group.links.new(enabled, inflated.inputs['Switch'])
group.links.new(profile, inflated.inputs['True'])
mode = menu_switch(group, 'Mode', ('Balloon', 'Shell'), 'FLOAT')
mode.inputs['Shell'].default_value = 1
group.links.new(inflated.outputs[0], mode.inputs['Balloon'])
scale = mode.outputs[0]
reduction = math_node(group.nodes, 'SUBTRACT')
reduction.inputs[0].default_value = 1.0
group.links.new(scale, reduction.inputs[1])
geometry = store_float_attribute(group, geometry, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction.outputs[0])
_shade_output(group, geometry)
prepare_node_group(group)
for item in group.interface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
continue
if item.name == 'Mode':
item.default_value = 'Balloon'
elif item.name == 'Shell Thickness':
item.name = 'Thickness'
return group

View File

@@ -0,0 +1,416 @@
"""Mirror completed depth geometry and fill matching boundaries."""
import bpy
from mathutils import Matrix
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, float_input, bool_input, vector_input,
compare_node, boolean_node, evaluate_field,
store_boolean_attribute, store_float_attribute, read_float_attribute,
remove_attribute_pattern, prepare_node_group,
)
from ..common.depth_surface import _math
from ..common.normal_map import ROTATION_ATTRIBUTE, FACE_ATTRIBUTE, AXIS_ATTRIBUTE
from ..common.direction import symmetry_legacy_direction_to_canonical
from ..common.smoothing import edge_boundary_field, pinned_smooth
NODE_GROUP_NAME = "O Image Cutout Symmetry"
# 切口环与补面衔接带各自向外覆盖的圈数。
SMOOTH_RINGS = 4
# 补面标记:挤出之后按面记下补面,作为补面平滑的范围。
WELD_ATTRIBUTE = '_o_symmetry_weld'
# 对称面附近按中位边长的倍数降低法线贴图强度。
SEAM_NORMAL_BAND = 4
def _align_front(group, geometry, controls):
"""Align existing geometry with the fixed XY symmetry plane."""
nodes, links = group.nodes, group.links
direction = symmetry_legacy_direction_to_canonical(group, controls['Direction'])
align = nodes.new('FunctionNodeAlignEulerToVector')
align.axis = 'Y'
links.new(direction, align.inputs['Vector'])
position = nodes.new('GeometryNodeInputPosition')
rotate = nodes.new('ShaderNodeVectorRotate')
rotate.rotation_type = 'EULER_XYZ'
rotate.invert = True
links.new(position.outputs[0], rotate.inputs['Vector'])
links.new(align.outputs['Rotation'], rotate.inputs['Rotation'])
offset = nodes.new('ShaderNodeCombineXYZ')
offset_value = nodes.new('ShaderNodeMath')
offset_value.operation = 'MULTIPLY'
offset_value.inputs[1].default_value = -1.0
links.new(controls['Offset'], offset_value.inputs[0])
links.new(offset_value.outputs[0], offset.inputs['Y'])
offset_position = nodes.new('ShaderNodeVectorMath')
offset_position.operation = 'ADD'
links.new(rotate.outputs[0], offset_position.inputs[0])
links.new(offset.outputs[0], offset_position.inputs[1])
scale_vector = nodes.new('ShaderNodeCombineXYZ')
scale_vector.inputs['X'].default_value = 1
scale_vector.inputs['Z'].default_value = 1
links.new(controls['Scale'], scale_vector.inputs['Y'])
scale = nodes.new('ShaderNodeVectorMath')
scale.operation = 'MULTIPLY'
links.new(offset_position.outputs[0], scale.inputs[0])
links.new(scale_vector.outputs[0], scale.inputs[1])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(scale.outputs[0], place.inputs['Position'])
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT_VECTOR', 'FACE'
store.inputs['Name'].default_value = ROTATION_ATTRIBUTE
links.new(place.outputs[0], store.inputs['Geometry'])
links.new(align.outputs['Rotation'], store.inputs['Value'])
return store.outputs['Geometry']
def _boundary_edge_field(group):
"""Report one on edges that carry a single face."""
neighbors = group.nodes.new('GeometryNodeInputMeshEdgeNeighbors')
return compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 1, data_type='INT')
def _beyond_plane_field(group, distance):
"""Report the points sitting farther than the weld distance past the plane."""
nodes = group.nodes
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0])
return compare_node(group, 'GREATER_THAN', components.outputs['Y'], distance)
def _on_plane_field(group, distance):
"""Report the points sitting within the weld distance of the plane."""
nodes = group.nodes
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
group.links.new(position.outputs[0], components.inputs[0])
return compare_node(group, 'LESS_THAN', _math(group, 'ABSOLUTE', components.outputs['Y']), distance)
def _merge_points(group, geometry, selection, distance):
"""Weld the selected points that share a position."""
nodes, links = group.nodes, group.links
merge = nodes.new('GeometryNodeMergeByDistance')
links.new(geometry, merge.inputs['Geometry'])
links.new(selection, merge.inputs['Selection'])
if isinstance(distance, (int, float)):
merge.inputs['Distance'].default_value = distance
else:
links.new(distance, merge.inputs['Distance'])
return merge.outputs[0]
def _delete_faces(group, geometry, selection):
"""Delete the selected faces and the geometry only they used."""
nodes, links = group.nodes, group.links
delete = nodes.new('GeometryNodeDeleteGeometry')
delete.domain, delete.mode = 'FACE', 'ALL'
links.new(geometry, delete.inputs['Geometry'])
links.new(selection, delete.inputs['Selection'])
return delete.outputs[0]
def _delete_beyond_plane(group, geometry, beyond):
"""Delete the faces whose points all sit beyond the symmetry plane."""
# 点域求值让面域选择按「全部点越界」删除,跨界的面连几何一起留给切口回拉。
return _delete_faces(group, geometry, evaluate_field(group, beyond, 'BOOLEAN', 'POINT'))
def _corner_edge(group, corner, offset, value):
"""Read an edge value at one corner around a face."""
nodes, links = group.nodes, group.links
shifted = nodes.new('GeometryNodeOffsetCornerInFace')
links.new(corner, shifted.inputs['Corner Index'])
shifted.inputs['Offset'].default_value = offset
edges = nodes.new('GeometryNodeEdgesOfCorner')
links.new(shifted.outputs['Corner Index'], edges.inputs['Corner Index'])
sample = nodes.new('GeometryNodeFieldAtIndex')
sample.data_type, sample.domain = 'BOOLEAN', 'EDGE'
links.new(value, sample.inputs['Value'])
links.new(edges.outputs['Next Edge Index'], sample.inputs['Index'])
return sample.outputs['Value']
def _boundary_edge_count(group):
"""Count the edges carrying a single face around each face."""
nodes, links = group.nodes, group.links
boundary = _boundary_edge_field(group)
index = nodes.new('GeometryNodeInputIndex')
corners = nodes.new('GeometryNodeCornersOfFace')
links.new(index.outputs[0], corners.inputs['Face Index'])
# 第四个偏移在三角形上绕回第一条边,按角数把它归零。
quad = compare_node(group, 'GREATER_THAN', corners.outputs['Total'], 3, data_type='INT')
count = None
for offset in range(4):
term = _corner_edge(group, corners.outputs['Corner Index'], offset, boundary)
if offset == 3:
masked = nodes.new('GeometryNodeSwitch')
masked.input_type = 'FLOAT'
masked.inputs['False'].default_value = 0.0
links.new(quad, masked.inputs['Switch'])
links.new(term, masked.inputs['True'])
term = masked.outputs['Output']
count = term if count is None else _math(group, 'ADD', count, term)
return count
def _delete_bridge_faces(group, geometry):
"""Delete the faces left bridging two openings of a cut."""
return _delete_faces(group, geometry, compare_node(group, 'GREATER_THAN', _boundary_edge_count(group), 1.5))
def _delete_flat_faces(group, geometry, distance):
"""Delete the faces lying on the symmetry plane; they mirror onto themselves."""
nodes, links = group.nodes, group.links
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
normal = nodes.new('GeometryNodeInputNormal')
normal_axes = nodes.new('ShaderNodeSeparateXYZ')
links.new(normal.outputs[0], normal_axes.inputs[0])
center = evaluate_field(group, _math(group, 'ABSOLUTE', components.outputs['Y']), 'FLOAT', 'FACE')
return _delete_faces(
group, geometry,
boolean_node(
group, 'AND',
compare_node(group, 'LESS_THAN', center, distance),
compare_node(group, 'GREATER_THAN', _math(group, 'ABSOLUTE', normal_axes.outputs['Y']), 1 - 1e-3),
),
)
def _delete_crease_faces(group, geometry, distance):
"""Delete the faces the fold leaves sharing an interior edge on the plane."""
nodes, links = group.nodes, group.links
heights = _edge_abs_y(group)
plane = nodes.new('FunctionNodeBooleanMath')
plane.operation = 'AND'
links.new(compare_node(group, 'LESS_THAN', heights[0], distance), plane.inputs[0])
links.new(compare_node(group, 'LESS_THAN', heights[1], distance), plane.inputs[1])
neighbors = nodes.new('GeometryNodeInputMeshEdgeNeighbors')
crease = nodes.new('FunctionNodeBooleanMath')
crease.operation = 'AND'
links.new(plane.outputs[0], crease.inputs[0])
links.new(compare_node(group, 'EQUAL', neighbors.outputs['Face Count'], 2, data_type='INT'), crease.inputs[1])
index = nodes.new('GeometryNodeInputIndex')
corners = nodes.new('GeometryNodeCornersOfFace')
links.new(index.outputs[0], corners.inputs['Face Index'])
selected = None
for offset in range(4):
term = _corner_edge(group, corners.outputs['Corner Index'], offset, crease.outputs[0])
selected = term if selected is None else boolean_node(group, 'OR', selected, term)
return _delete_faces(group, geometry, selected)
def _off_plane_edge_field(group, distance):
"""Report one on edges that keep an endpoint farther than the weld distance."""
heights = _edge_abs_y(group)
return compare_node(group, 'GREATER_THAN', _math(group, 'MAXIMUM', heights[0], heights[1]), distance)
def _edge_abs_y(group):
"""Return both endpoint heights on the symmetry axis for an edge."""
edge = group.nodes.new('GeometryNodeInputMeshEdgeVertices')
heights = []
for socket in ('Position 1', 'Position 2'):
axis = group.nodes.new('ShaderNodeSeparateXYZ')
group.links.new(edge.outputs[socket], axis.inputs[0])
heights.append(_math(group, 'ABSOLUTE', axis.outputs['Y']))
return heights
def _mirror(group, geometry):
mirror = group.nodes.new('GeometryNodeTransform')
mirror.inputs['Scale'].default_value = (1, -1, 1)
group.links.new(geometry, mirror.inputs['Geometry'])
flip = group.nodes.new('GeometryNodeFlipFaces')
group.links.new(mirror.outputs[0], flip.inputs['Mesh'])
return flip.outputs[0]
def _seam_field(group, distance):
"""Report the cut ring the folded front leaves on the symmetry plane."""
boundary = evaluate_field(group, edge_boundary_field(group.nodes, group.links), 'BOOLEAN', 'POINT')
return boolean_node(group, 'AND', boundary, _on_plane_field(group, distance))
def _falloff(group, center, rings):
"""Blur a 0/1 center into a falloff over the requested rings."""
blur = group.nodes.new('GeometryNodeBlurAttribute')
blur.data_type = 'FLOAT'
blur.inputs['Iterations'].default_value = rings
group.links.new(center, blur.inputs['Value'])
return _math(group, 'MAXIMUM', center, blur.outputs[0])
def _snap_to_plane(group, geometry, selection):
"""Move the selected points onto the symmetry plane."""
nodes, links = group.nodes, group.links
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
target = nodes.new('ShaderNodeCombineXYZ')
links.new(components.outputs['X'], target.inputs['X'])
links.new(components.outputs['Z'], target.inputs['Z'])
place = nodes.new('GeometryNodeSetPosition')
links.new(geometry, place.inputs['Geometry'])
links.new(selection, place.inputs['Selection'])
links.new(target.outputs[0], place.inputs['Position'])
return place.outputs[0]
def _relax_seam(group, geometry, seam, retract, smooth):
"""Relax the cut seam in three dimensions and keep the cut on the plane."""
relaxed = pinned_smooth(
group, geometry, smooth, _falloff(group, seam, SMOOTH_RINGS),
pin_boundary=True,
)
return _snap_to_plane(group, relaxed, boolean_node(group, 'OR', seam, retract))
def _relax_fill(group, geometry, fill, smooth):
"""Relax the fill faces on the welded mesh and fade outward from them."""
return pinned_smooth(
group, geometry, smooth, _falloff(group, fill, SMOOTH_RINGS),
weight=0.2, pin_boundary=False,
)
def _build_wall(group, front, boundary):
"""Close the selected boundary edges onto the symmetry plane and mark the fill."""
nodes, links = group.nodes, group.links
extrude = nodes.new('GeometryNodeExtrudeMesh')
extrude.mode = 'EDGES'
extrude.inputs['Offset Scale'].default_value = 0
links.new(front, extrude.inputs['Mesh'])
links.new(boundary, extrude.inputs['Selection'])
geometry = _snap_to_plane(group, extrude.outputs['Mesh'], extrude.outputs['Top'])
# 挤出之后再标记补面:范围就是补面本身连同它的边界线,挤出不会改变它。
fill = evaluate_field(group, extrude.outputs['Side'], 'BOOLEAN', 'POINT')
geometry, fill = store_boolean_attribute(group, geometry, WELD_ATTRIBUTE, fill, domain='FACE')
store = nodes.new('GeometryNodeStoreNamedAttribute')
store.data_type, store.domain = 'FLOAT', 'FACE'
store.inputs['Name'].default_value = FACE_ATTRIBUTE
store.inputs['Value'].default_value = 3
links.new(geometry, store.inputs['Geometry'])
links.new(extrude.outputs['Side'], store.inputs['Selection'])
return store.outputs[0], fill
def build_image_cutout_symmetry_group():
existing = bpy.data.node_groups.get(NODE_GROUP_NAME)
if existing is not None:
return existing
group = bpy.data.node_groups.new(NODE_GROUP_NAME, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = True, True
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
vector_input(group, 'Direction', (0, 0, 1), subtype='DIRECTION').description = 'Align this direction with the local symmetry axis.'
float_input(group, 'Offset', 0, minimum=-10, maximum=10, subtype='DISTANCE').description = 'Offset geometry along the aligned symmetry axis before scaling.'
float_input(group, 'Scale', 1, maximum=2).description = 'Scale the offset geometry along the aligned symmetry axis.'
options = group.interface.new_panel(name='Options', default_closed=True)
bool_input(group, 'Fill Sides', True, parent=options).description = 'Connect matching front and mirrored boundaries.'
smooth = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt', options)
smooth.default_value, smooth.min_value, smooth.max_value = 4, 0, 16
smooth.description = 'Smooth the cut ring and the fill band around the outline and the fill, each over four rings. Zero keeps both raw.'
merge = float_input(group, 'Merge Distance', 0.001, minimum=0, subtype='DISTANCE', parent=options)
merge.description = 'Weld mirrored geometry within this distance of the symmetry plane; boundaries farther away get filled.'
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
nodes, links = group.nodes, group.links
inputs = group_input(nodes, set()).outputs
aligned = _align_front(group, inputs['Geometry'], inputs)
edge = nodes.new('GeometryNodeInputMeshEdgeVertices')
edge_length = nodes.new('ShaderNodeVectorMath')
edge_length.operation = 'DISTANCE'
links.new(edge.outputs['Position 1'], edge_length.inputs[0])
links.new(edge.outputs['Position 2'], edge_length.inputs[1])
statistics = nodes.new('GeometryNodeAttributeStatistic')
statistics.data_type, statistics.domain = 'FLOAT', 'EDGE'
links.new(aligned, statistics.inputs['Geometry'])
links.new(edge_length.outputs['Value'], statistics.inputs['Attribute'])
# 合并距离同时决定平面的贴合带和补面边界,两者一致才不会留下细小补面。
merge_distance = _math(group, 'MAXIMUM', inputs['Merge Distance'], 1e-6)
beyond = _beyond_plane_field(group, merge_distance)
on_plane = _on_plane_field(group, merge_distance)
retract = boolean_node(group, 'OR', on_plane, beyond)
is_boundary = _boundary_edge_field(group)
front = _delete_beyond_plane(group, aligned, beyond)
front = _delete_bridge_faces(group, front)
# 贴合带内和跨界保留的面都折回对称面,切口环因此落在平面上。
front = _snap_to_plane(group, front, retract)
seam_field = _seam_field(group, merge_distance)
shade = nodes.new('GeometryNodeSetShadeSmooth')
shade.domain = 'FACE'
links.new(front, shade.inputs['Geometry'])
front = store_float_attribute(group, shade.outputs[0], FACE_ATTRIBUTE, 1., domain='FACE')
front = _relax_seam(group, front, seam_field, retract, inputs['Smooth'])
# 松弛会把点压回平面,贴平的面和内部贴平边要在松弛之后再清一次。
front = _delete_flat_faces(group, front, merge_distance)
front = _delete_crease_faces(group, front, merge_distance)
fill_iterations = nodes.new('GeometryNodeSwitch')
fill_iterations.input_type = 'INT'
fill_iterations.inputs['False'].default_value = 0
links.new(inputs['Fill Sides'], fill_iterations.inputs['Switch'])
links.new(inputs['Smooth'], fill_iterations.inputs['True'])
# 切口环本身贴在对称面上并靠镜像焊住,只有不落在这条环上的边界边才需要补面。
wall = nodes.new('FunctionNodeBooleanMath')
wall.operation = 'AND'
links.new(is_boundary, wall.inputs[0])
links.new(_off_plane_edge_field(group, merge_distance), wall.inputs[1])
walled, fill = _build_wall(group, front, wall.outputs[0])
wall_shade = nodes.new('GeometryNodeSetShadeSmooth')
wall_shade.domain = 'FACE'
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 3), wall_shade.inputs['Selection'])
links.new(walled, wall_shade.inputs['Geometry'])
body = nodes.new('GeometryNodeSwitch')
body.input_type = 'GEOMETRY'
links.new(inputs['Fill Sides'], body.inputs['Switch'])
links.new(front, body.inputs['False'])
links.new(wall_shade.outputs[0], body.inputs['True'])
mark_back = nodes.new('GeometryNodeStoreNamedAttribute')
mark_back.data_type, mark_back.domain = 'FLOAT', 'FACE'
mark_back.inputs['Name'].default_value = FACE_ATTRIBUTE
mark_back.inputs['Value'].default_value = 2
links.new(_mirror(group, body.outputs[0]), mark_back.inputs['Geometry'])
links.new(compare_node(group, 'EQUAL', read_float_attribute(group, FACE_ATTRIBUTE), 1), mark_back.inputs['Selection'])
join = nodes.new('GeometryNodeJoinGeometry')
for geometry in (body.outputs[0], mark_back.outputs['Geometry']):
links.new(geometry, join.inputs[0])
# 平面附近的点已经折到平面上,接缝两侧位置重合;合并只作用在这条带内。
merged = _merge_points(group, join.outputs[0], on_plane, merge_distance)
# 先让镜像焊住接缝,再松弛补面和它外侧的衔接带。
geometry = _relax_fill(group, merged, fill, fill_iterations.outputs[0])
geometry = store_float_attribute(group, geometry, AXIS_ATTRIBUTE, 1., domain='FACE')
orient = nodes.new('GeometryNodeTransform')
orient.inputs['Rotation'].default_value = Matrix.Rotation(1.5707963267948966, 4, 'Z').to_euler()
links.new(geometry, orient.inputs['Geometry'])
# Fade the material normal map near the symmetry plane so the weld shades smoother.
position = nodes.new('GeometryNodeInputPosition')
components = nodes.new('ShaderNodeSeparateXYZ')
links.new(position.outputs[0], components.inputs[0])
ramp = nodes.new('ShaderNodeMapRange')
ramp.interpolation_type = 'SMOOTHSTEP'
ramp.clamp = True
links.new(_math(group, 'ABSOLUTE', components.outputs['X']), ramp.inputs['Value'])
ramp.inputs['From Min'].default_value = 0.0
links.new(_math(group, 'MULTIPLY', statistics.outputs['Median'], SEAM_NORMAL_BAND), ramp.inputs['From Max'])
ramp.inputs['To Min'].default_value = 1.0
ramp.inputs['To Max'].default_value = 0.0
reduction = _math(
group, 'MAXIMUM',
read_float_attribute(group, NORMAL_REDUCTION_ATTRIBUTE_NAME), ramp.outputs['Result'],
)
geometry = store_float_attribute(
group, orient.outputs[0], NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
)
geometry = remove_attribute_pattern(group, geometry, '_o_*')
links.new(geometry, nodes.new('NodeGroupOutput').inputs['Geometry'])
prepare_node_group(group)
return group

View File

@@ -0,0 +1,336 @@
"""Build O Image Depth Cutout."""
import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import (
interface_socket, group_input, float_input, boolean_node, read_float_attribute, read_vector_attribute,
store_float_attribute, store_vector_attribute, remove_attribute_pattern,
prepare_node_group, evaluate_field, compare_node,
)
from ..common.boundary_smoothing import smooth_cut_boundary
from ..common.cutout import _position_field, _shade_output
from ..common.depth_surface import _math, project_depth_surface, build_surface_camera, split_depth_surface, sample_face_camera
from ..common.smoothing import edge_boundary_field
from ..common.cutout_boundary import remove_boundary_triangles, taper_split_profile
NODE_GROUP_NAME = "O Image Depth Cutout"
FRONT_NORMAL_ATTRIBUTE = "_o_front_normal"
BALLOON_THICKNESS_BASE = 1.0
NORMAL_SMOOTH_ITERATIONS = 256
SOLID_WELD_SCALE = 1e-3
def build_normal_weight(group, is_shell, profile):
"""Keep shell smoothing and let the balloon profile drive the blend."""
weight = group.nodes.new("GeometryNodeSwitch")
weight.input_type = "FLOAT"
group.links.new(is_shell, weight.inputs["Switch"])
group.links.new(profile, weight.inputs["False"])
weight.inputs["True"].default_value = 0.0
return weight.outputs[0]
def build_profile_weight(group, geometry):
"""Normalize the corrected balloon profile into a zero to one blend weight."""
profile = read_float_attribute(group, "o_balloon")
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type, statistics.domain = "FLOAT", "POINT"
group.links.new(geometry, statistics.inputs["Geometry"])
group.links.new(profile, statistics.inputs["Attribute"])
return _math(
group, "DIVIDE", profile,
_math(group, "MAXIMUM", statistics.outputs["Max"], 1e-6),
)
def build_island_normal(group, normal):
"""Average the smoothed normals of one connected mesh island."""
nodes, links = group.nodes, group.links
island = nodes.new("GeometryNodeInputMeshIsland")
accumulate = nodes.new("GeometryNodeAccumulateField")
accumulate.data_type, accumulate.domain = "FLOAT_VECTOR", "POINT"
links.new(normal, accumulate.inputs["Value"])
links.new(island.outputs["Island Index"], accumulate.inputs["Group Index"])
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(accumulate.outputs["Total"], normalize.inputs[0])
return normalize.outputs["Vector"]
def build_smoothed_normals(group):
"""Return the smoothed normals and the average normal of each mesh island."""
nodes, links = group.nodes, group.links
normal = group.nodes.new("GeometryNodeInputNormal")
blur = group.nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
links.new(normal.outputs[0], blur.inputs["Value"])
blur.inputs["Iterations"].default_value = NORMAL_SMOOTH_ITERATIONS
smoothed = blur.outputs[0]
return smoothed, build_island_normal(group, smoothed)
def build_normal_direction(group, controls, smoothed, average, weight):
"""Blend the smoothed normal against the island average normal."""
nodes, links = group.nodes, group.links
difference = group.nodes.new("ShaderNodeVectorMath")
difference.operation = "SUBTRACT"
links.new(average, difference.inputs[0])
links.new(smoothed, difference.inputs[1])
lean = _math(
group, "MULTIPLY", _math(group, "SUBTRACT", 1.0, weight),
controls.outputs["Edge Turn"],
)
blend = _math(group, "ADD", weight, lean)
offset = group.nodes.new("ShaderNodeVectorMath")
offset.operation = "SCALE"
links.new(difference.outputs["Vector"], offset.inputs[0])
links.new(blend, offset.inputs[3])
direction = group.nodes.new("ShaderNodeVectorMath")
direction.operation = "ADD"
links.new(smoothed, direction.inputs[0])
links.new(offset.outputs["Vector"], direction.inputs[1])
normalize = group.nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(direction.outputs["Vector"], normalize.inputs[0])
return normalize.outputs["Vector"]
def build_inflation_amount(group, controls, is_shell):
"""Balloon scales the profile by Depth Scale, Shell moves a fixed distance."""
nodes, links = group.nodes, group.links
thickness = _math(
group, "MULTIPLY", controls.outputs["Thickness"],
_math(group, "ADD", BALLOON_THICKNESS_BASE, controls.outputs["Depth Scale"]),
)
balloon = _math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"), thickness,
)
amount = nodes.new("GeometryNodeSwitch")
amount.input_type = "FLOAT"
links.new(is_shell, amount.inputs["Switch"])
links.new(balloon, amount.inputs["False"])
links.new(controls.outputs["Shell Thickness"], amount.inputs["True"])
return amount.outputs[0]
def build_solid(group, surface, amount, normal, is_shell, controls, weld_distance):
"""Inflate the projected surface into a front leaf, wall and rear leaf."""
nodes, links = group.nodes, group.links
def offset(distance):
result = nodes.new("ShaderNodeVectorMath")
result.operation = "SCALE"
links.new(normal, result.inputs[0])
links.new(distance, result.inputs[3])
return result.outputs[0]
# The front leaf moves by Thickness x o_balloon x Front Inflation, independent of
# the Depth Scale that scales the rear recession.
inflation = _math(
group, "MULTIPLY",
_math(
group, "MULTIPLY", read_float_attribute(group, "o_balloon"),
controls.outputs["Thickness"],
),
controls.outputs["Front Inflation"],
)
front_amount = nodes.new("GeometryNodeSwitch")
front_amount.input_type = "FLOAT"
links.new(is_shell, front_amount.inputs["Switch"])
links.new(inflation, front_amount.inputs["False"])
front_amount.inputs["True"].default_value = 0.0
front = nodes.new("GeometryNodeSetPosition")
links.new(surface, front.inputs["Geometry"])
links.new(offset(front_amount.outputs[0]), front.inputs["Offset"])
# The rear leaf inflates along the negated front direction, straight from the
# untouched surface so the front offset cannot drag it along.
span = _math(group, "MULTIPLY", amount, -1.0)
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Individual"].default_value = False
links.new(surface, extrude.inputs["Mesh"])
links.new(offset(span), extrude.inputs["Offset"])
# The rear surface smooths in proportion to its thickness and the balloon profile,
# so thin shells and cut boundaries keep their exact positions.
thickness = nodes.new("GeometryNodeSwitch")
thickness.input_type = "FLOAT"
links.new(is_shell, thickness.inputs["Switch"])
links.new(controls.outputs["Thickness"], thickness.inputs["False"])
links.new(controls.outputs["Shell Thickness"], thickness.inputs["True"])
weight = _math(
group, "MULTIPLY", _math(group, "MINIMUM", thickness.outputs[0], 1.0),
read_float_attribute(group, "o_balloon"),
)
position = nodes.new("GeometryNodeInputPosition")
blur = nodes.new("GeometryNodeBlurAttribute")
blur.data_type = "FLOAT_VECTOR"
blur.inputs["Weight"].default_value = 1.0
links.new(position.outputs["Position"], blur.inputs["Value"])
links.new(group_input(nodes, {"Back Smooth"}).outputs["Back Smooth"], blur.inputs["Iterations"])
difference = nodes.new("ShaderNodeVectorMath")
difference.operation = "SUBTRACT"
links.new(blur.outputs["Value"], difference.inputs[0])
links.new(position.outputs["Position"], difference.inputs[1])
smooth_offset = nodes.new("ShaderNodeVectorMath")
smooth_offset.operation = "SCALE"
links.new(difference.outputs["Vector"], smooth_offset.inputs[0])
links.new(weight, smooth_offset.inputs[3])
smoothed = nodes.new("GeometryNodeSetPosition")
links.new(extrude.outputs["Mesh"], smoothed.inputs["Geometry"])
links.new(smooth_offset.outputs["Vector"], smoothed.inputs["Offset"])
# Extruding against the normal leaves the rear leaf and wall facing inward.
flipped = nodes.new("GeometryNodeFlipFaces")
links.new(smoothed.outputs["Geometry"], flipped.inputs["Mesh"])
rear = store_float_attribute(
group, flipped.outputs["Mesh"], NORMAL_REDUCTION_ATTRIBUTE_NAME, 1.0,
)
join = nodes.new("GeometryNodeJoinGeometry")
links.new(front.outputs["Geometry"], join.inputs["Geometry"])
links.new(rear, join.inputs["Geometry"])
merge = nodes.new("GeometryNodeMergeByDistance")
merge.mode = "ALL"
links.new(weld_distance, merge.inputs["Distance"])
links.new(join.outputs["Geometry"], merge.inputs["Geometry"])
return merge.outputs["Geometry"]
def _build_depth_surface(group, geometry, controls):
"""Project the depth image and inflate the projected surface into a solid."""
links = group.links
split = controls.outputs["Depth Split"]
image = controls.outputs["Depth Image"]
scale = controls.outputs["Uniform Scale"]
geometry, corner_camera, cut_vertex = split_depth_surface(
group, geometry, sample_face_camera(group, image), split, controls.outputs["Reference Depth"],
controls.outputs["Depth Scale"], triangles=True,
)
geometry = remove_boundary_triangles(group, geometry)
boundary = edge_boundary_field(group.nodes, group.links)
rim = evaluate_field(group, boundary, "FLOAT", "POINT")
cut = evaluate_field(
group, boolean_node(group, "AND", boundary, cut_vertex), "FLOAT", "POINT",
)
# Only the Depth Split cuts taper the profile; the outline keeps its thickness.
geometry = taper_split_profile(group, geometry, cut)
camera = build_surface_camera(
group, _position_field(group, image), corner_camera, cut_vertex, split,
)
projection = project_depth_surface(
group, geometry, camera, scale, controls.outputs["Reference Depth"], controls.outputs["Depth Scale"],
)
projection = smooth_cut_boundary(group, projection, cut_vertex, boundary=rim)
# Clamped depth pixels can collapse adjacent outline samples after projection.
edge = group.nodes.new("GeometryNodeInputMeshEdgeVertices")
length = group.nodes.new("ShaderNodeVectorMath")
length.operation = "DISTANCE"
links.new(edge.outputs["Position 1"], length.inputs[0])
links.new(edge.outputs["Position 2"], length.inputs[1])
typical = group.nodes.new("GeometryNodeAttributeStatistic")
typical.data_type, typical.domain = "FLOAT", "EDGE"
links.new(projection, typical.inputs["Geometry"])
links.new(length.outputs["Value"], typical.inputs["Attribute"])
merge = group.nodes.new("GeometryNodeMergeByDistance")
merge.mode = "CONNECTED"
links.new(projection, merge.inputs["Geometry"])
links.new(_math(group, "MULTIPLY", typical.outputs["Mean"], 1e-6), merge.inputs["Distance"])
projection = merge.outputs["Geometry"]
# The mode field is shared by the thickness and direction choices.
mode = group.nodes.new("GeometryNodeMenuSwitch")
mode.data_type = "BOOLEAN"
for item, name in zip(mode.enum_items, ("Balloon", "Shell")):
item.name = name
links.new(controls.outputs["Mode"], mode.inputs["Menu"])
mode.inputs["Balloon"].default_value = False
mode.inputs["Shell"].default_value = True
is_shell = mode.outputs[0]
amount = build_inflation_amount(group, controls, is_shell)
statistics = group.nodes.new("GeometryNodeAttributeStatistic")
statistics.data_type, statistics.domain = "FLOAT", "POINT"
links.new(projection, statistics.inputs["Geometry"])
links.new(amount, statistics.inputs["Attribute"])
has_thickness = compare_node(group, "GREATER_THAN", statistics.outputs["Max"], 1e-6)
profile = build_profile_weight(group, projection)
smoothed, average = build_smoothed_normals(group)
weight = build_normal_weight(group, is_shell, profile)
# Material normal strength: the balloon profile gates it, Depth Scale attenuates it.
inflated = group.nodes.new("GeometryNodeSwitch")
inflated.input_type = "FLOAT"
links.new(is_shell, inflated.inputs["Switch"])
links.new(profile, inflated.inputs["False"])
inflated.inputs["True"].default_value = 1.0
normal_strength = group.nodes.new("GeometryNodeSwitch")
normal_strength.input_type = "FLOAT"
links.new(has_thickness, normal_strength.inputs["Switch"])
normal_strength.inputs["False"].default_value = 1.0
links.new(inflated.outputs[0], normal_strength.inputs["True"])
reduction = _math(
group, "SUBTRACT", 1.0,
_math(group, "MULTIPLY", controls.outputs["Depth Scale"], normal_strength.outputs[0]),
)
projection = store_float_attribute(
group, projection, NORMAL_REDUCTION_ATTRIBUTE_NAME, reduction,
)
# The rear leaf inflates along the negated front direction, so one direction is
# carried across the extrusion instead of being re-evaluated on the moved surface.
projection = store_vector_attribute(
group, projection, FRONT_NORMAL_ATTRIBUTE,
build_normal_direction(group, controls, smoothed, average, weight),
)
solid = build_solid(
group, projection, amount,
read_vector_attribute(group, FRONT_NORMAL_ATTRIBUTE),
is_shell, controls,
_math(group, "MULTIPLY", typical.outputs["Mean"], SOLID_WELD_SCALE),
)
shaped = group.nodes.new("GeometryNodeSwitch")
shaped.input_type = "GEOMETRY"
links.new(has_thickness, shaped.inputs["Switch"])
links.new(projection, shaped.inputs["False"])
links.new(solid, shaped.inputs["True"])
return remove_attribute_pattern(group, shaped.outputs[0], "_o_*")
def build_image_depth_cutout_group():
name = NODE_GROUP_NAME
existing = bpy.data.node_groups.get(name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
group.is_modifier, group.use_fake_user = (True, True)
group.color_tag = 'GEOMETRY'
interface_socket(group, 'Geometry', 'INPUT', 'NodeSocketGeometry')
interface_socket(group, 'Mode', 'INPUT', 'NodeSocketMenu')
float_input(group, 'Thickness', 0.0, maximum=2.0).description = 'Control how much the surface bulges in Balloon mode. Zero removes the bulge.'
float_input(group, 'Shell Thickness', 0.0, maximum=1.0, subtype='DISTANCE').description = 'Add thickness behind the original surface while keeping the front surface in place.'
float_input(group, 'Depth Scale', 1.0, maximum=2.0)
float_input(group, 'Depth Split', 0.1, maximum=1, subtype='FACTOR').description = 'Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps.'
options = group.interface.new_panel(name='Options', default_closed=True)
boundary_smooth = interface_socket(group, 'Boundary Smooth', 'INPUT', 'NodeSocketInt', options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
boundary_smooth.description = 'Smooth geometry near the original outline and Depth Split edges. Zero disables boundary smoothing.'
back_smooth = interface_socket(group, 'Back Smooth', 'INPUT', 'NodeSocketInt', options)
back_smooth.default_value, back_smooth.min_value, back_smooth.max_value = 8, 0, 16
back_smooth.description = 'Soften the rear surface where the balloon is thick. The effect follows Thickness and the balloon profile, so thin geometry and cut boundaries keep their positions.'
float_input(group, 'Edge Turn', 2.0, minimum=-3.0, maximum=3.0, parent=options).description = 'Turn the outline direction: zero follows the smoothed normal, one reaches the island average normal, two mirrors the smoothed normal across it, and higher or negative values keep turning either way.'
float_input(group, 'Front Inflation', 0.2, maximum=1, subtype='FACTOR', parent=options).description = 'Control how much the front surface bulges in Balloon mode, as a share of the balloon thickness. Zero keeps the original front surface.'
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
data = group.interface.new_panel(name='Data', default_closed=True)
float_input(group, 'Uniform Scale', 1.0, parent=data).hide_in_modifier = True
interface_socket(group, 'Depth Image', 'INPUT', 'NodeSocketImage', data).hide_in_modifier = True
interface_socket(group, 'Geometry', 'OUTPUT', 'NodeSocketGeometry')
controls = group_input(group.nodes, set())
geometry = _build_depth_surface(group, controls.outputs['Geometry'], controls)
_shade_output(group, geometry)
prepare_node_group(group)
for item in group.interface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
continue
if item.name == 'Mode':
item.default_value = 'Balloon'
elif item.name == 'Shell Thickness':
item.name = 'Thickness'
return group

View File

@@ -0,0 +1,90 @@
"""Build O Image Depth Layer."""
import bpy
from anyimage.common.material import DEPTH_MATERIAL_NODE_GROUP_NAME
from ..common.shader import build_material_nodes, connect_material_outputs
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
def build_image_depth_layer_group():
group_name = DEPTH_MATERIAL_NODE_GROUP_NAME
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(group_name, 'ShaderNodeTree')
interface = group.interface
input_sockets = [('Color', 'NodeSocketColor'), ('Alpha', 'NodeSocketFloat'), ('Bump Scale', 'NodeSocketFloat')]
input_sockets.insert(2, ('Depth', 'NodeSocketFloat'))
options = None
option_names = {'Alpha Fix', 'Normal Scale', 'Object Space'}
for name, socket_type in input_sockets:
socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None)
if name in {'Alpha', 'Alpha Fix'}:
socket.default_value = 1.0 if name == 'Alpha' else 0.0
socket.min_value = 0.0
socket.max_value = 1.0
socket.subtype = 'FACTOR'
elif name == 'Bump Scale':
socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.'
socket.default_value = 0.0
socket.min_value = -10000.0
socket.max_value = 10000.0
elif name == 'Normal':
socket.default_value = (0.5, 0.5, 1.0, 1.0)
elif name == 'Normal Scale':
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 10.0
elif name == 'Object Space':
socket.default_value = False
output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')]
output_sockets.append(('Displacement', 'NodeSocketVector'))
for name, socket_type in output_sockets:
interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type)
nodes, links = group.nodes, group.links
group_input, group_output, roughness_curve, bump = build_material_nodes(group)
processed_alpha = group_input.outputs['Alpha']
displacement = nodes.new('ShaderNodeDisplacement')
displacement.inputs['Midlevel'].default_value = 0.0
displacement.inputs['Scale'].default_value = 1.0
background_attribute = nodes.new('ShaderNodeAttribute')
background_attribute.attribute_type = 'GEOMETRY'
background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
material_attribute = nodes.new('ShaderNodeAttribute')
material_attribute.attribute_type = 'GEOMETRY'
material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
strength_attribute = nodes.new('ShaderNodeAttribute')
strength_attribute.attribute_type = 'GEOMETRY'
strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE
foreground_mask = nodes.new('ShaderNodeMath')
foreground_mask.operation = 'GREATER_THAN'
foreground_mask.inputs[1].default_value = 0.5
clamp_foreground = nodes.new('ShaderNodeMath')
clamp_foreground.operation = 'MAXIMUM'
masked_depth = nodes.new('ShaderNodeMix')
masked_depth.data_type = 'FLOAT'
enabled_depth = nodes.new('ShaderNodeMath')
enabled_depth.operation = 'MULTIPLY'
scaled_depth = nodes.new('ShaderNodeMath')
scaled_depth.operation = 'MULTIPLY'
links.new(group_input.outputs['Alpha'], foreground_mask.inputs[0])
links.new(group_input.outputs['Depth'], clamp_foreground.inputs[0])
links.new(background_attribute.outputs['Fac'], clamp_foreground.inputs[1])
links.new(foreground_mask.outputs[0], masked_depth.inputs[0])
links.new(background_attribute.outputs['Fac'], masked_depth.inputs[2])
links.new(clamp_foreground.outputs[0], masked_depth.inputs[3])
links.new(masked_depth.outputs['Result'], enabled_depth.inputs[0])
links.new(material_attribute.outputs['Fac'], enabled_depth.inputs[1])
links.new(enabled_depth.outputs[0], scaled_depth.inputs[0])
links.new(strength_attribute.outputs['Fac'], scaled_depth.inputs[1])
processed_alpha = foreground_mask.outputs[0]
processed_depth = scaled_depth.outputs[0]
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
links.new(processed_depth, displacement.inputs['Height'])
links.new(displacement.outputs['Displacement'], group_output.inputs['Displacement'])
group.color_tag = 'SHADER'
group.description = 'Prepare image depth material outputs.'
group.use_fake_user = True
return group

View File

@@ -0,0 +1,212 @@
"""Build a quad panorama with periodic sampling and radial depth separation."""
import math
import bpy
from ..common.depth_surface import (
_math, split_depth_surface, build_surface_camera,
)
from ..common.nodes import (
boolean_node, compare_node, float_input, group_input, interface_socket,
prepare_node_group, evaluate_field, remove_attribute_pattern,
)
from ..common.material_slots import inherit_material_slots
from ..common.boundary_smoothing import smooth_boundary_depth, smooth_cut_boundary
def _quad_sphere(group, segments):
nodes, links = group.nodes, group.links
cube = nodes.new("GeometryNodeMeshCube")
cube.inputs["Size"].default_value = (2, 2, 2)
for axis in "XYZ":
links.new(segments, cube.inputs[f"Vertices {axis}"])
xyz = nodes.new("ShaderNodeSeparateXYZ")
links.new(nodes.new("GeometryNodeInputPosition").outputs[0], xyz.inputs[0])
squares = {axis: _math(group, "MULTIPLY", xyz.outputs[axis], xyz.outputs[axis]) for axis in "XYZ"}
combine = nodes.new("ShaderNodeCombineXYZ")
for axis, a, b in (("X", "Y", "Z"), ("Y", "Z", "X"), ("Z", "X", "Y")):
half = _math(group, "DIVIDE", _math(group, "ADD", squares[a], squares[b]), 2)
third = _math(group, "DIVIDE", _math(group, "MULTIPLY", squares[a], squares[b]), 3)
root = _math(group, "SQRT", _math(group, "ADD", _math(group, "SUBTRACT", 1, half), third))
links.new(_math(group, "MULTIPLY", xyz.outputs[axis], root), combine.inputs[axis])
position = nodes.new("GeometryNodeSetPosition")
links.new(cube.outputs["Mesh"], position.inputs["Geometry"])
links.new(combine.outputs[0], position.inputs["Position"])
return position.outputs["Geometry"]
def build_image_depth_panorama_group():
group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
group.is_modifier = group.use_fake_user = True
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
subdivide.default_value, subdivide.min_value, subdivide.max_value = 8, 0, 8
dome = float_input(group, "Dome Radius", 50, minimum=1, maximum=100, subtype="DISTANCE")
dome.description = "Radius of the sphere kept at Depth Scale 0, and where mask areas below Mask Threshold land."
float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR").description = "Blend between a sphere at zero and the estimated depth surface at one."
float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps."
valid_only = interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
valid_only.default_value = True
valid_only.description = "Remove mesh areas marked invalid by the depth mask; with the mask off, they rest on the dome sphere."
options = group.interface.new_panel("Options", default_closed=True)
boundary_smooth = interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 2, 0, 16
boundary_smooth.description = "Smooth depth and geometry near cut edges on the panorama surface. Zero disables boundary smoothing."
float_input(group, "Mask Threshold", 0.9, maximum=0.99, subtype="FACTOR", parent=options).description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."
data = group.interface.new_panel("Data", default_closed=True)
interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes, links = group.nodes, group.links
def uv(direction):
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(direction, normalize.inputs[0])
xyz = nodes.new("ShaderNodeSeparateXYZ")
links.new(normalize.outputs[0], xyz.inputs[0])
longitude = _math(group, "DIVIDE", _math(group, "ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"]), 2 * math.pi)
z = _math(group, "MAXIMUM", -1, _math(group, "MINIMUM", 1, xyz.outputs["Z"]))
latitude = _math(group, "SUBTRACT", 1, _math(group, "DIVIDE", _math(group, "ARCCOSINE", z), math.pi))
return longitude, latitude, xyz.outputs["Z"]
def combine(u, v):
node = nodes.new("ShaderNodeCombineXYZ")
links.new(u, node.inputs["X"])
links.new(v, node.inputs["Y"])
return node.outputs[0]
def texture(coordinates, interpolation):
inputs = group_input(nodes, {"Depth Image"})
info = nodes.new("GeometryNodeImageInfo")
links.new(inputs.outputs["Depth Image"], info.inputs["Image"])
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(coordinates, axes.inputs[0])
half_pixel = _math(group, "DIVIDE", 0.5, info.outputs["Height"])
v = _math(group, "MAXIMUM", half_pixel,
_math(group, "MINIMUM", axes.outputs["Y"], _math(group, "SUBTRACT", 1, half_pixel)))
node = nodes.new("GeometryNodeImageTexture")
node.interpolation, node.extension = interpolation, "REPEAT"
links.new(inputs.outputs["Depth Image"], node.inputs["Image"])
links.new(combine(axes.outputs["X"], v), node.inputs["Vector"])
return node.outputs["Color"]
def depth(coordinates):
linear, nearest = texture(coordinates, "Linear"), texture(coordinates, "Closest")
alphas = []
for color in (linear, nearest):
separate = nodes.new("FunctionNodeSeparateColor")
links.new(color, separate.inputs["Color"])
alphas.append(separate.outputs["Alpha"])
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "RGBA"
links.new(compare_node(group, "LESS_THAN", alphas[0],
_math(group, "SUBTRACT", alphas[1], 1e-6)), choose.inputs["Switch"])
links.new(linear, choose.inputs["False"])
links.new(nearest, choose.inputs["True"])
return choose.outputs[0], alphas[1]
inputs = group_input(nodes, {"Subdivide"})
sphere = _quad_sphere(group, _math(group, "ADD", _math(group, "POWER", 2, inputs.outputs["Subdivide"]), 1))
position = nodes.new("GeometryNodeInputPosition").outputs[0]
longitude, latitude, z = uv(position)
coordinates = evaluate_field(group, combine(longitude, latitude), "FLOAT_VECTOR", "POINT")
face = nodes.new("GeometryNodeFieldOnDomain")
face.data_type, face.domain = "FLOAT_VECTOR", "FACE"
links.new(position, face.inputs["Value"])
face_u, face_v, _ = uv(face.outputs["Value"])
correction = _math(group, "ROUND", _math(group, "SUBTRACT", face_u, longitude))
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "FLOAT"
links.new(compare_node(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"])
links.new(_math(group, "ADD", longitude, correction), choose.inputs["False"])
links.new(face_u, choose.inputs["True"])
uv_map = nodes.new("GeometryNodeStoreNamedAttribute")
uv_map.data_type, uv_map.domain = "FLOAT2", "CORNER"
uv_map.inputs["Name"].default_value = "UVMap"
links.new(sphere, uv_map.inputs["Geometry"])
links.new(combine(choose.outputs[0], latitude), uv_map.inputs["Value"])
pixel, _ = depth(coordinates)
pixel = evaluate_field(group, pixel, "FLOAT_VECTOR", "POINT")
# Both the mask and the depth of a face are read at its centre, like Depth Plane.
face_camera, face_alpha = depth(combine(face_u, face_v))
face_alpha = evaluate_field(group, face_alpha, "FLOAT", "FACE")
threshold_input = group_input(nodes, {"Mask Threshold"})
invalid = compare_node(group, "LESS_THAN", face_alpha, threshold_input.outputs["Mask Threshold"])
protected = uv_map.outputs["Geometry"]
separate = nodes.new("GeometryNodeSeparateGeometry")
separate.domain = "FACE"
links.new(protected, separate.inputs["Geometry"])
links.new(invalid, separate.inputs["Selection"])
controls = group_input(nodes, {"Depth Split", "Depth Scale"})
strength = controls.outputs["Depth Split"]
radial = nodes.new("ShaderNodeSeparateXYZ")
links.new(face_camera, radial.inputs[0])
geometry, corner_camera, cut_vertex = split_depth_surface(
group, separate.outputs["Inverted"], face_camera, strength, 1.0, controls.outputs["Depth Scale"],
face_distance=radial.outputs["X"], face_center=face.outputs["Value"],
)
distance = build_surface_camera(group, pixel, corner_camera, cut_vertex, strength)
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(distance, axes.inputs[0])
distance = smooth_boundary_depth(group, axes.outputs["X"])
dome_radius = group_input(nodes, {"Dome Radius"}).outputs["Dome Radius"]
radius = _math(
group, "ADD", dome_radius,
_math(group, "MULTIPLY", controls.outputs["Depth Scale"],
_math(group, "SUBTRACT", distance, dome_radius)),
)
radial = nodes.new("ShaderNodeVectorMath")
radial.operation = "SCALE"
links.new(position, radial.inputs[0])
links.new(radius, radial.inputs[3])
deform = nodes.new("GeometryNodeSetPosition")
links.new(geometry, deform.inputs["Geometry"])
links.new(radial.outputs[0], deform.inputs["Position"])
geometry = remove_attribute_pattern(group, deform.outputs[0], "_o_*")
geometry = smooth_cut_boundary(group, geometry, cut_vertex, outline_scale=1.0)
# The excluded faces rest on the dome sphere and relax along their own boundary.
domed = nodes.new("ShaderNodeVectorMath")
domed.operation = "SCALE"
links.new(position, domed.inputs[0])
links.new(dome_radius, domed.inputs[3])
resting = nodes.new("GeometryNodeSetPosition")
links.new(separate.outputs["Selection"], resting.inputs["Geometry"])
links.new(domed.outputs[0], resting.inputs["Position"])
resting = smooth_cut_boundary(group, resting.outputs["Geometry"], outline_scale=1.0)
joined = nodes.new("GeometryNodeJoinGeometry")
links.new(geometry, joined.inputs["Geometry"])
links.new(resting, joined.inputs["Geometry"])
# A flat projection without the mask is the dome sphere alone: no split, no seam.
dome_sphere = nodes.new("GeometryNodeSetPosition")
links.new(protected, dome_sphere.inputs["Geometry"])
links.new(domed.outputs[0], dome_sphere.inputs["Position"])
cull_controls = group_input(nodes, {"Depth Mask"})
dome_only = boolean_node(
group,
"AND",
boolean_node(group, "NOT", compare_node(group, "GREATER_THAN", controls.outputs["Depth Scale"], 0.0)),
boolean_node(group, "NOT", cull_controls.outputs["Depth Mask"]),
)
unmasked = nodes.new("GeometryNodeSwitch")
unmasked.input_type = "GEOMETRY"
links.new(dome_only, unmasked.inputs["Switch"])
links.new(joined.outputs["Geometry"], unmasked.inputs["False"])
links.new(dome_sphere.outputs["Geometry"], unmasked.inputs["True"])
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
links.new(unmasked.outputs["Output"], cull.inputs["False"])
links.new(geometry, cull.inputs["True"])
flip = nodes.new("GeometryNodeFlipFaces")
links.new(cull.outputs["Output"], flip.inputs["Mesh"])
smooth = nodes.new("GeometryNodeSetShadeSmooth")
smooth.domain = "FACE"
smooth.inputs["Shade Smooth"].default_value = True
links.new(flip.outputs["Mesh"], smooth.inputs["Geometry"])
geometry = inherit_material_slots(group, smooth.outputs[0], group_input(nodes, {"Geometry"}).outputs["Geometry"])
output = nodes.new("NodeGroupOutput")
links.new(geometry, output.inputs["Geometry"])
prepare_node_group(group)
return group

View File

@@ -0,0 +1,158 @@
"""Build the camera-projected Depth Plane Geometry Nodes group."""
import bpy
from ..common.validity import delete_invalid_faces
from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket
from ..common.nodes import compare_node, prepare_node_group, float_input, evaluate_field
from ..common.boundary_smoothing import smooth_boundary_camera, smooth_cut_boundary
from ..common.depth_surface import (
_math, project_depth_surface, thicken_depth_surface,
split_depth_surface, sample_face_camera, build_surface_camera,
)
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
def build_image_depth_plane_group(image_plane_group):
group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME)
if group is not None:
return group
group = bpy.data.node_groups.new(
DEPTH_PLANE_NODE_GROUP_NAME,
"GeometryNodeTree",
)
group.is_modifier = True
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
subdivide.default_value = 6
subdivide.min_value = 0
subdivide.max_value = 10
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
thickness.description = "Add shell thickness inward from the depth surface. Zero keeps a single surface."
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
depth_scale.default_value = 1.0
depth_scale.min_value = 0.0
depth_scale.max_value = 2.0
float_input(group, "Depth Split", 0.1, maximum=1, subtype="FACTOR").description = "Split the mesh where depth changes abruptly. Scaled by Depth Scale: a flat projection stays whole, higher values detect smaller depth jumps."
valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
valid_only.description = "Remove mesh areas marked invalid by the depth mask."
valid_only.default_value = True
options = group.interface.new_panel(name="Options", default_closed=True)
boundary_smooth = _interface_socket(group, "Boundary Smooth", "INPUT", "NodeSocketInt", parent=options)
boundary_smooth.default_value, boundary_smooth.min_value, boundary_smooth.max_value = 4, 0, 16
boundary_smooth.description = "Smooth depth and geometry near cut edges while preserving the original image border. Zero disables boundary smoothing."
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", parent=options)
validity_threshold.description = "Set the minimum depth mask value treated as valid. Higher values exclude more partially valid boundary areas."
validity_threshold.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99
validity_threshold.subtype = "FACTOR"
float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options)
data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _interface_socket(
group,
"Uniform Scale",
"INPUT",
"NodeSocketFloat",
parent=data,
)
uniform_scale.default_value = 1.0
uniform_scale.min_value = 0.0
uniform_scale.hide_in_modifier = True
depth_image = _interface_socket(
group,
"Depth Image",
"INPUT",
"NodeSocketImage",
parent=data,
)
depth_image.hide_in_modifier = True
_interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes, links = group.nodes, group.links
controls = _group_input(nodes, {"Geometry", "Subdivide"})
plane = nodes.new("GeometryNodeGroup")
plane.node_tree = image_plane_group
plane.inputs["Thickness"].default_value = 0
links.new(controls.outputs["Geometry"], plane.inputs["Geometry"])
links.new(controls.outputs["Subdivide"], plane.inputs["Subdivide"])
texture_input = _group_input(nodes, {"Depth Image"})
scale_input = _group_input(nodes, {"Uniform Scale"})
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
texture = nodes.new("GeometryNodeImageTexture")
texture.interpolation, texture.extension = "Linear", "EXTEND"
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
links.new(texture_input.outputs["Depth Image"], texture.inputs["Image"])
threshold_input = _group_input(nodes, {"Mask Threshold"})
channels = nodes.new("FunctionNodeSeparateColor")
channels.mode = "RGB"
links.new(texture.outputs["Color"], channels.inputs["Color"])
invalid = nodes.new("FunctionNodeCompare")
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
links.new(channels.outputs["Alpha"], invalid.inputs["A"])
links.new(threshold_input.outputs["Mask Threshold"], invalid.inputs["B"])
# Image Plane's normalized UV rectangle retains its original outline after cuts.
uv_axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(uv.outputs["Attribute"], uv_axes.inputs[0])
u, v = uv_axes.outputs["X"], uv_axes.outputs["Y"]
margin = _math(group, "MINIMUM", _math(group, "MINIMUM", u, v),
_math(group, "MINIMUM", _math(group, "SUBTRACT", 1, u), _math(group, "SUBTRACT", 1, v)))
original_boundary = evaluate_field(group, compare_node(group, "LESS_THAN", margin, 1e-6), "BOOLEAN", "POINT")
protected = plane.outputs["Geometry"]
culled = delete_invalid_faces(group, protected, invalid.outputs["Result"])
cull_controls = _group_input(nodes, {"Depth Mask"})
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"
links.new(cull_controls.outputs["Depth Mask"], cull.inputs["Switch"])
links.new(culled, cull.inputs["True"])
links.new(protected, cull.inputs["False"])
split_input = _group_input(nodes, {"Depth Split", "Depth Scale"})
reference_input = _group_input(nodes, {"Reference Depth"})
geometry, corner_camera, cut_vertex = split_depth_surface(
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
split_input.outputs["Depth Split"], reference_input.outputs["Reference Depth"],
split_input.outputs["Depth Scale"],
)
camera = build_surface_camera(
group, texture.outputs["Color"], corner_camera, cut_vertex,
split_input.outputs["Depth Split"],
)
depth_input = _group_input(nodes, {"Reference Depth", "Depth Scale"})
camera = smooth_boundary_camera(group, camera, original_boundary)
surface = project_depth_surface(
group, geometry, camera,
scale_input.outputs["Uniform Scale"], depth_input.outputs["Reference Depth"],
depth_input.outputs["Depth Scale"],
)
surface = smooth_cut_boundary(
group, surface, cut_vertex, protected=original_boundary, outline_scale=1.0,
)
shell_input = _group_input(nodes, {"Thickness"})
shell = thicken_depth_surface(
group, surface, shell_input.outputs["Thickness"],
normal_smooth=100,
)
positive = compare_node(group, "GREATER_THAN", shell_input.outputs["Thickness"], 0)
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "GEOMETRY"
links.new(positive, choose.inputs["Switch"])
links.new(surface, choose.inputs["False"])
links.new(shell["geometry"], choose.inputs["True"])
smooth = nodes.new("GeometryNodeSetShadeSmooth")
smooth.domain = "FACE"
links.new(choose.outputs["Output"], smooth.inputs["Geometry"])
output = nodes.new("NodeGroupOutput")
links.new(smooth.outputs["Geometry"], output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = "Build a camera-projected image shell."
group.use_fake_user = True
prepare_node_group(group)
return group

103
nodes/groups/image_layer.py Normal file
View File

@@ -0,0 +1,103 @@
"""Build O Image Layer."""
import bpy
from anyimage.common.material import IMAGE_MATERIAL_NODE_GROUP_NAME
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.shader import build_material_nodes, connect_material_outputs
from ..common.normal_map import transform_object_normal_color
def build_image_layer_group():
group_name = IMAGE_MATERIAL_NODE_GROUP_NAME
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
return existing
group = bpy.data.node_groups.new(group_name, 'ShaderNodeTree')
interface = group.interface
input_sockets = [('Color', 'NodeSocketColor'), ('Alpha', 'NodeSocketFloat'), ('Bump Scale', 'NodeSocketFloat')]
input_sockets.insert(2, ('Normal', 'NodeSocketColor'))
input_sockets.insert(3, ('Normal Scale', 'NodeSocketFloat'))
input_sockets.insert(4, ('Object Space', 'NodeSocketBool'))
input_sockets.insert(2, ('Alpha Fix', 'NodeSocketFloat'))
options = interface.new_panel(name='Options', default_closed=True)
option_names = {'Alpha Fix', 'Object Space'}
for name, socket_type in input_sockets:
socket = interface.new_socket(name=name, in_out='INPUT', socket_type=socket_type, parent=options if name in option_names else None)
if name in {'Alpha', 'Alpha Fix'}:
socket.default_value = 1.0 if name == 'Alpha' else 0.0
socket.min_value = 0.0
socket.max_value = 1.0
socket.subtype = 'FACTOR'
elif name == 'Bump Scale':
socket.description = 'Control bump detail derived from image brightness. Negative values reverse the bumps. Zero disables the effect.'
socket.default_value = 0.0
socket.min_value = -10000.0
socket.max_value = 10000.0
elif name == 'Normal':
socket.default_value = (0.5, 0.5, 1.0, 1.0)
elif name == 'Normal Scale':
socket.description = 'Control the strength of surface detail from the normal map.'
socket.default_value = 1.0
socket.min_value = 0.0
socket.max_value = 10.0
elif name == 'Object Space':
socket.description = 'Interpret the normal map in object space when enabled, or tangent space when disabled.'
socket.default_value = False
if name == 'Alpha Fix':
socket.description = 'Reduce semi-transparent fringes around image edges. Higher values remove more faint edge pixels. Zero keeps the original transparency.'
output_sockets = [('Color', 'NodeSocketColor'), ('Roughness', 'NodeSocketFloat'), ('Alpha', 'NodeSocketFloat'), ('Normal', 'NodeSocketVector')]
for name, socket_type in output_sockets:
interface.new_socket(name=name, in_out='OUTPUT', socket_type=socket_type)
nodes, links = group.nodes, group.links
group_input, group_output, roughness_curve, bump = build_material_nodes(group)
normal_attribute = nodes.new('ShaderNodeAttribute')
normal_attribute.attribute_type = 'GEOMETRY'
normal_attribute.attribute_name = NORMAL_REDUCTION_ATTRIBUTE_NAME
normal_weight = nodes.new('ShaderNodeMath')
normal_weight.operation = 'SUBTRACT'
normal_weight.inputs[0].default_value = 1.0
links.new(normal_attribute.outputs['Fac'], normal_weight.inputs[1])
weighted_strength = nodes.new('ShaderNodeMath')
weighted_strength.operation = 'MULTIPLY'
links.new(group_input.outputs['Normal Scale'], weighted_strength.inputs[0])
links.new(normal_weight.outputs[0], weighted_strength.inputs[1])
tangent_normal = nodes.new('ShaderNodeNormalMap')
tangent_normal.space = 'TANGENT'
object_normal = nodes.new('ShaderNodeNormalMap')
object_normal.space = 'OBJECT'
choose_normal = nodes.new('ShaderNodeMix')
choose_normal.data_type = 'VECTOR'
links.new(group_input.outputs['Normal'], tangent_normal.inputs['Color'])
links.new(weighted_strength.outputs[0], tangent_normal.inputs['Strength'])
normal_color, normal_strength = transform_object_normal_color(
group, group_input.outputs['Normal'], weighted_strength.outputs[0],
)
links.new(normal_color, object_normal.inputs['Color'])
links.new(normal_strength, object_normal.inputs['Strength'])
links.new(group_input.outputs['Object Space'], choose_normal.inputs[0])
links.new(tangent_normal.outputs['Normal'], choose_normal.inputs[4])
links.new(object_normal.outputs['Normal'], choose_normal.inputs[5])
links.new(choose_normal.outputs['Result'], bump.inputs['Normal'])
processed_alpha = group_input.outputs['Alpha']
alpha_curve = nodes.new('ShaderNodeFloatCurve')
alpha_curve.mapping.initialize()
alpha_curve.mapping.extend = 'HORIZONTAL'
alpha_curve.mapping.use_clip = True
alpha_points = alpha_curve.mapping.curves[0].points
alpha_points[0].location = (0.0, 0.0)
alpha_points[-1].location = (1.0, 1.0)
alpha_points.new(0.8, 0.0)
alpha_points.new(0.925, 0.925)
for point in alpha_points:
point.handle_type = 'AUTO_CLAMPED'
alpha_curve.mapping.update()
links.new(group_input.outputs['Alpha'], alpha_curve.inputs['Value'])
links.new(group_input.outputs['Alpha Fix'], alpha_curve.inputs['Factor'])
processed_alpha = alpha_curve.outputs['Value']
connect_material_outputs(group, group_input, group_output, roughness_curve, bump, processed_alpha)
group.color_tag = 'SHADER'
group.description = f'Prepare {group_name} material outputs.'
group.use_fake_user = True
for node in nodes:
for socket in node.outputs:
socket.hide = not socket.is_linked
return group

253
nodes/groups/image_plane.py Normal file
View File

@@ -0,0 +1,253 @@
"""Build the Image Plane Geometry Nodes group."""
import bpy
from mathutils import Matrix
from ..common.nodes import prepare_node_group
from ..common.material_slots import inherit_material_slots
IMAGE_PLANE_GROUP_NAME = "O Image Plane"
def new_socket(interface, name, in_out, socket_type, parent=None):
socket = interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
parent=parent,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def new_group_input(nodes, visible_names):
node = nodes.new("NodeGroupInput")
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def build_dimension_fields(group):
"""Build independent source bounds for UV and mesh calculations."""
sources = [
group.nodes[name] for name in ("Bounding Box", "Vector Math", "Separate XYZ")
]
for targets in (
{"Math.011", "Math.013"},
{"Grid", "Combine XYZ.001", "Vector Math.001"},
):
copies = {source: group.nodes.new(source.bl_idname) for source in sources}
copies[sources[1]].operation = "SUBTRACT"
for link in list(group.links):
if link.to_node in copies:
source = copies.get(link.from_node, link.from_node)
group.links.new(
source.outputs[link.from_socket.identifier],
copies[link.to_node].inputs[link.to_socket.identifier],
)
elif link.from_node in copies and link.to_node.name in targets:
group.links.new(
copies[link.from_node].outputs[link.from_socket.identifier],
link.to_socket,
)
def build_image_plane_group():
group = bpy.data.node_groups.new(IMAGE_PLANE_GROUP_NAME, "GeometryNodeTree")
group.is_modifier = True
interface = group.interface
geometry = new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
subdivide = new_socket(interface, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
subdivide.default_value = 0
subdivide.min_value = 0
subdivide.max_value = 10
thickness = new_socket(interface, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.max_value = 10000.0
thickness.subtype = "DISTANCE"
new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = new_group_input(nodes, {"Geometry"})
subdivide_input = new_group_input(nodes, {"Subdivide"})
thickness_input = new_group_input(nodes, {"Thickness"})
output = nodes.new("NodeGroupOutput")
bounds = nodes.new("GeometryNodeBoundBox")
links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"])
dimensions = nodes.new("ShaderNodeVectorMath")
dimensions.operation = "SUBTRACT"
links.new(bounds.outputs["Max"], dimensions.inputs[0])
links.new(bounds.outputs["Min"], dimensions.inputs[1])
separate_dimensions = nodes.new("ShaderNodeSeparateXYZ")
links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0])
short_segments = nodes.new("ShaderNodeMath")
short_segments.operation = "POWER"
short_segments.inputs[0].default_value = 2.0
links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1])
vertex_counts = []
for axis, other_axis in (("X", "Z"), ("Z", "X")):
ratio = nodes.new("ShaderNodeMath")
ratio.operation = "DIVIDE"
links.new(separate_dimensions.outputs[axis], ratio.inputs[0])
links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1])
at_least_one = nodes.new("ShaderNodeMath")
at_least_one.operation = "MAXIMUM"
at_least_one.inputs[1].default_value = 1.0
links.new(ratio.outputs[0], at_least_one.inputs[0])
scaled_segments = nodes.new("ShaderNodeMath")
scaled_segments.operation = "MULTIPLY"
links.new(short_segments.outputs[0], scaled_segments.inputs[0])
links.new(at_least_one.outputs[0], scaled_segments.inputs[1])
rounded_segments = nodes.new("ShaderNodeMath")
rounded_segments.operation = "ROUND"
links.new(scaled_segments.outputs[0], rounded_segments.inputs[0])
vertices = nodes.new("ShaderNodeMath")
vertices.operation = "ADD"
vertices.inputs[1].default_value = 1.0
links.new(rounded_segments.outputs[0], vertices.inputs[0])
vertex_counts.append(vertices.outputs[0])
grid = nodes.new("GeometryNodeMeshGrid")
links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"])
links.new(separate_dimensions.outputs["Z"], grid.inputs["Size Y"])
links.new(vertex_counts[0], grid.inputs["Vertices X"])
links.new(vertex_counts[1], grid.inputs["Vertices Y"])
position = nodes.new("GeometryNodeInputPosition")
separate_position = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], separate_position.inputs[0])
uv_components = []
for position_axis, dimension_axis in (("X", "X"), ("Y", "Z")):
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
links.new(normalize.outputs[0], offset_uv.inputs[0])
uv_components.append(offset_uv.outputs[0])
uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
links.new(uv_components[0], uv_coordinates.inputs["X"])
links.new(uv_components[1], uv_coordinates.inputs["Y"])
cube_uv_components = []
for position_axis, dimension_axis in (("X", "X"), ("Z", "Z")):
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
links.new(separate_position.outputs[position_axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[dimension_axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
links.new(normalize.outputs[0], offset_uv.inputs[0])
cube_uv_components.append(offset_uv.outputs[0])
cube_uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
links.new(cube_uv_components[0], cube_uv_coordinates.inputs["X"])
links.new(cube_uv_components[1], cube_uv_coordinates.inputs["Y"])
store_grid_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_grid_uv.data_type = "FLOAT2"
store_grid_uv.domain = "CORNER"
store_grid_uv.inputs["Name"].default_value = "UVMap"
links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"])
rotate_grid = nodes.new("GeometryNodeTransform")
rotate_grid.inputs["Rotation"].default_value = Matrix.Rotation(1.5707963267948966, 4, "X").to_euler()
links.new(store_grid_uv.outputs["Geometry"], rotate_grid.inputs["Geometry"])
center_sum = nodes.new("ShaderNodeVectorMath")
center_sum.operation = "ADD"
links.new(bounds.outputs["Min"], center_sum.inputs[0])
links.new(bounds.outputs["Max"], center_sum.inputs[1])
center = nodes.new("ShaderNodeVectorMath")
center.operation = "SCALE"
center.inputs[3].default_value = 0.5
links.new(center_sum.outputs["Vector"], center.inputs[0])
place_grid = nodes.new("GeometryNodeTransform")
links.new(rotate_grid.outputs["Geometry"], place_grid.inputs["Geometry"])
links.new(center.outputs["Vector"], place_grid.inputs["Translation"])
cell_sizes = []
for index, axis in enumerate(("X", "Z")):
segments = nodes.new("ShaderNodeMath")
segments.operation = "SUBTRACT"
segments.inputs[1].default_value = 1.0
links.new(vertex_counts[index], segments.inputs[0])
cell_size = nodes.new("ShaderNodeMath")
cell_size.operation = "DIVIDE"
links.new(separate_dimensions.outputs[axis], cell_size.inputs[0])
links.new(segments.outputs[0], cell_size.inputs[1])
cell_sizes.append(cell_size.outputs[0])
cell_area = nodes.new("ShaderNodeMath")
cell_area.operation = "MULTIPLY"
links.new(cell_sizes[0], cell_area.inputs[0])
links.new(cell_sizes[1], cell_area.inputs[1])
target_step = nodes.new("ShaderNodeMath")
target_step.operation = "SQRT"
links.new(cell_area.outputs[0], target_step.inputs[0])
depth_ratio = nodes.new("ShaderNodeMath")
depth_ratio.operation = "DIVIDE"
links.new(thickness_input.outputs["Thickness"], depth_ratio.inputs[0])
links.new(target_step.outputs[0], depth_ratio.inputs[1])
rounded_depth = nodes.new("ShaderNodeMath")
rounded_depth.operation = "ROUND"
links.new(depth_ratio.outputs[0], rounded_depth.inputs[0])
depth_segments = nodes.new("ShaderNodeMath")
depth_segments.operation = "MAXIMUM"
depth_segments.inputs[1].default_value = 3.0
links.new(rounded_depth.outputs[0], depth_segments.inputs[0])
depth_vertices = nodes.new("ShaderNodeMath")
depth_vertices.operation = "ADD"
depth_vertices.inputs[1].default_value = 1.0
links.new(depth_segments.outputs[0], depth_vertices.inputs[0])
cube_size = nodes.new("ShaderNodeCombineXYZ")
links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"])
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Y"])
links.new(separate_dimensions.outputs["Z"], cube_size.inputs["Z"])
cube = nodes.new("GeometryNodeMeshCube")
links.new(cube_size.outputs["Vector"], cube.inputs["Size"])
links.new(vertex_counts[0], cube.inputs["Vertices X"])
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Y"])
links.new(vertex_counts[1], cube.inputs["Vertices Z"])
store_cube_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_cube_uv.data_type = "FLOAT2"
store_cube_uv.domain = "CORNER"
store_cube_uv.inputs["Name"].default_value = "UVMap"
links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"])
links.new(cube_uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
place_cube = nodes.new("GeometryNodeTransform")
links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"])
links.new(center.outputs["Vector"], place_cube.inputs["Translation"])
has_thickness = nodes.new("FunctionNodeCompare")
has_thickness.data_type = "FLOAT"
has_thickness.operation = "GREATER_THAN"
links.new(thickness_input.outputs["Thickness"], has_thickness.inputs[0])
geometry_switch = nodes.new("GeometryNodeSwitch")
geometry_switch.input_type = "GEOMETRY"
links.new(has_thickness.outputs["Result"], geometry_switch.inputs["Switch"])
links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"])
links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"])
geometry = inherit_material_slots(group, geometry_switch.outputs["Output"], geometry_input.outputs["Geometry"])
links.new(geometry, output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = (
"Build an aspect-adaptive image plane and add optional centered thickness."
)
group.use_fake_user = True
build_dimension_fields(group)
prepare_node_group(group)
return group

View File

@@ -0,0 +1,234 @@
"""Build the Relief Plane Geometry Nodes group."""
import bpy
from anyimage.operators.cutout_tool.shape import NORMAL_REDUCTION_ATTRIBUTE_NAME
from ..common.nodes import group_input as _group_input, interface_socket as _interface_socket
from ..common.nodes import store_float_attribute
from ..common.nodes import compare_node, prepare_node_group, vector_input
from ..common.reference_plane import reference_plane_depth
from ..common.boundary_smoothing import boundary_influence
RELIEF_PLANE_NODE_GROUP_NAME = "O Image Relief Plane"
def _math(nodes, operation):
node = nodes.new("ShaderNodeMath")
node.operation = operation
return node
def build_image_relief_plane_group(image_plane_group):
group = bpy.data.node_groups.get(RELIEF_PLANE_NODE_GROUP_NAME)
if group is not None:
return group
group = bpy.data.node_groups.new(
RELIEF_PLANE_NODE_GROUP_NAME,
"GeometryNodeTree",
)
group.is_modifier = True
geometry = _interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
subdivide = _interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.description = "Increase mesh density to represent finer shapes. Higher levels create more vertices and require more processing time."
subdivide.default_value = 6
subdivide.min_value = 0
subdivide.max_value = 10
thickness = _interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
thickness.description = "Set the thickness of the flat base beneath the relief."
vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION").description = "Set the direction used to orient the reference depth plane."
depth_offset = _interface_socket(group, "Depth Offset", "INPUT", "NodeSocketFloat")
depth_offset.default_value = 0.0
depth_offset.min_value = -10.0
depth_offset.max_value = 10.0
depth_offset.subtype = "DISTANCE"
depth_offset.description = "Correct the reference depth before direction and scale are applied."
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
depth_scale.default_value = 0.5
depth_scale.min_value = 0.0
depth_scale.max_value = 2.0
valid_only = _interface_socket(group, "Depth Mask", "INPUT", "NodeSocketBool")
valid_only.description = "Treat depth-mask-invalid areas as zero depth instead of deleting them."
valid_only.default_value = True
options = group.interface.new_panel(name="Options", default_closed=True)
validity_threshold = _interface_socket(group, "Mask Threshold", "INPUT", "NodeSocketFloat", options)
validity_threshold.description = "Set the minimum depth mask value treated as valid."
validity_threshold.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 0.99
validity_threshold.subtype = "FACTOR"
base_plane_depth = _interface_socket(
group,
"Reference Depth",
"INPUT",
"NodeSocketFloat",
parent=options,
)
base_plane_depth.default_value = 1.0
base_plane_depth.min_value = 0.0
base_plane_depth.subtype = "DISTANCE"
data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _interface_socket(
group,
"Uniform Scale",
"INPUT",
"NodeSocketFloat",
parent=data,
)
uniform_scale.default_value = 1.0
uniform_scale.min_value = 0.0
uniform_scale.hide_in_modifier = True
depth_image = _interface_socket(
group,
"Depth Image",
"INPUT",
"NodeSocketImage",
parent=data,
)
depth_image.hide_in_modifier = True
_interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = _group_input(
nodes,
{"Geometry", "Subdivide"},
)
shape_input = _group_input(
nodes,
{"Thickness", "Depth Direction", "Depth Offset", "Depth Scale"},
)
image_input = _group_input(
nodes,
{"Depth Image", "Uniform Scale", "Depth Mask", "Mask Threshold"},
)
group_output = nodes.new("NodeGroupOutput")
image_plane = nodes.new("GeometryNodeGroup")
image_plane.node_tree = image_plane_group
links.new(geometry_input.outputs["Geometry"], image_plane.inputs["Geometry"])
links.new(geometry_input.outputs["Subdivide"], image_plane.inputs["Subdivide"])
links.new(shape_input.outputs["Thickness"], image_plane.inputs["Thickness"])
positive_half = _math(nodes, "MULTIPLY")
positive_half.inputs[1].default_value = 0.5
links.new(shape_input.outputs["Thickness"], positive_half.inputs[0])
translation = nodes.new("ShaderNodeCombineXYZ")
links.new(positive_half.outputs[0], translation.inputs["Y"])
place_block = nodes.new("GeometryNodeTransform")
links.new(image_plane.outputs["Geometry"], place_block.inputs["Geometry"])
links.new(translation.outputs["Vector"], place_block.inputs["Translation"])
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
texture = nodes.new("GeometryNodeImageTexture")
texture.interpolation = "Linear"
texture.extension = "EXTEND"
links.new(image_input.outputs["Depth Image"], texture.inputs["Image"])
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
depth_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(texture.outputs["Color"], depth_components.inputs["Vector"])
invalid = nodes.new("FunctionNodeCompare")
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
links.new(texture.outputs["Alpha"], invalid.inputs["A"])
links.new(image_input.outputs["Mask Threshold"], invalid.inputs["B"])
mask_enabled = image_input.outputs["Depth Mask"]
valid_mask = nodes.new("GeometryNodeSwitch")
valid_mask.input_type = "FLOAT"
valid_mask.inputs["False"].default_value = 1.0
links.new(invalid.outputs["Result"], valid_mask.inputs["Switch"])
mask_switch = nodes.new("GeometryNodeSwitch")
mask_switch.input_type = "FLOAT"
mask_switch.inputs["False"].default_value = 1.0
links.new(mask_enabled, mask_switch.inputs["Switch"])
links.new(valid_mask.outputs[0], mask_switch.inputs["True"])
metric_depth = _math(nodes, "MULTIPLY")
links.new(depth_components.outputs["Z"], metric_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1])
relative_depth = _math(nodes, "SUBTRACT")
reference_input = _group_input(nodes, {"Reference Depth"})
corrected_reference = _math(nodes, "ADD")
links.new(reference_input.outputs["Reference Depth"], corrected_reference.inputs[0])
links.new(shape_input.outputs["Depth Offset"], corrected_reference.inputs[1])
plane_depth = reference_plane_depth(
group, shape_input.outputs["Depth Direction"], corrected_reference.outputs[0],
)
links.new(metric_depth.outputs[0], relative_depth.inputs[0])
links.new(plane_depth, relative_depth.inputs[1])
full_offset = _math(nodes, "MULTIPLY")
links.new(relative_depth.outputs[0], full_offset.inputs[0])
links.new(shape_input.outputs["Depth Scale"], full_offset.inputs[1])
masked_full_offset = _math(nodes, "MULTIPLY")
links.new(full_offset.outputs[0], masked_full_offset.inputs[0])
links.new(mask_switch.outputs[0], masked_full_offset.inputs[1])
has_thickness = compare_node(group, "GREATER_THAN", shape_input.outputs["Thickness"], 1e-6)
safe_offset = _math(nodes, "MINIMUM")
safe_offset.inputs[1].default_value = 0.0
links.new(masked_full_offset.outputs[0], safe_offset.inputs[0])
edge_influence = boundary_influence(group, protected=False)
edge_clamped = nodes.new("ShaderNodeClamp")
edge_clamped.inputs["Min"].default_value = 0.0
edge_clamped.inputs["Max"].default_value = 1.0
links.new(edge_influence, edge_clamped.inputs["Value"])
geometry_weight = _math(nodes, "SUBTRACT")
geometry_weight.inputs[0].default_value = 1.0
links.new(edge_clamped.outputs[0], geometry_weight.inputs[1])
smooth_safe_offset = _math(nodes, "MULTIPLY")
links.new(safe_offset.outputs[0], smooth_safe_offset.inputs[0])
links.new(geometry_weight.outputs[0], smooth_safe_offset.inputs[1])
position = nodes.new("GeometryNodeInputPosition")
position_components = nodes.new("ShaderNodeSeparateXYZ")
links.new(position.outputs["Position"], position_components.inputs["Vector"])
layer_ratio = _math(nodes, "DIVIDE")
links.new(position_components.outputs["Y"], layer_ratio.inputs[0])
links.new(shape_input.outputs["Thickness"], layer_ratio.inputs[1])
nonnegative_ratio = _math(nodes, "MAXIMUM")
nonnegative_ratio.inputs[1].default_value = 0.0
links.new(layer_ratio.outputs[0], nonnegative_ratio.inputs[0])
clamped_ratio = _math(nodes, "MINIMUM")
clamped_ratio.inputs[1].default_value = 1.0
links.new(nonnegative_ratio.outputs[0], clamped_ratio.inputs[0])
one_minus_ratio = _math(nodes, "SUBTRACT")
one_minus_ratio.inputs[0].default_value = 1.0
links.new(clamped_ratio.outputs[0], one_minus_ratio.inputs[1])
layer_weight = nodes.new("GeometryNodeSwitch")
layer_weight.input_type = "FLOAT"
layer_weight.inputs["False"].default_value = 1.0
links.new(has_thickness, layer_weight.inputs["Switch"])
links.new(one_minus_ratio.outputs[0], layer_weight.inputs["True"])
weighted_offset = _math(nodes, "MULTIPLY")
links.new(smooth_safe_offset.outputs[0], weighted_offset.inputs[0])
links.new(layer_weight.outputs["Output"], weighted_offset.inputs[1])
displacement = nodes.new("ShaderNodeCombineXYZ")
links.new(weighted_offset.outputs[0], displacement.inputs["Y"])
set_position = nodes.new("GeometryNodeSetPosition")
links.new(place_block.outputs["Geometry"], set_position.inputs["Geometry"])
links.new(displacement.outputs["Vector"], set_position.inputs["Offset"])
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
shade_smooth.domain = "FACE"
shade_smooth.inputs["Shade Smooth"].default_value = True
links.new(set_position.outputs["Geometry"], shade_smooth.inputs["Geometry"])
normal_reduction = _math(nodes, "SUBTRACT")
normal_reduction.inputs[0].default_value = 1.0
scaled_depth = _math(nodes, "MULTIPLY")
links.new(shape_input.outputs["Depth Scale"], scaled_depth.inputs[0])
links.new(geometry_weight.outputs[0], scaled_depth.inputs[1])
links.new(scaled_depth.outputs[0], normal_reduction.inputs[1])
output_geometry = store_float_attribute(
group, shade_smooth.outputs["Geometry"], NORMAL_REDUCTION_ATTRIBUTE_NAME,
normal_reduction.outputs[0],
)
links.new(output_geometry, group_output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = (
"Build a fixed-base image relief."
)
group.use_fake_user = True
prepare_node_group(group)
return group

79
nodes/groups/shadeless.py Normal file
View File

@@ -0,0 +1,79 @@
"""Build the Shadeless shader node group."""
import bpy
SHADELESS_GROUP_NAME = "O Shadeless"
def build_shadeless_group():
group = bpy.data.node_groups.new(SHADELESS_GROUP_NAME, "ShaderNodeTree")
group.interface.new_socket(
name="Shader", in_out="OUTPUT", socket_type="NodeSocketShader"
)
group.interface.new_socket(
name="Color", in_out="INPUT", socket_type="NodeSocketColor"
)
alpha = group.interface.new_socket(name="Alpha", in_out="INPUT", socket_type="NodeSocketFloat")
alpha.default_value, alpha.min_value, alpha.max_value = 1.0, 0.0, 1.0
alpha.subtype = "FACTOR"
strength = group.interface.new_socket(
name="Strength", in_out="INPUT", socket_type="NodeSocketFloat"
)
strength.description = "Set the emission strength for camera rays. Zero renders the surface black."
strength.default_value, strength.min_value = 1.0, 0.0
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_output = nodes.new("NodeGroupOutput")
diffuse = nodes.new("ShaderNodeBsdfDiffuse")
emission = nodes.new("ShaderNodeEmission")
links.new(group_input.outputs["Color"], diffuse.inputs["Color"])
links.new(group_input.outputs["Color"], emission.inputs["Color"])
links.new(group_input.outputs["Strength"], emission.inputs["Strength"])
light_path = nodes.new("ShaderNodeLightPath")
bounce_count = nodes.new("ShaderNodeMath")
bounce_count.operation = "SUBTRACT"
links.new(light_path.outputs["Ray Depth"], bounce_count.inputs[0])
links.new(light_path.outputs["Transmission Depth"], bounce_count.inputs[1])
refracted = nodes.new("ShaderNodeMath")
refracted.operation = "SUBTRACT"
refracted.inputs[0].default_value = 1.0
links.new(bounce_count.outputs[0], refracted.inputs[1])
reflection_limit = nodes.new("ShaderNodeMath")
reflection_limit.operation = "SUBTRACT"
reflection_limit.inputs[0].default_value = 2.0
links.new(light_path.outputs["Ray Depth"], reflection_limit.inputs[1])
camera_reflected = nodes.new("ShaderNodeMath")
camera_reflected.operation = "MULTIPLY"
links.new(reflection_limit.outputs[0], camera_reflected.inputs[0])
links.new(light_path.outputs["Is Glossy Ray"], camera_reflected.inputs[1])
shadow_or_reflect = nodes.new("ShaderNodeMath")
shadow_or_reflect.operation = "MAXIMUM"
links.new(camera_reflected.outputs[0], shadow_or_reflect.inputs[0])
links.new(light_path.outputs["Is Shadow Ray"], shadow_or_reflect.inputs[1])
shader_factor = nodes.new("ShaderNodeMath")
shader_factor.operation = "MAXIMUM"
links.new(shadow_or_reflect.outputs[0], shader_factor.inputs[0])
links.new(refracted.outputs[0], shader_factor.inputs[1])
mix = nodes.new("ShaderNodeMixShader")
links.new(shader_factor.outputs[0], mix.inputs["Fac"])
links.new(diffuse.outputs["BSDF"], mix.inputs[1])
links.new(emission.outputs["Emission"], mix.inputs[2])
transparent = nodes.new("ShaderNodeBsdfTransparent")
alpha_mix = nodes.new("ShaderNodeMixShader")
links.new(group_input.outputs["Alpha"], alpha_mix.inputs["Fac"])
links.new(transparent.outputs["BSDF"], alpha_mix.inputs[1])
links.new(mix.outputs["Shader"], alpha_mix.inputs[2])
links.new(alpha_mix.outputs["Shader"], group_output.inputs["Shader"])
group.color_tag = "SHADER"
group.description = (
"Display color without scene lighting while preserving shadows "
"and reflections."
)
group.use_fake_user = True
return group

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-10

View File

@@ -0,0 +1,72 @@
## Context
Remove Background 通过 ImageEditTarget 准备输入并提交结果。普通文件按路径校验,packed 图片导出 PNG;后端 BEN2 接收 Pillow RGB,预测 mask 后转换成 8-bit RGBA,Job 写入 PNG。源像素、结果载入和图片替换都涉及 HDR 精度边界。
BEN2 网络输出是 alpha,识别本身可复用既有模型。HDR 支持的关键是分离识别预览与原始 RGB,并让浮点信息贯穿主线程合成、EXR 编码和结果替换。
## Goals / Non-Goals
本次交付为 HDR 静态图去背景:保留源图的场景线性色彩、有效动态范围和尺寸,以模型预测更新透明度。支持 Image Empty 和材质 Image Texture 两种已有入口,以及 Blender 可读取的外部、packed、generated 浮点静态图片。普通媒体沿用既有处理方式;HDR 动画在提交前明确提示首版支持范围。Upscale 的 HDR 适配作为独立能力评估。
## Decisions
### 1. 在 Blender 侧捕获源图和准备预览
以 `Image.is_float` 及真实图片来源判断 HDR 路径,扩展名仅用于来源识别与提示。所有可读浮点静态图片走该路径,包括数值暂时处于 0–1 内的图片。准备时读取当前像素,包含尚未保存的编辑;为本次任务保存独立的 RGBA 数值快照、尺寸、色彩空间和 alpha 解释信息。
Blender 数据仅在主线程准备与提交阶段访问,Job 参数只包含普通字典中的路径、字符串和数值。后端始终读取 PNG 识别预览,因此公共媒体格式校验仍校验实际提交文件。源图与临时文件的所有权由准备阶段和 Job cleanup 明确交接。
### 2. 用固定映射生成可识别的普通 RGB
识别预览从场景线性、非预乘 RGB 生成。Blender 的浮点缓冲已经采用场景线性颜色与预乘 alpha;预览先解关联,再采用按亮度的 Reinhard 映射,以非负亮度 Y 计算 `RGB / (1 + Y)`,执行线性 Rec.709 到 sRGB 编码并限制到可编码范围。负值只在预览中处理;源浮点数值保持原样。全黑图保持有限值。预览按源 alpha 合成到黑底,符合现有 BEN2 输入语义。
映射固定且不读取用户当前 View Transform、Look、Exposure,避免切换显示设置导致 mask 变化。源颜色解码由 Blender 完成。数据图片与 CHANNEL_PACKED 输入在准备时给出明确错误。
该映射是首版确定性基线。用实际高动态范围图片验证高光边缘、暗部与半透明物体;若识别不足,在本变更内调整并固定预览算法和对应行为测试。
### 3. 让 BEN2 推理核心返回数值 alpha
```mermaid
flowchart TD
A[主线程捕获 HDR RGBA 快照] --> B[固定色调映射生成 PNG 预览]
B --> C[BEN2 推理]
C --> D[浮点 alpha 归一化并还原源尺寸]
D --> E[Job 写入 alpha.npy]
A --> F[主线程合成原始 RGB 与新 alpha]
E --> F
F --> G[写入 32-bit float RGBA EXR]
G --> H[验证并提交到原图片目标]
```
将现有推理核心重构为返回原尺寸 float32 alpha 数组。保留既有 mask 归一化语义,缩放过程使用浮点数据并在最后限制到 0–1,检查尺寸与有限值。普通 RGBA 消费者在自身产物边界完成量化与合成,清理旧核心入口并更新真实调用者。
Remove Background Job 增加明确的产物模式,例如 `output_kind: "alpha"`,HDR 静态图产出 `alpha.npy` 并通过结果字段声明。读取使用 `allow_pickle=False`,验证形状、范围和有限值。现有普通图片与动画消费者继续取得对应的 RGBA 结果。
### 4. 主线程合成和 EXR 持久化
在非预乘语义下计算 `A_out = A_source × A_predicted`,保留原始 RGB。Blender 原生浮点缓冲使用预乘 RGB,因此实现直接将 RGBA 乘以预测 alpha,保持解关联后的颜色;解码 STRAIGHT EXR 后同样采用该缓冲语义。零 alpha 像素的不可恢复颜色置零,NONE 模式按不透明源处理。测试分别比较 straight RGB 或重新合成后的可见颜色。
创建 `float_buffer=True` 的结果图片,使用独立临时场景指定 32-bit float RGBA EXR、ZIP 压缩,通过 `save_render` 写出场景线性值。结果采用 Linear Rec.709 与 PREMUL,保存后载入并核对数值,再打包和提交;临时场景及文件在对应阶段结束后释放。
复用 ImageEditTarget 和 common 内的提交、共享引用隔离、图片内容备份与恢复。HDR 的 Empty 提交同时保护材质节点等其他实际使用者。独占源图包含未保存像素或为生成图时,将其当前浮点内容转为内嵌 EXR 并建立撤销基线,保留 Image 身份;一次撤销恢复原始内容,重做恢复处理结果。提交失败时恢复操作前的源状态。先验证候选结果再提交,其他共享使用者保持原状。
### 5. 保持异步编辑事务完整
使用提交时的源快照进行合成,完成前复核原目标及图片绑定。纹理节点保存独立的 `anyimage_identity`,提交时与捕获值核对,避免 Blender 删除重建节点后复用 RNA 地址造成误认。增加对捕获后源像素或色彩解释被修改的冲突检查,发现冲突则提示重新执行并保留当前编辑。
临时预览由输入 cleanup 释放,alpha 由 Job 目录管理,候选 EXR 完成载入和打包后释放临时副本。取消、推理失败、合成失败、提交失败均释放快照与本次临时数据。成功结果支持一次撤销、重做以及保存重开。
## Risks / Trade-offs
- 固定预览映射可能压低极暗部的可辨识度:用代表性 HDR 图片进行真实 BEN2 验收。
- Blender 像素、alpha 模式与 EXR 编码之间存在转换:以已知数值的真实 Blender 保存重开测试约束实现。
- 源快照增加内存占用:仅保留合成和冲突检测所需的数据,所有终止路径释放;大型输入准备失败时提供可读错误。
- 工作区相关公共入口正在修改:实施前核对现有变更,复用最新公共校验和事务接口。
## Migration Plan
先完成数值 alpha 核心与现有消费者回归,再实现 HDR 准备、合成和事务提交。验证普通媒体行为及 HDR round-trip 后完成能力交付。Job 协议由同版本插件与后端一起更新。
## Open Questions
无需要用户决策的前置问题。Blender 像素空间、PREMUL round-trip 与固定预览的识别质量已通过数值测试及实际 BEN2 推理确认。

View File

@@ -0,0 +1,27 @@
## Why
BEN2 通过普通 RGB 图像预测前景透明度。当前去背景流程用 Pillow 合成并保存 PNG,无法保留 HDR 图像的浮点颜色。将识别预览与原始颜色分离,可以让用户对 HDR 图片去背景,同时保留用于渲染的亮度和色彩。
## What Changes
- 为 Remove Background 增加 HDR 静态图处理,覆盖 Blender 已加载的 EXR、Radiance HDR、已打包及生成的浮点图片。
- 从提交时的浮点像素生成经过固定色调映射的 RGB 识别预览,原始颜色留在 Blender 侧。
- BEN2 提供浮点 alpha 结果;主线程将其与源 alpha 相乘,并与原始颜色合成。
- 结果使用 32-bit float RGBA EXR 持久化,支持保存重开、共享图片隔离、撤销和失败恢复。
- 对 HDR 动画输入在准备阶段给出明确提示;普通图片与现有视频、序列沿用当前行为。
## Capabilities
### New Capabilities
- `hdr-background-removal`: HDR 静态图片的识别预览、alpha 预测、浮点结果及生命周期。
### Modified Capabilities
无已发布能力规格需要修改;与现有 ImageEditTarget 的目标提交语义衔接。
## Impact
- Blender:`common/image.py`、`common/image_target.py`、`operators/remove_background.py`;复杂的 HDR 图像处理可独立放入 `common/hdr_image.py`。
- 后端:`server/models/onnx_ben2.py`、`server/models/ben2.py`、`server/jobs/remove_background.py`,以及对应测试。
- 输入仍为普通预览 PNG,新增 Job 的 alpha 产物模式,复用 NumPy 保存数值结果。BEN2 权重与运行依赖保持现有配置。
- 与进行中的 `unify-ai-input-validation` 及图片目标编辑变更共同使用实施时的真实调用链。

View File

@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: HDR still image preparation
系统 SHALL 为可读的浮点静态图片提供 Remove Background,覆盖 Image Empty 与材质 Image Texture 的外部、packed 和 generated 图片。输入 SHALL 采用任务开始时的当前像素快照。
#### Scenario: An HDR still is submitted
- **WHEN** 用户对可读的 EXR、HDR 或其他浮点静态图片执行 Remove Background
- **THEN** 系统捕获当前 RGBA、尺寸与颜色解释,并提交普通 RGB 识别预览
#### Scenario: Unsaved pixels are present
- **WHEN** 源浮点图包含未保存的像素编辑
- **THEN** 识别预览与最终 RGB 均使用当前编辑后的快照
#### Scenario: An HDR animation is submitted
- **WHEN** 用户对 HDR 序列或浮点动画执行 Remove Background
- **THEN** 系统在创建推理任务前提示本次 HDR 功能支持静态图,并保持源图状态
### Requirement: Deterministic recognition preview
系统 SHALL 通过确定性的曝光范围压缩及色彩编码生成 BEN2 的普通 RGB 预览,原始 RGB SHALL 独立保留。预览 SHALL 正确解释源 alpha,且不受用户显示变换设置影响。
#### Scenario: Highlights exceed one
- **WHEN** 源图包含多个大于 1 的不同亮度值
- **THEN** 预览先进行色调映射再编码,原始快照保留这些数值
#### Scenario: Display settings change
- **WHEN** 相同源像素分别在不同 View Transform、Look 或 Exposure 下提交
- **THEN** 识别预览一致
#### Scenario: The image is black or contains negative values
- **WHEN** 可读源像素为全黑或包含有限负颜色值
- **THEN** 预览数值有限,源 RGB 不因预览映射改变
### Requirement: Float alpha result
HDR 去背景 Job SHALL 返回原始尺寸的 float32 alpha 数组,数值有限且位于 0–1。系统 SHALL 在使用前验证产物。
#### Scenario: BEN2 predicts a soft edge
- **WHEN** 模型预测连续透明度边缘
- **THEN** alpha 经过浮点缩放返回,合成前不量化为 8-bit
#### Scenario: Alpha is invalid
- **WHEN** alpha 文件损坏、尺寸不符或包含非有限及越界数值
- **THEN** 系统报告结果错误,保持源图片与目标绑定
### Requirement: HDR color and transparency persistence
系统 SHALL 保留源 RGB 的场景线性色彩及动态范围,并以源 alpha 与预测 alpha 的乘积作为结果透明度。结果 SHALL 为同尺寸浮点图像并采用 32-bit float RGBA EXR 持久化,颜色误差 SHALL 限于浮点运算与必要颜色转换的容差。
#### Scenario: Straight alpha is present
- **WHEN** 源图为 straight alpha 且存在大于 1 的 RGB 和部分透明像素
- **THEN** 结果 RGB 保留,alpha 等于源 alpha 乘预测 alpha
#### Scenario: Premultiplied alpha is present
- **WHEN** 源图采用 PREMUL 且包含部分透明及完全透明像素
- **THEN** 系统按 alpha 关联语义合成,边缘颜色正确且结果没有 NaN 或 Inf
#### Scenario: The project is saved and reopened
- **WHEN** 用户保存含处理结果的 blend 文件后重新打开
- **THEN** 结果尺寸、HDR 数值、颜色解释和透明度保持一致
### Requirement: Transactional target update
系统 SHALL 复用既有图片目标提交规则,保护共享使用者并支持撤销、重做与错误恢复。Blender 数据访问 SHALL 发生于主线程,任务通信 SHALL 使用可序列化普通参数。
#### Scenario: Another owner shares the image
- **WHEN** 源图片存在其他实际使用者
- **THEN** 仅发起操作的目标取得独立 HDR 结果,其他使用者仍引用原图
#### Scenario: The source changes during inference
- **WHEN** 原目标失效、改绑图片或源像素与颜色解释发生修改
- **THEN** 系统拒绝覆盖当前状态,提示重新执行并清理本次临时资源
#### Scenario: The operation is cancelled or fails
- **WHEN** 准备、推理、合成或提交取消或失败
- **THEN** 系统保持或恢复源图及目标绑定,释放本次快照和临时数据
#### Scenario: The user undoes and redoes
- **WHEN** 用户撤销并重做一次成功的 HDR 去背景
- **THEN** 原始和处理后的浮点内容及绑定正确恢复,重做无需重新推理
### Requirement: Existing media behavior
系统 SHALL 保留普通图片、视频和普通序列的现有去背景行为,所有 BEN2 消费者 SHALL 适配统一的数值 alpha 核心。
#### Scenario: An existing regular image or animation is processed
- **WHEN** 用户提交现有支持的普通图片或动画
- **THEN** 系统按既有帧选择、透明度合成和输出规则完成操作

View File

@@ -0,0 +1,31 @@
## 1. 验证浮点图片基础行为
- [x] 1.1 核对最新输入校验、BEN2 消费者与图片提交调用链,保留工作区已有修改。
- [x] 1.2 在真实 Blender 中验证已知 HDR 数值、straight/PREMUL alpha、色彩空间及 32-bit EXR 保存载入行为,确定可用颜色转换与数值容差。
- [x] 1.3 检查图片内容备份、原位替换、打包及撤销对 float buffer 的支持,定位需要修改的公共边界。
## 2. 分离 BEN2 alpha 核心
- [x] 2.1 将推理核心改为返回原尺寸 float32 alpha,保留归一化行为,使用浮点缩放并校验有限值。
- [x] 2.2 更新所有真实 BEN2 消费者及相关测试,在各自输出边界合成 RGBA,移除旧核心入口。
- [x] 2.3 为 Remove Background Job 增加静态 alpha 产物模式、结果字段及安全的 NumPy 读写校验,覆盖错误与取消路径。
## 3. 准备 HDR 识别输入
- [x] 3.1 在主线程识别浮点静态图并捕获当前 RGBA 快照,覆盖外部、packed、generated 和未保存像素。
- [x] 3.2 实现固定色调映射及 sRGB 预览编码,覆盖 alpha、负值、全黑及显示设置独立性。
- [x] 3.3 接入 Remove Background 的准备与普通参数提交,明确拒绝 HDR 动画并验证临时输入清理。
## 4. 合成与提交 HDR 结果
- [x] 4.1 校验 float alpha 并按 straight/PREMUL 语义合成,保留源 HDR RGB 和尺寸。
- [x] 4.2 创建浮点结果并写入无损 32-bit RGBA EXR,完成载入、打包及临时文件释放。
- [x] 4.3 复用并补全 ImageEditTarget 的浮点提交与恢复行为,覆盖共享隔离、独占 Image 身份、源修改冲突、撤销和重做。
- [x] 4.4 验证取消、失败、目标失效各路径的资源释放和源状态恢复。
## 5. 验收
- [x] 5.1 更新并运行相关后端、common 和 Operator 测试,覆盖浮点 alpha 精度与现有普通媒体行为。
- [x] 5.2 用已知 RGB 大于 1、有限负值、软 alpha 的图片进行真实 Blender 两种入口及保存重开验证;记录数值误差。
- [x] 5.3 用代表性 HDR 实图运行 BEN2,检查高光、暗部、半透明边缘与黑底预览效果,必要时调整固定映射并补齐验证。
- [x] 5.4 运行全量 `uv run pytest` 并确认正常退出,检查最终差异及旧入口引用,记录验证结果。

View File

@@ -0,0 +1,32 @@
## 验证环境
2026-09-10,Windows,项目 uv 环境,Blender Python 4.5.3 LTS,BEN2 本地 `models/BEN2-ONNX/onnx/model_fp16.onnx`,ONNX Runtime CPU。
## 浮点与编辑事务
- 真实 Blender 验证外部 EXR、packed、generated、未保存像素,覆盖 Image Empty 与材质 Image Texture,以及共享和独占图片。
- RGBA 样本包含大于 1 的颜色、有限负值、零 alpha 与部分透明像素;32-bit ZIP EXR 保存、打包、载入和 blend 保存重开均通过数值检查。
- 单独验证 STRAIGHT EXR 解码后的预乘缓冲语义,处理后解关联颜色在 `rtol=1e-6` 内保持一致。
- 已验证 Radiance `.hdr` 实际文件读取与识别预览生成,源浮点像素保持不变。
- 原生 Operator 的撤销、重做、取消、提交异常和资源释放通过测试;未保存的独占浮点源图采用内嵌 EXR 撤销基线。
- 纹理节点删除重建测试覆盖独立身份字段,防止 RNA 地址复用造成异步目标误认。
## 实际 BEN2 推理
Cycles 渲染 320×320 金属 Suzanne,16 samples,两盏面光源,原始 RGB 峰值约 112.432。用渲染 alpha 作为轮廓参考,构造三档曝光的灰底 HDR 图,以及保留原始软 alpha 的透明图。四组均执行真实 BEN2 Job、浮点 alpha 读回、Blender 合成及 EXR 持久化。
| 输入 | 原始 RGB 峰值 | 输出预乘 RGB 峰值 | 轮廓 IoU |
| --- | ---: | ---: | ---: |
| 正常亮度 | 112.4321 | 112.4235 | 0.9822 |
| 16 倍亮度 | 1798.9137 | 1796.3881 | 0.9874 |
| 0.05 倍亮度 | 5.6216 | 5.6176 | 0.9757 |
| 原始透明度 | 112.4321 | 112.4292 | 0.9656 |
IoU 使用预测 alpha 与渲染 alpha 的 0.5 阈值轮廓计算。四组 EXR 读回值相对于 `source_rgba × predicted_alpha` 的最大绝对误差均为 0。已目视检查高亮预览、低亮度预览与预测轮廓。该结果验证当前渲染样例的识别效果;不同实图的抠图质量取决于模型预测。
## 自动检查
- OpenSpec 严格校验通过。
- BEN2 的旧 `infer` 入口及旧节点使用者检查名称已清理,相关调用者和测试已更新。
- 最终 `uv run pytest -q`:1074 passed、2 xfailed、52 subtests passed,175.71 秒,退出码 0。两项 xfail 为已有的浮点图片显示色彩空间适配测试。
- `git diff --check` 通过。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-12

View File

@@ -0,0 +1,49 @@
## Context
Cutout 的 `SelectCutoutSelection` 已继承 `common.viewport.ImageGesture`,目前固定为 Lasso 并通过拖动键位启动。Mask 的 Polyline 已由该共用类实现。Cutout 完成选区后打开 Shape 饼菜单,Depth Solid 经异步生成后使用 `O Image Depth Cutout` 创建对象。
节点构建内部的 Uniform Thickness 输入在保存前显示名改为 Thickness;节点组同时存在 Balloon 使用的 Thickness 输入,初始化必须区分 socket 标识。
## Goals
支持 Cutout 逐点剪出及 Uniform 初始化;统一 Mask 的 Extend 命名和 Set 默认值。
## Decisions
### 共用手势与独立设置
在 `properties.py` 定义 Cutout 的 Lasso / Polyline 属性,由 Scene 保存,默认 Lasso。Cutout 在启动时读取手势,沿用 `ImageGesture` 的逐点输入、实时预览、闭合提示和取消清理。使用共用按下启动键位支持首点输入,并验证 Lasso 拖动完成以及空白处源对象保持行为。共用逻辑留在 `common`,Cutout 负责自己的状态文本和选区提交。
### 创建时应用 Uniform
```mermaid
flowchart TD
A[读取 Cutout Gesture] --> B[ImageGesture 收集并确认选区]
B --> C[Shape 饼菜单携带本次 Gesture 快照]
C --> D[CutoutSelectionToShape 保存 Gesture]
D --> E[生成所需图像并在主线程创建对象]
E --> F{Polyline 且 Depth Solid}
F -->|是| G[设置新修改器 Mode 为 Uniform]
F -->|否| H[按 Shape 初始化修改器]
```
手势作为普通字符串从选择 Operator 显式传到饼菜单、转换 Operator 和对象创建链路,异步响应使用该快照,避免用户中途切换 Scene 设置影响结果。服务器生成协议继续处理深度和法线产物;修改器初始化由主线程完成。Uniform 自动设置针对本次新建的 Depth Solid 对象,之后用户可手动修改 Mode。
### 节点厚度默认值
将构建内部 Uniform Thickness 默认值设为 0.5,并验证最终资产对应 socket 的默认值和新修改器实际值。检查现有通用 Thickness 赋值只初始化其目标输入,避免同名 socket 覆盖 Uniform 默认值。按现有资产修订机制更新 Depth Solid 版本并运行 `uv run node-group build`。
### Mask 模式
统一为 `SET` / Set、`EXTEND` / Extend、`SUBTRACT` / Subtract,保留枚举数值 0、1、2。Scene 和 Operator 均通过共用属性默认 Set。Extend 保持 `max(alpha, mask)`,Set 保持 `alpha * mask`,Subtract 保持 `alpha * (1-mask)`;状态文本和测试同步命名。
## Risks / Trade-offs
- 按下启动可能改变 Cutout 的源对象选择时机 → 覆盖 Lasso、Polyline、空白点击和取消行为。
- 异步生成期间手势被切换 → 在提交时保存本次手势,并覆盖设置改变后的响应。
- 同名 Thickness socket 容易导致写入错误 → 验证具体 socket identifier、资产默认值及实际修改器值。
- 旧脚本传入 `ADD` 将失效 → 调用方统一使用 `EXTEND`,保持原枚举数值且不增加别名。
## Migration Plan
实现代码与测试后重建节点资产,执行相关测试及全量测试。回退时将本次代码、测试和资产作为同一变更回退。

View File

@@ -0,0 +1,27 @@
## Why
Cutout 目前只能自由绘制 Lasso,沿直线轮廓剪出时需要逐点定义边界。复用 Mask 的 Polyline 交互,并为这种剪出方式设置 Uniform 厚度,可减少手动调整;Mask 使用 Extend 和默认 Set,使名称与常用操作一致。
## What Changes
- Cutout 增加 Gesture 选项 Lasso / Polyline,默认 Lasso,复用共用逐点交互和选区提交链路。
- Polyline 剪出创建使用 `O Image Depth Cutout` 的 Depth Solid 对象时,自动设置修改器 Mode 为 Uniform。
- `O Image Depth Cutout` 的 Uniform Thickness 接口默认值改为 0.5(资产中显示为 Thickness)。
- **BREAKING** Mask 的 `ADD` / Add 统一重命名为 `EXTEND` / Extend,保留扩展 Alpha 的运算语义;默认模式从 Subtract 改为 Set。
## Capabilities
### New Capabilities
- `cutout-polyline`: Cutout 手势选择、共用 Polyline 交互,以及创建结果的 Uniform 初始化。
- `mask-mode-options`: Mask 的 Set / Extend / Subtract 名称、运算和默认值。
### Modified Capabilities
无。当前 `openspec/specs` 尚无已归档的对应规范。
## Impact
- `properties.py`、`common/viewport.py`、`operators/cutout_tool`、`operators/mask_tool.py` 和相关注册、交互、对象创建测试。
- `tools/nodes/groups/image_depth_cutout.py`、节点资产 `O_AnyImage.blend` 及节点测试;实现阶段需运行节点构建与验证。
- 无新增依赖。已有 change 中的 Mask、Polyline 和 Cutout 提案作为实现背景,本 change 定义本次行为。

View File

@@ -0,0 +1,57 @@
## ADDED Requirements
### Requirement: Cutout offers persistent gesture selection
Cutout SHALL 提供 Lasso 和 Polyline 手势,默认 Lasso,并保存到 Scene 的独立 Cutout 设置。
#### Scenario: User selects Polyline
- **WHEN** 用户将 Cutout Gesture 切换为 Polyline 并开始剪出
- **THEN** 工具以首次左键按下的位置为起点,随后通过点击添加顶点
#### Scenario: User returns to Cutout
- **WHEN** 用户选择 Polyline 后切换到其他工具再返回
- **THEN** Cutout 继续使用 Polyline,Mask Gesture 保持自己的设置
#### Scenario: Default Lasso interaction
- **WHEN** 新 Scene 中用户拖动 Cutout 并释放左键
- **THEN** 工具以 Lasso 收集轮廓并在有效选区完成后打开 Shape 饼菜单
### Requirement: Cutout reuses shared Polyline interaction
Cutout MUST 复用 Mask 所用的共用 Polyline 手势,提供轮廓预览、起点闭合提示、左键添加顶点、Backspace 删除末点、点击起点或双击或 Enter 完成,以及 RMB、Esc 和切换工具取消。
#### Scenario: User completes a polygon
- **WHEN** 至少三个顶点后,用户点击起点闭合范围内、双击或按 Enter / Numpad Enter
- **THEN** 工具提交已确认的闭合 SelectionPath 并打开 Shape 饼菜单,闭合点击和双击第二击不添加重复顶点
#### Scenario: Too few points
- **WHEN** 少于三个顶点时用户尝试完成
- **THEN** 工具继续收集顶点,不打开 Shape 菜单
#### Scenario: User edits or cancels a polygon
- **WHEN** 用户按 Backspace,或通过 RMB、Esc、切换工具取消
- **THEN** Backspace 删除末点并至少保留起点;取消清理预览且不创建结果对象
#### Scenario: Empty click preserves the source
- **WHEN** 用户在空白处启动 Cutout 手势
- **THEN** 工具保持现有源对象选择规则,不因空白点击清空源对象
### Requirement: Polyline initializes Depth Solid with Uniform mode
通过 Polyline 创建 Depth Solid 时,系统 MUST 将新对象的 `O Image Depth Cutout` 修改器 Mode 初始化为 Uniform。手势 MUST 使用本次提交快照。
#### Scenario: Polyline depth result completes asynchronously
- **WHEN** 用户提交 Polyline 并选择 Depth Solid,在生成结束前将 Cutout Gesture 改为 Lasso
- **THEN** 本次结果的 Mode 仍为 Uniform,用户可在创建后手动调整
#### Scenario: Lasso or another shape is used
- **WHEN** 用户通过 Lasso 创建 Depth Solid,或通过 Polyline 创建 Flat、Solid、Depth Balloon
- **THEN** 对象沿用该 Shape 的现有 Mode 初始化规则
### Requirement: Uniform thickness defaults to half a unit
`O Image Depth Cutout` 的 Uniform Thickness socket MUST 默认 0.5,保存资产中对应显示为 Thickness 的 socket 和新建修改器 MUST 使用该值。
#### Scenario: Node asset is built and loaded
- **WHEN** 完成节点资产构建并从资产加载节点组创建 Polyline Depth Solid
- **THEN** Mode 为 Uniform,Uniform 厚度值为 0.5,通用 Thickness 初始化不会覆盖该值

View File

@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Mask exposes Set Extend and Subtract
Mask MUST 按顺序提供 `SET` / Set、`EXTEND` / Extend、`SUBTRACT` / Subtract,枚举数值分别为 0、1、2;界面和状态文本 MUST 使用 Extend。
#### Scenario: User extends Alpha
- **WHEN** 用户以 Extend 提交值为 mask 的选区到现有 alpha
- **THEN** 结果 Alpha 为 `max(alpha, mask)`,RGB 保持不变
#### Scenario: User uses Set or Subtract
- **WHEN** 用户使用 Set 或 Subtract
- **THEN** 结果 Alpha 分别为 `alpha * mask` 或 `alpha * (1-mask)`,RGB 保持不变
### Requirement: Mask defaults to Set
Scene 的 Mask Mode 和 Mask Operator 的模式属性 MUST 默认 Set,并通过同一属性定义保持一致。
#### Scenario: New scene and operator defaults
- **WHEN** 创建新 Scene 或未指定模式的 Mask Operator
- **THEN** 模式默认值为 `SET`
#### Scenario: User chooses another mode
- **WHEN** 用户主动设置 Extend 或 Subtract 后启动 Mask
- **THEN** 本次操作使用用户保存的模式

View File

@@ -0,0 +1,21 @@
## 1. Cutout 手势
- [x] 1.1 在 properties.py 添加默认 Lasso 的 Cutout Gesture 属性并接入 Scene、Operator 和工具设置,检查注册与逆序注销测试。
- [x] 1.2 将 Cutout 接入共用 Polyline 启动、预览与完成逻辑,更新状态文本;覆盖逐点、闭合、双击、Enter、Backspace、取消、Lasso 回归和空白处源对象保持行为。
## 2. Uniform 初始化与资产
- [x] 2.1 将本次 Gesture 快照从选区提交经 Shape 菜单传入转换 Operator 和对象创建链路;覆盖异步期间切换设置的行为。
- [x] 2.2 为 Polyline 创建的 Depth Solid 设置 Mode 为 Uniform,验证 Lasso 与其他 Shape 的初始化行为。
- [x] 2.3 将节点内部 Uniform Thickness 默认值改为 0.5,检查同名 Thickness socket 的赋值目标并更新资产修订号;补充资产默认值与实际修改器值测试。
- [x] 2.4 运行 `uv run node-group build`,更新并验证 O_AnyImage.blend,确认源码与保存资产的 Capture Attribute 数量为零。
## 3. Mask 模式
- [x] 3.1 将 Mask 的 ADD / Add 统一改为 EXTEND / Extend,保持枚举数值与 Alpha 运算语义,将共用模式属性默认值改为 SET。
- [x] 3.2 更新模式、状态文本、设置持久化和注册测试,验证三种模式的 Alpha 结果及 RGB 保持行为。
## 4. 验证
- [x] 4.1 运行相关测试及 `uv run pytest`,确认正常退出。
- [x] 4.2 检查运行时代码和测试中的旧 Mask 模式引用及最终差异,确认源码、测试和节点资产一致。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-09

View File

@@ -0,0 +1,44 @@
## Context
`build_material_layer_group()` 共用构建 O Image Layer 和 O Image Depth Layer。普通层直接传出输入 Alpha,深度层使用前景阈值。`create_image_material()` 已集中创建材质,并通过 `configured_material_view_adaptation()` 配置颜色图片。
## Goals / Non-Goals
目标是将参考图的 Alpha 调整内置到 O Image Layer,支持连续强度调节,并在创建时读取现有偏好。变更范围为普通图像材质层;深度层继续使用其前景阈值协议。
## Decisions
### 曲线与接口
O Image Layer 的默认折叠面板 `Options` 依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix 类型为 NodeSocketFloat,subtype 为 FACTOR,范围 0–1,默认 0。直接连接 ShaderNodeFloatCurve 的 Factor,复用节点自身的混合语义:`output = (1 - factor) * alpha + factor * curve(alpha)`。
曲线使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、上方控制点 `(0.925, 0.925)` 和终点 `(1, 1)`。上方控制点为参考图估计值。采用自动钳制手柄、水平延伸及 0–1 的裁剪与采样区间。低端控制点之后快速上升,完整调整时输入 0.85 输出约 0.1593、输入 0.9 输出约 0.774;低端附近存在 Blender 曲线查表插值误差,测试容差为 0.0003。
```mermaid
flowchart TD
A[组输入 Alpha] --> C[Float Curve Value]
B[组输入 Alpha Fix] --> D[Float Curve Factor]
C --> E[曲线调整并按 Factor 混合]
D --> E
E --> F[组输出 Alpha]
F --> G[Principled Alpha 或 Shadeless 混合系数]
```
Alpha 曲线放在独立且就近的支线,保持曲线可见、默认节点标题和未使用输出隐藏;Color、Roughness 与 Normal 保持各自连接。
### 创建时应用偏好
`create_image_material()` 在普通层节点绑定后读取现有公共偏好函数,将节点实例的 `Alpha Fix` 设为 1 或 0。判断依据就是偏好值,因此开启偏好但颜色空间回退到 sRGB 时仍设为 1。手动拖入节点组时采用资产默认值 0。
用户随后可独立调整每个材质实例;偏好切换只影响后续创建。普通着色与 Shadeless 共用组输出 Alpha。Color 图片的颜色空间和 Alpha 模式继续由现有配置入口处理。
### 验证与交付
修改构建脚本、资产验证及材质测试;通过测试期间构建的节点组检查接口、连接与曲线采样。节点资产文档按节点组专项规范同步描述接口。用户已授权更新节点资产,O_AnyImage.blend 中的 O Image Layer 与运行时代码配套交付;在 Blender 节点编辑器确认实际尺寸、节点间距和连接,并运行全量测试及资产验证。
## Risks / Trade-offs
- 截图未展示所有精确坐标与手柄设置 → 用上述初值实现,检查曲线采样,并对照实际节点编辑器及透明边缘效果验收。
- 曲线会削弱中低 Alpha → 默认手动实例强度为 0,创建时按用户偏好启用,保留每个实例的强度控制。
- 工作区已有材质与节点资产修改 → 在当前实现上追加变更,保留已有修改并检查最终差异。
- 旧资产缺少输入 → 验证资产接口与运行时代码一致,正式发布配套资产。

View File

@@ -0,0 +1,27 @@
## Why
使用 inverse 类颜色空间时,用户观察到 Alpha 无法正确遮盖图片边缘,手动添加 Float Curve 后可改善。将这项调整放入 O Image Layer,让材质创建时自动应用,并允许用户调节强度。
## What Changes
- O Image Layer 内新增 Alpha Float Curve,依据参考图设置曲线。
- 默认折叠的 `Options` 面板依次包含 `Alpha Fix`、`Normal Scale`、`Object Space`;Alpha Fix 连接 Float Curve 的 Factor,范围 0–1,默认 0。
- 创建使用 O Image Layer 的材质时,读取 `Adapt to Scene View Transform`:开启设为 1,关闭设为 0。
- 验证曲线、接口、材质创建和着色输出行为。
## Capabilities
### New Capabilities
- `image-layer-alpha-adjustment`: 材质层 Alpha 曲线调整、强度输入与创建时偏好联动。
### Modified Capabilities
无。
## Impact
- `tools/node_assets/material_layers.py`:节点组接口、曲线与布局。
- `src/anyimage/common/material.py`:创建节点实例时设置调整强度。
- `tools/node_assets/asset_validation.py` 及材质相关测试:接口、连接、曲线求值和偏好联动验证。
- 发布节点资产 `src/anyimage/assets/O_AnyImage.blend` 需要包含新接口;本轮仅生成提案。

View File

@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Image layer exposes alpha adjustment strength
O Image Layer SHALL 在默认折叠的 `Options` 面板中依次提供 `Alpha Fix`、`Normal Scale`、`Object Space`。Alpha Fix SHALL 为浮点输入,范围 0–1,subtype 为 FACTOR,默认 0,并以该值控制内部 Alpha Float Curve 的 Factor。
#### Scenario: Options are initially collapsed
- **WHEN** 创建 O Image Layer 节点实例
- **THEN** Options 面板 SHALL 默认折叠,包含 Alpha Fix、Normal Scale、Object Space
- **AND** Color、Alpha、Normal、Bump Scale SHALL 位于面板外
#### Scenario: Adjustment is disabled
- **WHEN** Alpha Fix 为 0
- **THEN** 组输出 Alpha SHALL 等于输入 Alpha
#### Scenario: Adjustment is fully applied
- **WHEN** Alpha Fix 为 1
- **THEN** 组输出 Alpha SHALL 等于内部 Float Curve 对输入 Alpha 的求值
#### Scenario: Adjustment is partially applied
- **WHEN** Alpha Fix 为 0.5
- **THEN** 输出 SHALL 等于原始 Alpha 与完整曲线结果的平均值
### Requirement: Alpha curve follows the reference profile
Alpha 曲线 SHALL 使用四个点:起点 `(0, 0)`、低端控制点 `(0.8, 0)`、靠近右上方的平滑控制点和终点 `(1, 1)`,形成参考图中的快速上升轮廓。对输入范围 0–1,完整调整后的输出 SHALL 保持在 0–1,保持单调不减,并保持完全透明与完全不透明端值。
#### Scenario: Low alpha is suppressed
- **WHEN** 输入 Alpha 在 0–0.8 且调整强度为 1
- **THEN** 输出 Alpha SHALL 为 0,允许浮点求值误差
#### Scenario: Opaque alpha is preserved
- **WHEN** 输入 Alpha 为 1
- **THEN** 任意调整强度下输出 Alpha SHALL 为 1
### Requirement: Material creation initializes adjustment from preferences
创建使用 O Image Layer 的材质时,系统 SHALL 通过现有偏好读取入口,将 Alpha Fix 初始化为 `Adapt to Scene View Transform` 开启时的 1、关闭时的 0。
#### Scenario: Adaptation is enabled
- **WHEN** 开启适配后创建普通着色或 Shadeless 材质
- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 1
- **AND** 对应着色器 SHALL 使用该组调整后的 Alpha 输出
#### Scenario: Adaptation is disabled
- **WHEN** 关闭适配后创建材质
- **THEN** O Image Layer 实例的 Alpha Fix SHALL 为 0
#### Scenario: Color space falls back
- **WHEN** 适配开启且颜色空间解析回退到 sRGB
- **THEN** 新建 O Image Layer 实例的 Alpha Fix SHALL 仍为 1
#### Scenario: Existing material is manually adjusted
- **WHEN** 用户修改现有材质的 Alpha Fix 后切换偏好
- **THEN** 已有实例的调整值 SHALL 保留
- **AND** 后续新建材质 SHALL 使用当前偏好初始化

View File

@@ -0,0 +1,18 @@
## 1. Alpha 曲线与接口
- [x] 1.1 在 `material_layers.py` 的 O Image Layer 分支加入 Alpha Fix 输入及 Float Curve,按设计设置坐标、手柄、裁剪和连接。
- [x] 1.2 调整节点布局及未使用输出隐藏,在实际 Blender 节点编辑器检查总览与曲线局部。
- [x] 1.3 更新资产验证及对应测试,检查 socket 顺序、默认值、连接和曲线采样;覆盖强度 0、0.5、1、低 Alpha、端点、单调性与范围。
## 2. 材质创建联动
- [x] 2.1 在 `create_image_material()` 中复用偏好读取入口,为 O Image Layer 实例设置 Alpha Fix。
- [x] 2.2 补充材质行为测试,覆盖偏好开关、sRGB 回退、普通着色、Shadeless 及现有材质手动值保留,验证 O Image Depth Layer 的既有 Alpha 行为。
## 3. 验收
- [x] 3.1 对照参考图验证曲线形状,使用透明边缘图片比较强度 0、0.5、1 的效果,记录估计控制点的最终取值。
- [x] 3.2 按节点组专项规范同步 `docs/internals/node-assets.md` 的新输入与曲线说明。
- [x] 3.3 按用户授权更新 O Image Layer 节点资产,运行全量测试并确认正常退出,检查最终差异和接口引用;验证 Blender 节点资产,不构建文档或扩展发布包。
验证结果:766 passed、2 xfailed、50 subtests passed,退出码 0;Blender 4.5.10 资产验证与 OpenSpec 严格校验通过。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-03

View File

@@ -0,0 +1,84 @@
## Context
Mask 的 Lasso、Brush 与 Polyline 共用 `ImageGesture`。第一版 Brush 实时预览在 Modal 期间创建同尺寸 Generated Image,并以最多 30 FPS 更新 Pixel Buffer;4K 实测表明 `image.update()` 仍可能上传完整纹理,频繁更新会造成明显卡顿,红色填充 Overlay 也会随着 Flush 时机出现或消失。
因此 Brush 回到与 Lasso、Polyline 一致的事务边界:手势期间只保留简化后的屏幕轨迹并绘制几何 Overlay,不读取、创建或修改 Image;鼠标释放后才投影和栅格化完整笔画,创建一个最终 Image 并替换 active Image Empty。
Keep Original 与 Refine Selection 已无运行时代码,但测试中仍有旧字段缺席断言和一个多余参数。`SelectionPath.invert` 也已无生产调用方,却继续扩大 JSON 协议并让栅格器保留完整画布反选分支。
## Goals / Non-Goals
**Goals:**
- Brush 拖动期间显示与 Lasso、Polyline 相同的虚线和 Mode 填充,不更新 active Image Empty。
- Overlay 与释放提交复用同一组 Brush Primitive 和 union 语义,保证预览范围就是最终 Selection。
- 增加与原 Crop Polyline 手势一致、但使用当前 Mask 术语和 Alpha 主链的 Polyline。
- 将 Mask 默认 Mode 改为 Subtract。
- 删除 Keep Original、Refine Selection、Selection Invert 的剩余状态和历史测试噪音。
**Non-Goals:**
- Lasso 与 Polyline 在路径闭合前只显示几何 Overlay,不实时修改 Image Alpha。
- 不编辑共享源 Image 的 Pixel Buffer,不改变 Frame、Rectify、Cutout Shape 或 Server 行为。
- 不在 Modal 拖动阶段创建预览 Image、读取源 RGBA 或执行图片级计算。
- 不增加 Pillow、OpenCV 或 GPU Compute 依赖。
## Decisions
### 1. Brush 拖动阶段只维护屏幕轨迹
Brush 确认开始编辑后只保存屏幕坐标。鼠标移动使用与 Lasso 相同的最小距离和近共线简化,避免静止事件或直线移动积累冗余点。Modal 期间不调用 `image_rgba()`、`bpy.data.images.new()`、Pixel Buffer 写入、`image.update()` 或 `pack()`,active Image Empty 始终引用原 Image。
RMB、Esc、工具切换和异常只移除 Draw Handler 并结束 Operator,不需要临时 Image 恢复或数据块清理。这样拖动性能只与 Viewport Overlay 和简化后的屏幕轨迹有关,不随图片分辨率变化。
### 2. Overlay 与提交共享 Brush Primitive Union
简化后的中心轨迹生成一组稳定 Primitive:每个采样点对应一个圆,每对相邻点对应一个等宽连接条。Viewport 在 screen space 对这些 Primitive 做 union;鼠标释放后把同一组 Primitive 投影到图片,逐个栅格化并以逐像素 `max` 合入单个覆盖画布。转向、折返与交错都由圆和连接条的并集定义,不再从中心线偏移出一个可能改变 winding 的自交多边形,因此不会产生 miter 尖角或未填充三角。
Selection 完成后读取一次源 RGBA,并根据操作开始 Alpha `A0` 与 Mask `M` 合成:
```text
Set A' = A0 × M
Add A' = max(A0, M)
Subtract A' = A0 × (1 - M)
```
`apply_alpha_mask()` 使用同一纯 Alpha 合成函数;释放阶段只保留一个累计覆盖画布,不保存逐 Primitive Mask 列表。
不选择在每次刷新时重新栅格化完整轨迹,因为成本随轨迹长度持续增加;也不保存每个 Primitive 的独立 Mask,因为内存会随鼠标事件数增长。
### 3. Brush 使用与 Lasso、Polyline 相同的 Overlay
Viewport Overlay 在每个有效移动事件后,把全部 Brush Primitive 在局部屏幕 bounds 内以 2 px scanline bands 做区间 union,再由合并 bands 生成填充三角形,并从相邻 bands 的边界差异连接出闭合外轮廓。内部重叠边和 band 接缝不会进入虚线,急转与交错区域保持连续填充。该过程只处理 Viewport 坐标,不读取或栅格化源 Image。
Subtract 使用红色半透明填充,Set 与 Add 使用灰色半透明填充;虚线保持相同样式。Overlay 不随任何 Image 状态变化,并让用户在释放前看清即将作用的准确区域。
### 4. Polyline 是 Mask 的第三种 Gesture
`mask_gesture_property()` 增加 `POLYLINE`,单一 Mask WorkspaceTool 和 `EditImageAlpha` Operator 保持不变。`ImageGesture` 恢复无 Crop 术语的 Polyline 状态机:首次点击建立起点,后续左键提交顶点,鼠标移动预览下一条边;至少三个点后,点击起点 8 px 范围或按 Enter 完成,Backspace 删除最后一点,RMB/Esc 取消。
完成后把已提交顶点转换为一个 `SelectionPath`,与 Lasso 一样栅格化并进入当前 Mode 的 Alpha 合成。Radius 只属于 Brush;Polyline Tool Settings 只显示 Gesture 与 Mode。
### 5. SelectionPath 只保存 Points
删除 `SelectionPath.invert`、JSON 中的 `invert`、完整画布反选 bounds 和 `1 - mask` 分支。Cutout 的 Lasso 与双击整图都已经提供正向 Path,不需要迁移运行时状态。JSON 继续用于 Cutout Operator 间传值,但只包含 `points`。
删除重复验证 Keep Original、Refine Selection 和旧 Crop 字段缺席的历史断言;仍通过当前 Scene Property、Operator annotation、Job request 和打包文件的正向精确集合测试固定公开边界。测试夹具中的多余 `keep_original=False` 同步删除。
## Risks / Trade-offs
- [高分辨率图片会让释放后的提交耗时可感知] → 将图片级投影、栅格化和结果创建集中到一次释放事件,避免拖动期间反复上传纹理。
- [长而复杂的笔画会保留较多屏幕点] → 使用与 Lasso 相同的距离与近共线简化,只保留表达轨迹形状所需的点。
- [Set 模式会让笔画外 Alpha 归零] → 保持既有 Set 公式;默认 Subtract 仍覆盖最常用的擦除行为。
- [复杂笔画包含大量重叠 Primitive] → screen-space scanline 先合并每个 band 的区间,再生成一次填充和外轮廓;图片空间只在释放时使用单个累计覆盖画布。
- [Polyline PRESS 事件与对象点选冲突] → 沿用 `resolve_image_edit_click()`,首次点击另一对象只切换 active,再次点击当前 Image 才开始手势。
## Migration Plan
先完成已实现的 `replace-crop-with-mask-and-rectify` 作为基线。实现时先精简 SelectionPath,再加入 Polyline 状态机,随后实现 Brush 虚线 Overlay 与释放栅格化,最后删除失去调用方的 Helper 和历史测试。Scene 中未显式保存 Mode 的文件自动使用新的 Subtract 默认值;已经保存的 Mode 值保持不变。
回滚时可恢复本 change 前的 Mask 实现;它不迁移持久文件、Server 数据或 Blender Object 结构。
## Open Questions
无。4K 实测决定拖动阶段不再更新 Image;后续实测进一步要求 Overlay 与提交共享圆角 footprint,并恢复 Mode 填充。

View File

@@ -0,0 +1,26 @@
## Why
Mask Brush 的实时 Image 预览在 4K 图片上会频繁上传大纹理,造成明显卡顿;红色填充 Overlay 还会随 Image 刷新时机出现或消失。Brush 更适合像 Lasso、Polyline 一样在手势期间只显示稳定的几何反馈,释放后再提交 Alpha 修改。与此同时,已删除的 Keep Original、Refine Selection 和 Invert 仍有测试或通用数据分支残留,Brush 还会为整条轨迹积累大量临时 Mask,适合在扩展 Gesture 时一并收紧。
## What Changes
- **BREAKING**:Mask Mode 默认值从 Set 改为 Subtract,使 Mask 默认表现为擦除 Alpha。
- Brush 按住拖动期间不读取或修改 Image Pixel Buffer;Overlay 与释放提交复用同一组圆形印记、连接条和 union 规则,急转与交错区域融合且虚线只保留融合后的外轮廓。显示沿用 Lasso、Polyline 的 Mode 填充:Subtract 为红色,Set/Add 为灰色。
- Mask Gesture 增加 Polyline,沿用逐点单击、移动预览、点击起点或 Enter 完成、Backspace 撤回一点的交互,并复用 Set、Add、Subtract Alpha 合成。
- 删除无生产调用方的 `SelectionPath.invert`、全画布反选栅格分支及旧字段序列化,清理只验证 Keep Original、Refine Selection 缺席的历史测试和测试参数。
- 删除由中心线偏移生成单一自交外框的方案,避免急转时 winding 产生三角孔洞;改为直接合并 Brush 圆和连接条。
## Capabilities
### New Capabilities
- `mask-editing`: 定义 Mask 的 Lasso、Brush、Polyline Gesture,Subtract 默认模式,Brush 虚线预览、释放提交与取消语义,以及无旧编辑状态的精简 Selection 数据边界。
### Modified Capabilities
## Impact
- 影响 `properties.py` 的 Mask 默认设置,以及 `operators/image_edit_tool/mask.py` 的释放提交与 Brush Selection 主链。
- 影响 `common/viewport.py` 的 Gesture 状态机、Polyline 与 Brush Overlay,以及 `common/selection.py` 的 SelectionPath 表示。
- 影响 Mask、Selection、Cutout、注册、交互与打包测试,并同步 Image Edit、Cutout 和 Common 内部文档。
- 不增加第三方依赖,不改变 Frame、Rectify、Cutout Shape、Remove Background 或 Server Job 协议。

View File

@@ -0,0 +1,94 @@
## ADDED Requirements
### Requirement: Mask exposes three gestures with Subtract by default
系统 SHALL 在单一 Mask Tool 中提供 Lasso、Brush 和 Polyline Gesture,并 SHALL 提供 Set、Add、Subtract Mode。新 Scene 的 Mask Mode MUST 默认为 Subtract;已有 Scene 中已保存的 Mode MUST 保持其值。Radius MUST 只在 Brush Gesture 下显示和生效。
#### Scenario: New scene uses Subtract
- **WHEN** 新 Scene 尚未保存 Mask Mode
- **THEN** Mask Tool Settings 与新启动的 Mask Operator 使用 Subtract
#### Scenario: Gesture settings remain unified
- **WHEN** 用户在 Lasso、Brush 与 Polyline 之间切换
- **THEN** 系统仍使用同一个 Mask Tool、Mode 设置和 Alpha 合成入口,并只在 Brush 下显示 Radius
### Requirement: Brush defers Image work until release
Brush MUST 在手势完成前保持 active Image Empty 的 Image 不变。拖动期间 MUST NOT 读取源 RGBA、创建临时 Image、栅格化图片级 Mask、写入 Pixel Buffer、调用 `image.update()` 或 Pack;鼠标释放后 SHALL 一次性计算并提交结果。
#### Scenario: Dragging leaves the Image untouched
- **WHEN** 用户按住左键移动 Brush 且手势尚未完成
- **THEN** active Image Empty 继续显示并引用操作开始时的 Image,只通过 Viewport Overlay 呈现待提交范围
#### Scenario: Shared source remains unchanged during the gesture
- **WHEN** active Image Empty 与另一个对象共享源 Image 并开始 Brush
- **THEN** 两个对象都继续引用且显示原始 Image,直到 active Image Empty 在释放后接收新结果
#### Scenario: Stationary hold performs no Image work
- **WHEN** 用户按住 Brush 且鼠标没有产生有效位移
- **THEN** 系统不执行 Image 更新或图片级计算
### Requirement: Brush completion and cancellation are transactional
Brush 完成时 MUST 创建并 Pack 一个最终结果 Image,并 SHALL 通过公共替换入口让 active Image Empty 承载结果。取消、工具切换或错误 MUST 保持源 Image 不变且不产生完成的编辑;成功完成 MUST 保持对象 identity、matrix、display size、offset、共享源引用和单步 Undo 语义。
#### Scenario: Completing a Brush persists one result
- **WHEN** 用户释放左键完成 Brush
- **THEN** 系统计算完整覆盖、创建并 Pack 一个同尺寸结果,active Image Empty 的画框和对象身份保持不变
#### Scenario: Cancelling restores the source
- **WHEN** 用户在 Brush 期间按 RMB 或 Esc
- **THEN** active Image Empty 仍引用操作前 Image,且没有临时 Image 或完成的 Undo 编辑
#### Scenario: Tool switch cleans the preview
- **WHEN** Brush 运行期间 active WorkspaceTool 发生变化
- **THEN** 系统只结束 Overlay 与 Modal,Image 保持不变
### Requirement: Brush uses one continuous primitive union
Brush MUST 从简化后的屏幕轨迹生成圆形采样点和等宽连接条。Overlay 与释放后的 Selection MUST 使用同一组 Primitive 和 union 语义;急转、折返、自交与重叠区域 MUST 保持连续,MUST NOT 出现超出 Radius 的尖角或未填充三角。图片空间 union MUST 以逐像素最大值累计到单个覆盖画布,不得保存逐 Primitive Mask 列表。
#### Scenario: Sparse events leave no gap
- **WHEN** 两个连续 Brush 采样点之间的距离大于 Radius
- **THEN** 两点之间的胶囊完整覆盖连线且不出现空隙
#### Scenario: Turning does not create a horn or hole
- **WHEN** Brush 中心轨迹发生方向变化
- **THEN** 圆形采样点与连接条融合,Overlay 与最终 Selection 都不出现 miter 尖角或未填充三角
#### Scenario: Preview and result share the footprint
- **WHEN** 用户释放 Brush
- **THEN** 系统投影并栅格化拖动时显示的同一组 Brush Primitive,并使用相同的 union 规则
### Requirement: Brush matches the Lasso and Polyline overlay
Brush SHALL 在每个有效鼠标事件后绘制整次未提交笔画的 footprint。Overlay MUST 复用 Lasso、Polyline 的虚线边界、non-zero winding 三角化和半透明填充;自交区域 MUST 融合为一个填充区域,虚线 MUST 只绘制融合后的外轮廓而不得保留内部交错边。Subtract MUST 使用红色填充,Set 与 Add MUST 使用灰色填充。
#### Scenario: Overlay persists until release
- **WHEN** 用户继续拖动 Brush 且尚未释放左键
- **THEN** Viewport Overlay 以当前 Mode 的填充和虚线边界显示从起点到当前点的准确 footprint,Image 本身保持不变
#### Scenario: Crossing strokes merge visually
- **WHEN** Brush footprint 与自身交错或重叠
- **THEN** 交错部分显示为连续的一整块填充,虚线只围绕融合区域的外边界
### Requirement: Polyline edits Alpha through the Mask pipeline
Polyline SHALL 以逐点单击构建闭合 Selection:鼠标移动 MUST 预览下一条边;至少三个点后点击起点 8 px 范围或按 Enter MUST 完成;Backspace MUST 删除最后一个已提交点;RMB 或 Esc MUST 取消。完成的 Polyline MUST 栅格化为一个 SelectionMask,并 MUST 以当前 Set、Add 或 Subtract Mode 修改当前 Alpha,保持 RGB、图片尺寸和 Image Empty 画框不变。
#### Scenario: Clicking the start closes Polyline
- **WHEN** 用户已提交至少三个点并在起点 8 px 范围内单击
- **THEN** 系统闭合路径并应用当前 Mask Mode
#### Scenario: Enter confirms Polyline
- **WHEN** 用户已提交至少三个点并按 Enter 或 Numpad Enter
- **THEN** 系统闭合路径并应用当前 Mask Mode
#### Scenario: Backspace removes one point
- **WHEN** Polyline 已有两个以上已提交点且用户按 Backspace
- **THEN** 系统删除最后一点并继续预览未完成路径
### Requirement: Image selections carry only positive path geometry
`SelectionPath` SHALL 只保存并序列化 Points。Mask、Cutout、Scene Property、Operator 参数和 Server Job 请求 MUST NOT 声明或传递 Invert、Keep Original、Refine Selection 或 Refine Model 状态;Selection 栅格化 SHALL 只建立带抗锯齿 Padding 的局部 Path Bounds。
#### Scenario: Cutout serializes a path
- **WHEN** Cutout 把 Lasso SelectionPath 传给 Shape Operator
- **THEN** JSON 只包含 `points`,恢复后得到相同的正向路径
#### Scenario: Public state contains current concepts
- **WHEN** 系统枚举 Scene Property、Mask/Cutout Operator annotation 和 Cutout Job 请求
- **THEN** 只出现当前 Gesture、Mode、Radius、Shape、Depth 与 Normal 业务字段

View File

@@ -0,0 +1,35 @@
## 1. Mask 设置与 Selection 精简
- [x] 1.1 将 Mask Mode 默认值改为 Subtract,在 Gesture 中增加 Polyline,并更新 Scene Property、Operator 与 Tool Settings 测试
- [x] 1.2 删除 `SelectionPath.invert`、JSON `invert` 字段、完整画布反选 bounds 和补集栅格化分支,使 Cutout 序列化只传递 points
- [x] 1.3 清理测试中遗留的 Keep Original、Refine Selection 参数及重复历史缺席断言,以当前 Property、Operator、Job 和打包内容的精确集合固定公开边界
- [x] 1.4 更新 Selection 与 Cutout 测试,验证局部抗锯齿 bounds、JSON 往返、整图 Cutout 和普通 Lasso 在精简后保持正确
## 2. Polyline Gesture
- [x] 2.1 在无 Crop 术语的 `ImageGesture` 中实现 Polyline 点提交、移动预览、8 px 起点闭合、Enter 完成、Backspace 回退和取消状态机
- [x] 2.2 为 Polyline 绘制闭合预览、虚线轮廓和起点指示,并更新 Mask 状态文字与 Radius 显示条件
- [x] 2.3 将完成的 Polyline 接入 Lasso 共用的 SelectionMask 与 Alpha 合成入口,验证 Set、Add、Subtract、画布保持和对象点选语义
- [x] 2.4 补充 Polyline Modal、Overlay、闭合方式、无效路径、取消和工具切换测试
## 3. Brush Footprint 与 Alpha
- [x] 3.1 将 Alpha 公式整理为完整 Selection 共用的纯函数,固定 Set、Add、Subtract 结果
- [x] 3.2 从简化后的屏幕轨迹生成圆形采样点和等宽连接条,并在 screen space 与图片空间分别执行同语义 union
- [x] 3.3 删除逐 Primitive Mask 列表和失去调用方的 `merge_selection_masks()`,图片空间只保留一个累计覆盖画布
- [x] 3.4 补充单击、稀疏移动、急转折返、自交、倾斜/缩放投影与图片边界测试
## 4. Brush 虚线预览与延迟提交
- [x] 4.1 删除临时 Image、dirty RGBA、30 FPS Flush 与实时 Pixel Buffer 更新,使 Brush 拖动阶段不读取或修改 Image
- [x] 4.2 将 Brush Primitive 在 screen-space scanline bands 中融合,消除内部交错边、急转尖角和未填充三角,并复用 Lasso、Polyline 的填充配色
- [x] 4.3 让 Overlay 与鼠标释放后的 Selection 栅格化复用同一组 Primitive 与 union 语义,再通过公共 Alpha 与 Image 替换入口一次性提交
- [x] 4.4 取消、异常与工具切换只结束 Overlay,不创建临时 Image 或恢复分支,并保持对象 identity、transform、画框和单步 Undo
- [x] 4.5 更新 Brush 急转折返、Primitive Union、融合外轮廓、Overlay 配色、释放提交和真实 Blender Image 生命周期测试
## 5. 文档与验证
- [x] 5.1 更新 README 与 Image Edit、Common、Cutout 内部文档,说明三种 Gesture、Subtract 默认值、Brush Mode 填充、Primitive Union、释放提交和只含 points 的 SelectionPath
- [x] 5.2 搜索并清除运行时与文档中的 Keep Original、Refine Selection、Selection Invert、旧 Brush 全轨迹合并 Helper 和历史 Crop Polyline 名称
- [x] 5.3 运行 Selection、Blender Addon、Cutout、Image Edit 与 Packaging 定向测试并修正失败
- [x] 5.4 运行 `uv run pytest` 和 OpenSpec strict validation,不构建文档、节点资产或发布产物

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-10

View File

@@ -0,0 +1,63 @@
## Context
项目现有几何推理使用 ONNX Runtime,Windows 使用 DirectML,其他平台使用默认 Runtime。MoGe 3 的稀疏细化已在项目外改写为标准 ONNX 索引、聚合和矩阵运算,完整三次细化已实际运行。
实现使用上游提交 `74fbce054ebed49800de42d0ad0e83495065719a`,骨干与细化分别导出为 ONNX 图。骨干支持动态输入和 token 数,细化支持动态点数。在 RTX 5070 Ti 上,1200–3600 tokens 的完整动态推理约需 4.86–17.11 秒,平均深度相对误差约 0.002%,二值 Mask 完全一致。参考比较关闭 FlexGEMM 独立的 TF32 开关。Windows DirectML 与 Linux CPU 均已通过真实 Job 验证,详细结果见 [验收记录](validation.md)。
实验脚本与日志位于项目外 `C:/Users/icrdr/AppData/Local/Temp/anyimage-moge3-trial/`。正式转换位于 `tools/models/moge3_export.py`,模型准备和运行使用项目已有的资产机制。
## Goals
以最小改动将完整 MoGe 3 接入 MoGe 2 的现有工程流程,沿用输入准备、参数、下载、会话管理和几何产物契约。
## Decisions
### 动态尺寸与分辨率等级作为接入门槛
正式模型接收现有输入准备流程产生的图片尺寸及宽高比,沿用现有 resolution_level 到 token 数的控制方式,输出尺寸对应分析图片。256×192 / 1200 tokens 是原型测试配置,正式推理需通过动态形状及可变 token 数验证,同一组模型资产在任务间接受不同配置。
沿用已验证的两个图边界:骨干输出初始坐标、编码特征、法线、Mask 和尺度;细化图输出 log-depth 增量。MoGe 3 模块内部使用 NumPy 生成体素邻域并执行三轮调度,学习网络计算全部由 ONNX Runtime 执行,几何恢复复用现有后处理能力。逐层对比用于验证动态化后的数值一致性。
正式 Runtime 使用标准 ONNX 图和现有执行提供程序,完整执行三次细化。原型中主要网络计算由 DirectML 执行,部分索引与计数节点由 ONNX Runtime CPU 执行;正式验收继续记录实际执行路径。
### 最小工程改动
沿用现有图像准备、最大分析尺寸、分辨率等级、设备选择、Job 参数和产物。新模型特有的图集合、索引生成及迭代封装在 `models/onnx_moge3.py`;已有推理调用点只增加必要的模型分派。
复用现有模型下载与会话生命周期,通用编排名称仅在职责确实扩展时局部调整并一次更新真实调用者。Operator、Blender 响应和节点资产继续消费现有契约。正式运行依赖保持现有 ONNX 和后处理体系,离线转换依赖独立管理。
### 精度和性能验证
比较同权重、相同输入与分辨率等级的原始三次细化结果和 ONNX 结果。先用 FP32 验证语义,再评估 FP16。实验需在导出前声明验收容差,记录有效区域深度与点图误差、法线角误差、内参偏差、Mask 一致性及每次细化误差,不得根据结果事后放宽。
样本包含室内、细小物体、室外、不同宽高比及全景透视视图;覆盖低/高分辨率等级、自动 FOV 与固定 90 度 FOV。记录转换耗时、ONNX 文件大小、会话启动、热推理耗时及峰值内存/显存。Windows 验证 CPU 和 DirectML 的完整执行情况及实际算子回退;其他平台需分别验证,不沿用 PyTorch 的 CUDA 平台限制。
### 验证后的正式接入
```mermaid
flowchart TD
A[已验证的双 ONNX 图原型] --> B[动态尺寸及分辨率等级验证]
B --> C{精度 性能 提供程序通过}
C -->|通过| D[生成并校验模型资产]
D --> E[登记模型与下载]
E --> F[统一 MoGe ONNX 推理入口]
F --> G[深度平面 裁切 全景]
C -->|未通过| H[记录阻碍与实验结果]
```
转换工具放在 `tools/models`,依赖固定版本或上游提交;最终登记所有图文件及 external data 的大小和哈希,完整下载后标记就绪。镜像发布后才登记对应来源。生产推理只读取已准备的 ONNX 资产。
新增模型沿用现有几何模型枚举,保留已有模型键和默认值;仅调整识别新模型所必需的元数据与筛选。输出使用现有 CPU NumPy 数组和相机内参约定。全景传入 90 度 FOV,保持 Mask 与源有效性、Alpha 合成以及径向距离融合契约。
缓存按模型、设备和图集合识别,连续帧及视图复用会话,切换模型和关闭时释放资源。取消与失败沿用现有 Job 事务。
## Risks / Trade-offs
- 动态体素分箱可能放大浮点数值差异 → 对相同 FP32 配置按预定容差比较。
- 高分辨率等级增加计算量与内存 → 已记录 CPU/DirectML 性能及峰值占用,继续沿用现有分辨率等级控制。
- 多图执行会增加 CPU/GPU 数据搬运 → 测量完整流水线耗时并据此决定图边界。
- 提供程序算子覆盖不同 → Windows DirectML 与 Linux CPU 已验收,macOS 仍需实机验证。
## Migration Plan
先基于已完成原型验证动态尺寸、分辨率等级、精度与性能,再完成转换工具和模型登记。沿用现有设置与默认模型,只增加新模型选择及必要分派。实施更新相关测试,检查依赖与打包内容一致性。

View File

@@ -0,0 +1,23 @@
## Why
MoGe-3 ViT-L 的完整三次细化已在项目外通过 CPU ONNX Runtime 与 DirectML 原型验证。正式接入应沿用 MoGe 2 的工程流程,以最小改动增加模型选项,并完成动态输入和分辨率等级验证。
## What Changes
- 将已跑通的双 ONNX 图原型扩展到现有输入尺寸处理、宽高比和分辨率等级,建立可复现导出与精度比较流程。
- 验证通过后新增 `MOGE3_VITL` 模型选项、ONNX 资产下载和就绪检查,保留已有模型及默认值。
- 深度平面、裁切与全景统一按模型分派,推理统一采用现有 ONNX Runtime,复用几何产物。
- 两个 ONNX 图、体素邻域生成与三次细化封装在 MoGe 3 模型内部;复用现有 Operator、节点资产、Job 参数和用户操作流程。
- 以完整细化、数值一致性、执行提供程序兼容性与可用性能作为正式接入门槛。
## Capabilities
### New Capabilities
- `moge3-model`: 完整 MoGe 3 的 ONNX 推理、模型选择、资产准备与几何工作流集成。
### Modified Capabilities
## Impact
改动集中在离线转换工具、模型登记、MoGe 3 ONNX 模块和必要的推理分派;模型枚举、下载与会话管理沿用既有机制。PyTorch、MoGe、FlexGEMM 与 Triton 仅用于隔离的离线转换及参考验证环境;正式 Server 沿用 ONNX Runtime 与现有后处理依赖。正式接入以动态尺寸、分辨率等级及完整流程验收通过为前提。

View File

@@ -0,0 +1,73 @@
## Purpose
让用户通过现有 ONNX Runtime 体系使用完整的 MoGe-3 ViT-L 几何预测,使模型准备、选择和执行在深度平面、裁切与全景中具有一致行为,并通过完整细化及数值验证保证结果可信。
## ADDED Requirements
### Requirement: Execute complete MoGe 3 with ONNX
正式推理 SHALL 通过 ONNX Runtime 执行 MoGe 3 的学习网络计算,并完成三次细化;运行环境 SHALL 无需 PyTorch、FlexGEMM 或 Triton。
#### Scenario: Production inference
- **WHEN** 在仅安装声明的 ONNX 运行依赖的 Server 中执行 MoGe 3
- **THEN** 完成包含三次细化的预测,不依赖外部 Torch 环境或服务
### Requirement: Validate conversion before availability
系统 SHALL 在完整流程的动态尺寸、分辨率等级、精度、性能和执行提供程序验证通过后才将新模型登记为正式可用模型。
#### Scenario: Incomplete conversion
- **WHEN** 只有关闭细化的模型可以运行,或完整模型未通过预定数值验收
- **THEN** 保留验证结果且不将其作为完整 MoGe 3 发布
### Requirement: Select and prepare model
系统 SHALL 提供 MOGE3_VITL 选项,保留已有模型键和默认值,下载并校验推理所需的全部 ONNX 资产。
#### Scenario: Download success
- **WHEN** 所需图文件及外部权重全部下载并通过完整性校验
- **THEN** 标记模型就绪,深度平面、裁切与全景使用用户所选模型
#### Scenario: Interrupted download
- **WHEN** 下载失败、取消或文件不完整
- **THEN** 不标记就绪,并允许重新准备
### Requirement: Preserve geometry contract
MoGe 3 SHALL 按所选分辨率等级提供与分析图尺寸对应的深度、法线、有效性、点图和相机内参,并遵循既有源 Alpha 处理契约。
#### Scenario: Image inference
- **WHEN** 有效图片完成推理
- **THEN** 有效区域深度为有限正值,产物可供既有几何工作流应用
#### Scenario: Panorama inference
- **WHEN** 全景任务选择 MoGe 3
- **THEN** 透视视图使用已知 90 度水平 FOV,模型 Mask 与源有效性共同约束融合及 Alpha 输出
### Requirement: Reuse existing input and workflow controls
MoGe 3 SHALL 沿用 MoGe 2 的输入准备、最大分析尺寸、分辨率等级及设备选择流程。同一组已下载模型资产 SHALL 接受现有流程产生的不同图片尺寸、宽高比和分辨率等级,输出尺寸对应分析图片。
#### Scenario: Vary image dimensions
- **WHEN** 用户连续提交现有输入流程支持的不同尺寸和宽高比图片
- **THEN** 使用同一组模型资产完成预测,遵循现有分析尺寸限制及输出尺寸契约
#### Scenario: Change resolution level
- **WHEN** 用户修改现有 MoGe Resolution Level 后再次执行任务
- **THEN** 新任务采用对应 token 数并执行三次细化,复用已下载模型资产
#### Scenario: Switch to MoGe 3 in an existing workflow
- **WHEN** 用户在深度平面、裁切或全景工作流中将模型改为 MoGe 3
- **THEN** 沿用原有任务参数、操作步骤与几何产物,模型内部完成多图及细化调度
### Requirement: Manage supported sessions
系统 SHALL 按已验证的 ONNX 设备能力执行,复用连续帧或视图的会话,在模型切换及关闭时释放资源,对不支持的执行方式和推理失败给出明确反馈。
#### Scenario: Model switching
- **WHEN** 用户在 MoGe 2 与 MoGe 3 之间切换
- **THEN** 执行新选择并释放旧模型资源
#### Scenario: Unsupported execution or cancellation
- **WHEN** 执行设备不支持、资源不足或任务取消
- **THEN** 给出明确状态,不应用部分结果,并允许后续重新运行

View File

@@ -0,0 +1,20 @@
## 1. 原型与动态配置验收
- [x] 1.1 在项目外将完整 MoGe 3 拆为骨干与细化 ONNX,使用固定 256×192 / 1200 tokens 在 CPU 和 DirectML 上运行三张图、三次细化,保存参考比较及耗时记录。
- [x] 1.2 将骨干扩展为动态输入尺寸和可变 token 数,用同一组 ONNX 资产连续运行不同宽高比、分析尺寸与分辨率等级,验证输出尺寸遵循现有契约。
- [x] 1.3 定位原型中较大像素误差,确定后续验收容差,在 CPU 与 DirectML 验证动态配置及 90 度 FOV,记录精度、实际执行路径、耗时与内存峰值。
- [x] 1.4 汇总动态配置、精度、性能及平台支持矩阵;完整验收通过后进入第 2 阶段,未通过项保留具体实验结论。
## 2. 模型资产与推理
- [x] 2.1 将已验证的转换方案整理至 tools/models,固定离线依赖,重新导出并通过相同精度验收以验证可复现性。
- [x] 2.2 登记 MOGE3_VITL 的全部 ONNX 图及外部数据文件,验证大小和哈希,测试下载成功、失败、取消与就绪判断。
- [x] 2.3 将双图、邻域生成和三次细化封装在 MoGe 3 ONNX 模块,仅增加必要推理分派;测试现有参数、输出尺寸、内参、有效性及分辨率等级传递。
- [x] 2.4 更新会话缓存和释放,测试模型交替、连续帧复用与失败后恢复,确认正式环境不需 Torch 或 Triton。
## 3. 工作流接入与验收
- [x] 3.1 在现有模型登记与枚举中加入新选项,测试旧模型键和默认值保持、新模型选择及设备错误提示,核对继续使用既有 Job 参数。
- [x] 3.2 在深度平面、裁切与全景的必要调用点接入分派,复用现有 Operator 和节点资产,运行产物、90 度 FOV、Alpha、取消与融合回归测试。
- [x] 3.3 在正式 ONNX Runtime 环境运行完整工作流并记录结果,在支持矩阵中的其他平台完成对应验收。(Windows 与 Linux 已通过,macOS 见 validation.md。)
- [x] 3.4 检查依赖、Manifest 和打包测试的一致性,运行相关测试与全量 pytest,确认正常退出及最终差异范围。

View File

@@ -0,0 +1,51 @@
# MoGe 3 ONNX 验收
2026-09-10。新增 `MOGE3_VITL`,复用 MoGe 2 的输入准备、0–9 分辨率等级、下载、会话缓存和几何产物。骨干支持动态图片尺寸及 token 数,细化图支持动态点数,正式推理执行三次细化。
## 资产
转换源码:`tools/models/moge3_export.py`。上游提交为 `74fbce054ebed49800de42d0ad0e83495065719a`,权重 revision 为 `184008f877d7ad1ad4c2cd2182a9bd1f63d0e5be`。固定 Torch、ONNX、NumPy 和上游提交后重复导出,两次文件哈希一致。
| 文件 | 字节数 | SHA-256 |
|---|---:|---|
| backbone.onnx | 1324190318 | f21246c6921373c8c79ecdb8c78818a79bd2506c2fb88f4d4cdec21b92f725e3 |
| refiner.onnx | 157729628 | 67ad7297aba18f8ef7939e8f700a5b25c41a19ddb68656bc2cd08b54c248fe2a |
权重内嵌于两个 ONNX 文件。镜像已发布到 `models.omoolab.xyz/moge-3-vitl-onnx/`,使用项目 `ModelManager.download` 下载到新目录,耗时 136.1 秒,大小、哈希及 `ready()` 校验通过。
## 精度与动态配置
参考为原始 MoGe 3、相同权重和三次细化。上游 FlexGEMM 的 `SPCONV_ALLOW_TF32` 独立于 Torch TF32 设置;关闭该选项后进行 FP32 比较。验收脚本预设平均深度相对误差小于 0.1%、99 分位小于 1%、二值 Mask 一致率大于 99.9%。
同一组动态图依次执行以下配置;耗时包含骨干、邻域生成、三次细化和后处理,不包含会话创建。
| 图片宽×高 | tokens | 水平 FOV | Windows CPU | DirectML | DirectML 平均深度相对误差 | 99 分位 |
|---|---:|---|---:|---:|---:|---:|
| 384×256 | 1200 | 自动 | 10.65 s | 4.86 s | 0.00216% | 0.02068% |
| 257×385 | 2400 | 90° | 22.92 s | 9.67 s | 0.00228% | 0.01823% |
| 320×320 | 3600 | 自动 | 36.41 s | 17.11 s | 0.00167% | 0.01609% |
三项均通过 CPU 和 DirectML 容差,二值 Mask 一致率为 100%。正式模块使用现有 MoGe 2 后处理,等级 0/9 的平均点图相对误差约 0.00217%/0.00173%,平均法线角误差约 0.006°,归一化内参最大绝对偏差小于 0.000002。2400 tokens 是图层测试值;公开等级继续按原公式映射至 1200–3600 tokens。
## 工作流与平台
| 平台 / Runtime | 验收结果 |
|---|---|
| Windows / DirectML 1.24.4 / RTX 5070 Ti | 动态精度、深度、裁切、12 视图全景通过;纯 ONNX 环境 |
| Windows / CPU | 动态精度通过;原型 CPU Runtime 为 1.29.0 |
| Linux x64 / CPU 1.24.4 / Python 3.12 | 使用项目声明的正式依赖,深度、裁切、12 视图全景通过 |
| macOS / CPU、CoreML | 当前没有可用设备,待实机验收 |
真实 Job 调用仅替换进度/取消传输上下文,图片准备、模型管理、推理、几何处理与文件写入均使用项目代码。Windows 深度、裁切、全景分别约 9.81、5.45、67.13 秒;Linux 分别约 19.19、9.10、112.10 秒。深度/裁切生成 EXR、元数据及法线,全景完成 12 个 90° 视图推理和径向融合并生成 EXR 与元数据。
DirectML 的主要网络计算在 GPU 执行,部分 Gather、Unsqueeze、Concat、Less、Cast、ReduceSum 由 ONNX Runtime CPU 提供程序执行。正式环境依赖保持原样,未引入 Torch、FlexGEMM 或 Triton。
等级 0/9 连续运行时,Windows 进程峰值工作集约 1483 MiB;显卡整体占用由 6370 MiB 上升至最高 15716 MiB(每 0.5 秒采样,包含桌面及其他进程,不能视为模型独占显存)。Linux 三个 Job 进程峰值 RSS 约 4664 MiB。高等级的内存占用明显高于低等级。
## 回归与复现记录
全量 pytest:1143 passed、52 subtests passed,187.79 秒,正常退出。随后新增会话加载失败恢复测试,MoGe 3 专项 7 passed。覆盖下载完整性、缓存复用/切换/恢复、三次细化、尺寸、参数及现有 Alpha、FOV、取消、全景融合和打包内容契约。OpenSpec 严格校验通过。
实验与运行日志保存在 `C:/Users/icrdr/AppData/Local/Temp/anyimage-moge3-trial/`:`dynamic_reference.py`、`dynamic_validate.py`、`production_validate.py`、`job_validate.py`、`linux_jobs.py`、`download_validate.py`、`memory_validate.py`,及对应 JSON/日志。Linux 使用只读项目挂载的 Python 3.12 容器;Windows 全量测试使用按项目锁文件创建的独立测试环境。
任务 3.3 保留未完成状态,待补充 macOS 实机验收。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-09

View File

@@ -0,0 +1,86 @@
## Context
独立原型验证了 12 个透视视图的 MoGe2 推理、距离融合和 Quad Sphere。正式转换沿用 Image Empty 的异步转换流程。用户明确选择通过现有 Remove Background 预先处理背景,转换消费当前图片和 Alpha。
## Goals / Non-Goals
本次范围是静态 2:1 等距柱状全景的菜单转换、正式节点资产和 Split Threshold。沿用原型的 Subdivide、Depth Scale 和单张深度图片约定。
## Decisions
### 入口与对象生命周期
```mermaid
flowchart TD
A[Image Empty 右键 AnyImage] --> B[Convert to Panorama]
B --> C[检查静态图和 2 比 1 尺寸]
C --> D[读取当前 RGBA 和 Preferences]
D --> E[提交全景 Job]
E --> F[加载并打包颜色和深度]
F --> G[创建全景对象和节点修改器]
G --> H[替换源 Empty 并记录撤销]
```
新增 `operators/convert_to_panorama/`,`operators.py` 定义 `ConvertToPanorama` 与 `GeneratePanorama`,`object.py` 创建结果,`__init__.py` 导出注册清单。公开入口为 `anyimage.convert_to_panorama`,Job 为 `generate-panorama-geometry`。复用 `common` 的输入暂存、材质、深度加载、修改器赋值和对象收尾函数。
模型、推理级别和分析尺寸从 Preferences 读取,Job 参数为普通字典。Blender 数据只在主线程读取输入和处理响应。新对象中心对应原 Empty 原点,继承其世界变换;局部 +Z 为全景上方,U=0 朝向 +X,U 增加沿 +Y 转动。生成成功后替换源对象并激活结果;失败、取消或源对象已失效时清理本次临时资源。图片显示偏移只属于平面预览,球面方向由完整图片 UV 定义。
### 后端与数据协议
```mermaid
flowchart TD
A[当前 RGBA 全景] --> B[12 个重叠 90 度视图]
B --> C[MoGe2 与已知 FOV 的几何恢复]
C --> D[有效视图的径向距离]
D --> E[横向周期的对数梯度融合]
E --> F[尺度锚定与有限距离场]
A --> G[源 Alpha 对齐到深度尺寸]
F --> H[depth.exr]
G --> H
```
`server/jobs/panorama.py` 负责任务调度,`server/geometry/panorama.py` 负责投影与融合。将已知 FOV 接入已有 ONNX 后处理和模型会话生命周期,普通图片继续使用焦距估计。保留原分辨率颜色;视图分析尺寸遵循输入上限,首版目标视图为 512×512,融合网格最长边上限 512 且保持 2:1。Subdivide 独立控制最终网格密度。
源 Alpha 对齐、黑底模型输入及可见区域排除采用原型已验证的流程。全透明视图跳过推理,部分视图无效时继续融合其余有效观测;全局无有效几何时报错。逐视图及融合前后检查取消,释放模型资源。
| 字段 | 文件 | 内容 |
| --- | --- | --- |
| 颜色文件 | `image.0001.png` | 当前 RGBA,保留源 Alpha |
| `depth` | `depth.exr` | float32 RGB 重复保存径向距离,A 为融合 Mask × 源 Alpha |
| `depth_metadata` | `depth.json` | `projection: equirectangular`、`image_size`、`reference_distance` |
`reference_distance` 为满足 Alpha 阈值的有限正径向距离中位数,用于 Split Threshold 的尺度归一化。全景元数据按投影类型单独读取;复用现有 EXR 写入及 Non-Color、CHANNEL_PACKED、Pack 配置。颜色由材质采样,几何节点只有一个深度图片输入。
### Quad Sphere 与 Split Threshold
新增资产 `O Image Panorama`。Quad Sphere 与球面校正子组的构建定义归入本仓库节点构建模块,参考已检查的 `O Quad Sphere`,运行时从 `O_AnyImage.blend` 加载。面数为 `6 × 4^Subdivide`,默认 Subdivide=7;Depth Scale 默认 1,范围 0–1;Validity Threshold 默认 0.9;Split Threshold 默认 0,范围 0–1。深度图片、材质和参考距离作为数据输入。
```mermaid
flowchart TD
A[可调细分 Quad Sphere] --> B[固定单位射线与球面 UV]
B --> C[按深度 EXR Alpha 剔除无效面]
C --> D[相邻面径向距离变化判断]
D --> E[Split Edges 与条带面清理]
E --> F[分离面角沿原射线取所属面距离]
F --> G[Depth Scale 插值到重建表面]
G --> H[材质与临时属性清理]
```
提取并复用 `tools/nodes/common/depth_surface.py` 中的相邻面采样、内边判断、Split Edges、面角归属和条带面清理。共用逻辑消费明确的深度字段、基础面中心和归一化比例;Depth Plane 的调用同步适配,保持已有求值结果。
对全景,令相邻面距离为 rA/rB,单位球面上的面中心为 cA/cB,参考距离为 R。沿用现有 2.5 系数,以 `abs(rA-rB) / R × Split Threshold > 2.5 × distance(cA,cB)` 选择有两个邻面的内边。阈值为 0 时直接保留通过有效性剔除的原网格;阈值增大时选择更多断层边。
选中断层附近的面角使用其所属面的距离,未选中的顶点保持原采样;所有点沿原始球面射线定位。最终半径为 `1 + Depth Scale × (目标距离 - 1)`。条带面清理遵循当前 Depth Plane 的同一拓扑快照规则。
全景面中心采样由面中心方向重新计算 UV,横向循环采样;材质面角 UV 独立处理经度分支和极点。这样左右接缝与普通内边参与同样的判断。分裂后保留原射线及面角 UV,末尾清理专用临时属性。
### 验证与交付
正式模块从原型迁移有效算法及测试,运行时依赖正式模块与打包资产。此次实现运行相关单元测试、完整回归测试和独立 Blender 求值检查。节点资产更新走现有离线资产流程;扩展包及文档构建属于另行发布操作。
## Risks / Trade-offs
- 径向距离与平面 Z 深度的比较尺度不同 → 用参考距离归一化,并测试相同形状整体缩放后的分裂选择一致。
- 全景接缝和极点的 UV 有多种等价表示 → 用方向生成采样坐标,并验证接缝两侧的等价深度断层。
- 共享分裂代码近期增加了条带清理 → 保留现有 Depth Plane 回归结果,并覆盖全景分裂后孤立条带、Alpha 边缘和连续斜面。
- 天空及遮挡导致预测缺失 → 以当前源图 Alpha 和融合 Mask 确定有效几何,允许通过现有 Remove Background 预先处理。

View File

@@ -0,0 +1,25 @@
## Why
全景原型已验证 Quad Sphere、径向深度和单张深度 EXR 的可行性。现在需要把生成流程接入 Image Empty 的常用入口,并通过 Depth Plane 的 Split Threshold 机制处理前后景连接处的拉伸。
## What Changes
- 在 Image Empty 的 AnyImage 右键菜单增加 `Convert to Panorama`,通过后台 Job 将静态 2:1 全景转换成可编辑的全景表面。
- 将原型的多视图 MoGe2 推理、距离融合和深度 Alpha 导出接入正式 Server。
- 新增 `O Image Panorama` 节点资产,提供 Quad Sphere 细分、Depth Scale、Validity Threshold 和 Split Threshold。
- 复用 Depth Plane 的分边、面角深度重定位和条带面清理机制,按全景径向距离适配判断。
- 延续单深度图片输入:`depth.exr` 的 Alpha 在导出时合成模型 Mask 与当前源图 Alpha。
## Capabilities
### New Capabilities
- `panorama-conversion`: Image Empty 菜单、异步转换、对象替换与撤销。
- `panorama-geometry-generation`: 全景多视图推理、融合与文件产物。
- `panorama-surface`: Quad Sphere、球面 UV、深度有效性剔除与 Split Threshold。
### Modified Capabilities
## Impact
涉及 `menu.py`、类型注册、新增 `operators/convert_to_panorama/`、Server Job 与全景几何模块、共享深度表面节点构建函数,以及对应测试。生产节点组由资产构建流程写入 `O_AnyImage.blend`,运行时按名称加载。使用现有 MoGe2、ONNX Runtime、NumPy 与 SciPy 环境。

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Image Empty panorama conversion entry
系统 SHALL 在 Image Empty 的 AnyImage 右键菜单展示 `Convert to Panorama`,调用 `anyimage.convert_to_panorama`。
#### Scenario: Select panorama conversion
- **WHEN** 用户选中静态 2:1 Image Empty 并选择 Convert to Panorama
- **THEN** 提交当前图片及 Alpha 的全景生成任务,使用 Preferences 中的模型、推理级别和分析尺寸
#### Scenario: Unsupported image
- **WHEN** 输入是动画、视频或非 2:1 图片
- **THEN** 提示输入限制并保留源对象
### Requirement: Consume current image alpha
转换 SHALL 直接使用当前源图及 Alpha。背景处理由用户通过现有 Remove Background 完成。
#### Scenario: Already removed background
- **WHEN** 源 Empty 的天空已透明
- **THEN** 当前 Alpha 进入全景任务,转换过程中只运行几何模型
### Requirement: Complete conversion transaction
系统 SHALL 在主线程加载结果、打包贴图并创建节点对象,成功后替换源 Empty,并提供完整撤销。
#### Scenario: Successful conversion
- **WHEN** Job 返回有效产物
- **THEN** 创建使用 O Image Panorama 的对象,继承源 Empty 世界变换并以其原点为拍摄中心,激活结果并支持撤销恢复源对象
#### Scenario: Cancelled or failed conversion
- **WHEN** 任务失败、取消或响应时源对象已失效
- **THEN** 清理本任务暂存及未使用数据块,不应用部分转换结果

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Generate panoramic radial distance
Server SHALL 从当前 RGBA 全景生成 12 个重叠的 90 度透视视图,使用已知 FOV 恢复 MoGe2 几何并融合为横向周期的径向距离场。
#### Scenario: Continuous panorama
- **WHEN** 输入包含跨越 U=0/1 的连续表面
- **THEN** 两侧使用同一周期求解,产出有限正距离且无额外拼接断层
#### Scenario: Invalid views and cancellation
- **WHEN** 部分视图全透明或模型无有效预测
- **THEN** 利用其余有效观测完成融合,全局无有效几何时返回明确错误,并在视图与融合阶段响应取消
### Requirement: Export combined depth alpha
系统 SHALL 返回颜色文件、`depth.exr` 与 `depth.json`。深度 RGB 保存径向距离,Alpha 在导出时一次合成融合 Mask × 源 Alpha;元数据 SHALL 标明等距柱状投影、深度图尺寸和参考距离。
#### Scenario: Transparent background
- **WHEN** 源图某区域 Alpha 为零
- **THEN** 该区域深度 EXR Alpha 为零,颜色文件保留源 Alpha
#### Scenario: Fractional alpha
- **WHEN** 有效模型 Mask 为 1 且源 Alpha 为 0.5
- **THEN** 深度导出 Alpha 为 0.5,几何端直接消费该值
### Requirement: Use managed production runtime
全景生成 SHALL 使用现有模型会话、Preferences 配置和 Job 生命周期;Job 参数可序列化,Server 与 Blender 数据保持进程边界。
#### Scenario: Configured inference
- **WHEN** 用户修改模型或推理分辨率级别后发起转换
- **THEN** 新任务采用配置值,最终网格细分仍由独立 Subdivide 参数控制

View File

@@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Editable Quad Sphere surface
系统 SHALL 提供资产 `O Image Panorama`,使用全四边面 Quad Sphere,并提供 Subdivide、Depth Scale、Validity Threshold 和 Split Threshold。几何节点 SHALL 只有一个深度图片输入。
#### Scenario: Sphere and depth endpoints
- **WHEN** 所有样本有效且 Split Threshold 为 0
- **THEN** Subdivide=n 产生 6×4^n 个四边面,Depth Scale=0 为单位球,Depth Scale=1 为预测径向距离表面
#### Scenario: Validity culling
- **WHEN** 深度 EXR Alpha 低于 Validity Threshold
- **THEN** 按 Depth Plane 的点域布尔字段与面域删除方式剔除对应区域
### Requirement: Depth Plane split semantics
全景 SHALL 复用 Depth Plane 的相邻面深度差判断、内边分裂、面角深度重定位和条带面清理机制,并使用参考距离归一化的径向距离适配球面。
#### Scenario: Disabled splitting
- **WHEN** Split Threshold 为 0
- **THEN** 分裂与条带清理完全旁路,有效性剔除保持生效
#### Scenario: Foreground depth discontinuity
- **WHEN** 相邻有效面的归一化距离跳变达到分裂条件
- **THEN** 分离选中的内边,断层附近面角沿原球面射线使用所属面的距离,未选中顶点保持原始深度采样
#### Scenario: Threshold and scale consistency
- **WHEN** 增大 Split Threshold 或对所有距离与参考距离同时乘以相同正比例
- **THEN** 增大阈值不会减少被选中的断层边,等比例距离变化不会改变断层边选择
#### Scenario: Seam and strip cleanup
- **WHEN** 深度断层跨越全景经度接缝或分裂产生孤立条带
- **THEN** 经度接缝采用周期采样,面角 UV 保持正确,条带面按 Depth Plane 的同一拓扑快照规则清理
### Requirement: Packaged node assets
生产节点组及 Quad Sphere 子组 SHALL 包含在 `O_AnyImage.blend` 中,通过现有加载入口使用。
#### Scenario: Reopen converted object
- **WHEN** 在新 Blender 进程重新打开转换后的文件
- **THEN** 无需访问原型目录或外部参考库即可求值,贴图、细分、Depth Scale 和 Split Threshold 均正常工作

View File

@@ -0,0 +1,24 @@
## 1. 全景后端
- [x] 1.1 将原型的投影、周期距离融合和参考距离计算迁移到正式 Server 模块,保留解析几何测试
- [x] 1.2 为现有 MoGe2 ONNX 推理增加已知 FOV 的几何恢复,复用模型会话并验证普通图片回归
- [x] 1.3 实现 generate-panorama-geometry Job、尺寸和有效性检查、取消与清理,导出当前颜色、深度及全景元数据
- [x] 1.4 验证源 Alpha 与 Mask 仅在深度导出合成一次,覆盖透明视图、全局无有效数据和跨经度接缝
## 2. 全景节点资产
- [x] 2.1 在本仓库实现 Quad Sphere 及校正子组的资产构建,创建 O Image Panorama 的细分、球面 UV 和 Depth Scale
- [x] 2.2 复用深度 EXR 加载与 Alpha 剔除,保持几何节点单深度图片输入
- [x] 2.3 提取共享 Split Threshold 机制并同步适配现有调用,将径向距离判断、面角重定位和条带清理接入全景
- [x] 2.4 覆盖阈值旁路、断层强度、尺度不变性、连续斜面、经度接缝、极点及条带清理,并运行现有深度表面回归
## 3. 菜单与对象转换
- [x] 3.1 实现 ConvertToPanorama 与 GeneratePanorama,接入 Preferences、输入暂存、Job 参数与注册清单
- [x] 3.2 在 Image Empty 的 AnyImage 菜单添加 Convert to Panorama,验证静态 2:1 输入限制及当前 Alpha 传递
- [x] 3.3 实现主线程结果加载、材质、全景对象和修改器,覆盖源变换继承、替换、撤销及失败清理
## 4. 集成验证
- [x] 4.1 通过既有离线节点资产流程更新 O_AnyImage.blend,验证新进程加载和打包后独立求值
- [x] 4.2 使用已去天空的真实全景与解析样本检查正式菜单流程和 Split Threshold,运行完整测试并检查最终差异

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-08

View File

@@ -0,0 +1,74 @@
## Context
当前 `onnx_upscale.py` 用 256 像素 tile 执行三个 4× 模型,`models/upscale.py` 负责输入尺寸限制和 alpha 回填,`ModelManager` 缓存单个 Upscale Session。Operator 的倍率和提示均为 4×。
本地实验位于 `build/upscale-explore/pisa-research/`。已验证固定 512×512 的 PiSA-SR FP16 ONNX 全流程在 DirectML 上出图,0.7 预设对应像素强度 1.0、语义强度 0.7、空提示词和 seed 42。两套 UNet 常驻时,512 输出约 1.57–2.37 秒;默认强度 1.0 的单 UNet 速度不代表 0.7。分块和跨设备部署尚待验证。
## Goals / Non-Goals
**Goals:** 四个选项统一 2× 输出;复现已选定的 PiSA-SR 0.7 观感;通过已有 ONNX 运行时服务图片和逐帧任务。
**Non-Goals:** 本次范围限定为固定预设与既有 Upscale 操作,强度滑杆、提示词编辑和视频时序增强属于独立能力。
## Decisions
### 1. 最终倍率与模型原生倍率分别定义
```mermaid
flowchart TD
A[读取并限制输入尺寸] --> B{模型}
B -->|现有三个模型| C[原生 4× 分块推理]
C --> D[Lanczos 缩至输入宽高的 2 倍]
B -->|PiSA-SR| E[Bicubic 放大至 2×]
E --> F[VAE 编码与固定噪声采样]
F --> G[像素与完整 UNet 预测]
G --> H[按语义强度 0.7 组合并解码]
H --> I[AdaIN 颜色校正与裁切]
D --> J[按最终尺寸回填原始 alpha]
I --> J
J --> K[保存结果并替换图片]
```
公共结果倍率为 2,旧 ONNX 图的 shape 校验仍为原生 4。先实现完整 4× 拼接后一次 Lanczos 缩小,作为明确的像素基线;避免先逐 tile 缩小引入新的滤波接缝。最终尺寸相对于 `limit_image()` 之后的输入,沿用 Operator 对输入长边的既有限制。
保留三个模型的 key、原生名称和默认选择,说明文字明确最终输出 2×。新增 key `PISA_SR`、显示名 `PiSA-SR`,说明固定细节强度 0.7;生产和 Debug 复用相同输出入口。
### 2. 固定 0.7 的定义与实验一致
使用同一采样 latent 计算 `pixel + 0.7 * (full - pixel)`,再按已验证的公式解码。空提示词 embedding 与 timestep 固定在资产中,seed 固定为 42;采样算法、精度和噪声布局通过测试锁定。保留实验中的 Bicubic 输入插值、VAE scaling、输出量化和 AdaIN 颜色校正。
该强度是在预测结果上插值。将 LoRA 权重乘以 0.7 或插值两套 UNet 权重,不能视为等价的单 UNet 优化;任何这样的优化必须另行通过效果对照。
### 3. PiSA-SR 使用独立适配器与多文件资产
新增 `server/models/onnx_pisa_sr.py` 承担 VAE、UNet、采样及分块。`ModelManager` 按模型类型创建单 Session 或 PiSA-SR 管线,并统一缓存 key、释放和状态展示。Session 创建和运行复用 `onnx_runtime.py`,补齐 DirectML 所需的 sequential execution 与禁用 memory pattern 配置。
资产以 encoder、decoder、pixel UNet、full UNet 为明确文件集合,通过既有 catalog 下载与完整性校验。实验包约 3.63 GB,发布清单必须使用最终导出文件的尺寸和 SHA256。导出工具固定 PiSA 源码 commit、基础模型 revision、LoRA 校验值和导出环境;训练依赖仅用于离线工具。实验目录仅作为参考,不作为运行时路径。
### 4. 分阶段执行限制模型常驻
优先采用按阶段处理全部 tile 的调度:编码并把 latent 存入主机内存,运行像素 UNet 保存预测,释放该 Session 后运行完整 UNet并组合,最后解码。GPU 上同一时刻只常驻一套 UNet;切换在整图阶段边界发生。大图中间数组可落入任务临时目录,结束或取消后清理。
此方案以降低显存峰值为优先,代价是阶段加载成本;实施时同时测冷启动、连续图片和多帧耗时。任务结束按实际 Session 最后使用点释放临时推理内存,资源错误使整个 PiSA 管线缓存失效,Clear Models 释放全部组件。
### 5. 固定尺寸 ONNX 通过重叠分块覆盖任意输入
以已验证的 512×512 输出 tile 为初始部署尺寸,采用重叠区域加权融合,边缘填充后裁回精确的 2× 尺寸。噪声按整图 latent 坐标生成并切片,重叠区域共享噪声;AdaIN 在拼接完成后按整图执行。单 tile 情况保持实验基线,多 tile 的重叠宽度通过接缝对照确定并固化为内部常量。
alpha 从受尺寸限制的源图直接 Lanczos 放大到最终 2×,独立于生成模型;透明 RGB 的处理沿用现有输入语义,并加入半透明边缘目视验收。多帧逐帧处理并复用固定噪声规则,支持取消;生成纹理的时序稳定性列入验收观察。
## Risks / Trade-offs
- [分块改变生成纹理或形成接缝] → 对两张实验样图及跨 tile 的纹理、渐变、透明边缘做完整图与局部对照;接缝未通过时继续调整分块方案。
- [分阶段 Session 加载拖慢连续任务] → 记录端到端冷暖耗时、峰值显存和主机内存;不将单 UNet 默认模式的数字作为 0.7 性能承诺。
- [FP16 图在 CPU/CoreML 上兼容性未知] → 实测项目支持的设备路径;需要时导出匹配的精度资产,并在 catalog 中表达准确支持范围。不可运行的设备返回明确原因。
- [新细节改变原始图案或引起逐帧闪动] → 保留已有模型选择,验收脸部、文字、纹理和短序列样例,界面说明生成型细节补充。
- [多 GB 权重分发与依赖来源] → 发布前核对基础模型和 PiSA 权重的分发条款,登记固定来源及最终文件校验信息。
## Migration Plan
先准备与验证 PiSA 资产及分块管线,再接入 catalog、模型管理器和界面,统一最终 2× 结果并运行测试。现有用户保存的模型 key 继续有效,后续执行采用 2×;已有图片产物无需迁移。回退时同步回退模型选项和推理入口,已下载资产可由已有缓存管理处理。
## Open Questions
实施实验需确定分块重叠宽度、阶段加载的可接受耗时,以及 CPU/CoreML 的实际精度与支持范围。这些验证不改变 0.7 和最终 2× 的产品约定。

Some files were not shown because too many files have changed in this diff Show More