Compare commits
91 Commits
fb78a8068b
...
archive
| Author | SHA1 | Date | |
|---|---|---|---|
| 9debab5c0c | |||
| 2b3c0194a6 | |||
| 75f6692f28 | |||
| 1e5fa4e6ba | |||
| 580fe84275 | |||
| 122f609156 | |||
| 4a3981a1e7 | |||
| 531f7a4dfb | |||
| 0e0287d340 | |||
| 076a8c90f1 | |||
| 2811c7bf3a | |||
| 0aeb61f818 | |||
| ac7f5a7f6d | |||
| efd117dd9b | |||
| c681d51fd1 | |||
| 78bbff6351 | |||
| c62ec2cd48 | |||
| 7f69630371 | |||
| 6ca084ee4a | |||
| 7f878a2f3f | |||
| cf512dc013 | |||
| 474fc10f0f | |||
| 2dfa9b2add | |||
| 94130ff38f | |||
| 4035f5c9e4 | |||
| 591e62085a | |||
| ab282e4fdf | |||
| dc000e50b1 | |||
| 09778c4b48 | |||
| 1bf858b17e | |||
| a0dd6e4a88 | |||
| 2a00ed0ee6 | |||
| 18799e8828 | |||
| 938d95db0f | |||
| e8268504c2 | |||
| 5b2dc59f79 | |||
| dd32ccdc44 | |||
| 31074bd511 | |||
| e8129fe495 | |||
| 8ae3c1d5b7 | |||
| 489003c2cc | |||
| 27ca25224a | |||
| 1622afa7a2 | |||
| f48bbb3d05 | |||
| 89087fc2e2 | |||
| f5aff50b13 | |||
| 4c902ed7db | |||
| 196b964efc | |||
| 7dec3b7ca4 | |||
| a0adf79db0 | |||
| 7e55af6722 | |||
| 1994d38ccd | |||
| bbaa4bff46 | |||
| bdbde1a10d | |||
| a6041cbe03 | |||
| 8d279e9ab3 | |||
| 5a011b3b57 | |||
| 7f6e47432e | |||
| 2ff7b04f56 | |||
| 4d4b892d64 | |||
| c97b4a3e41 | |||
| 14d63001d2 | |||
| e7aeeb21d4 | |||
| 377eee43f5 | |||
| 87d7721128 | |||
| 08309f719d | |||
| 32bcd45d7b | |||
| 7d39fe8c5e | |||
| 6c8358f92f | |||
| 2b79a342b0 | |||
| 0cf04a13c6 | |||
| 1613bd3194 | |||
| f42cba1932 | |||
| 988332548e | |||
| af5603045c | |||
| b5a2f9690c | |||
| 8afcf48ddf | |||
| 4e1231790b | |||
| 13ef705226 | |||
| ee46d98b93 | |||
| 9bb603b532 | |||
| 8c037fc8a9 | |||
| 9fb0d010e3 | |||
| 4a69d162a0 | |||
| 70072b852a | |||
| 753f87da03 | |||
| c115498b5e | |||
| 20b653bb30 | |||
| 835225d264 | |||
| 9dfa49602c | |||
| 6e6dd60a88 |
156
.codex/skills/openspec-apply-change/SKILL.md
Normal file
156
.codex/skills/openspec-apply-change/SKILL.md
Normal 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
|
||||
114
.codex/skills/openspec-archive-change/SKILL.md
Normal file
114
.codex/skills/openspec-archive-change/SKILL.md
Normal 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
|
||||
288
.codex/skills/openspec-explore/SKILL.md
Normal file
288
.codex/skills/openspec-explore/SKILL.md
Normal 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
|
||||
110
.codex/skills/openspec-propose/SKILL.md
Normal file
110
.codex/skills/openspec-propose/SKILL.md
Normal 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
|
||||
38
.github/workflows/build.yml
vendored
38
.github/workflows/build.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: 构建扩展
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: 安装测试依赖
|
||||
run: python -m pip install numpy pytest
|
||||
|
||||
- name: 运行测试
|
||||
run: python -m pytest
|
||||
|
||||
- name: 构建扩展
|
||||
run: python pack.py
|
||||
|
||||
- name: 上传扩展包
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: SnapForm
|
||||
path: dist/*.zip
|
||||
if-no-files-found: error
|
||||
73
.github/workflows/release.yml
vendored
Normal file
73
.github/workflows/release.yml
vendored
Normal 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
29
.github/workflows/test.yml
vendored
Normal 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
|
||||
404
.gitignore
vendored
404
.gitignore
vendored
@@ -1,11 +1,395 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.uv-cache/
|
||||
dist/
|
||||
build/
|
||||
*.zip
|
||||
.pytest_cache/
|
||||
# Tempary files
|
||||
temp
|
||||
sandbox.*
|
||||
/notes/
|
||||
|
||||
/models/
|
||||
wheels/
|
||||
# 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
140
AGENTS.md
140
AGENTS.md
@@ -1,81 +1,111 @@
|
||||
# SnapForm 开发约定
|
||||
# AnyImage 项目规范
|
||||
|
||||
## 总则
|
||||
|
||||
- 说明性内容(提交说明、文档、注释、Mermaid 等)以中文为主,专有名词和约定俗成的表达可使用英文
|
||||
- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/development.md#程序设计`
|
||||
- 修改代码、依赖或执行方式后,同步更新 `README.md` 与对应主题文档,保持文档、测试和实现一致
|
||||
- 优先选择依赖少、直接、易验证的实现;保留工作区中与当前任务无关的已有改动
|
||||
**不管是代码还是文档,遵循「少就是多」的原则,以好理解为首要要求**
|
||||
|
||||
## 项目结构
|
||||
- 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文
|
||||
- 修改代码、依赖或执行方式后,
|
||||
- 不保留旧路径、转发函数、兼容层或胶水代码
|
||||
- 仅更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展
|
||||
- 优先选择依赖少、直接、易验证的实现
|
||||
- 复用来自 `common` 的函数,避免重复造轮子
|
||||
- 确保文件名/文件夹名和内容边界一致
|
||||
|
||||
```text
|
||||
.
|
||||
├── README.md # 用户入口与快速上手
|
||||
├── docs/ # 按功能拆分的设计与开发文档
|
||||
├── pack.py # 构建各平台 Blender Extension ZIP
|
||||
├── sync_models.py # 准备并同步 ONNX 模型
|
||||
├── src/snapform/
|
||||
│ ├── __init__.py # Blender 注册入口
|
||||
│ ├── operators.py # Job Operator 与结果导入
|
||||
│ ├── panel.py # 3D View 面板
|
||||
│ ├── preferences.py # Extension Preferences
|
||||
│ ├── properties.py # Scene 设置与动态枚举
|
||||
│ ├── job_runtime.py # BlendJob Runtime 配置
|
||||
│ ├── blender/ # Depth Plane、Surface 的 Blender 侧实现
|
||||
│ ├── server/ # Job、模型、ONNX 推理与输出实现
|
||||
│ ├── assets/O_SnapForm.blend # Geometry Nodes 与材质资产
|
||||
│ └── wheels/ # 随扩展分发的 BlendJob wheel
|
||||
├── tools/ # Blender 资产构建与验证脚本
|
||||
└── tests/ # Python 自动化测试
|
||||
```
|
||||
## 全面整理代码
|
||||
|
||||
文档按主题定位:功能与使用 `docs/features.md`、Depth Plane `docs/depth-plane.md`、Surface `docs/surface.md`、输出格式 `docs/outputs.md`、Job Server `docs/server.md`、程序设计与开发 `docs/development.md`。
|
||||
当用户说「全面整理代码」时,依据本文件规范,直接执行以下整理:
|
||||
|
||||
- 按业务职责梳理代码结构与命名;简单功能用单文件,复杂 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):节点组的构建与验证(节点组不在运行时新建)
|
||||
|
||||
## 常见命令
|
||||
|
||||
优先在项目根目录使用 Git Bash;Windows 路径在 Bash 命令中使用正斜杠。
|
||||
|
||||
```bash
|
||||
# 安装或同步开发依赖
|
||||
uv sync
|
||||
|
||||
# 运行全部测试
|
||||
uv run pytest
|
||||
|
||||
# 运行插件注册、Manifest 与打包相关的快速测试
|
||||
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
|
||||
|
||||
# 构建 Windows、macOS、Linux 扩展包到 dist/
|
||||
uv run python pack.py
|
||||
|
||||
# 本地 BlendJob wheel 内容变化后刷新锁文件
|
||||
uv lock --refresh
|
||||
```
|
||||
|
||||
资产脚本必须由 Blender 执行;具体入口与验证范围见 `docs/development.md#资产构建与验证`,不要用普通 Python 解释器运行。
|
||||
# 运行全部测试
|
||||
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 使用英文;同一概念在代码和文档中使用统一表达
|
||||
- Python 类使用大驼峰,不添加项目名前缀。Operator 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground`;Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `SnapFormImageMenu`、`ServerPanel`、`SnapFormPreferences`、`SnapFormSettings`
|
||||
- `bl_idname` 与挂载到公共 Blender RNA 类型的 Property 使用 `snapform` 项目前缀:Operator 如 `snapform.convert_to_depth_plane`,Menu 如 `SNAPFORM_MT_image_context`,Panel 如 `SNAPFORM_PT_server`,公共 Property 如 `Scene.snapform_settings`
|
||||
- 变量、函数、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 中的真实名称、版本和平台,例如 `SnapForm.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称
|
||||
- 扩展包名使用 Manifest 中的真实名称、版本和平台,例如 `AnyImage.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称
|
||||
|
||||
## 代码规范
|
||||
## 文档规范
|
||||
|
||||
- 避免不必要的嵌套,优先使用提前返回和小而明确的函数
|
||||
- 只为特殊、非常规或存在兼容性原因的实现写注释,不复述代码本身
|
||||
- 删除功能时同时删除无效参数、分支、测试和文档,不保留没有实际消费者的兼容层
|
||||
- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/snapform/` 或 `src/snapform/blender/`,模型下载、ONNX 推理和输出放在 `src/snapform/server/`
|
||||
- `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/snapform/__init__.py` 的注册与逆序注销,并补充对应测试
|
||||
- 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试
|
||||
- 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试
|
||||
- `server/` 是 blendjob 后端代码,无法引用来自它上级文件夹的内容。共享函数需单独实现。
|
||||
|
||||
## 文档与 Markdown 规范
|
||||
## Blender 节点组规范
|
||||
|
||||
- `README.md` 面向用户;实现原理放入 `docs/` 对应主题,不在 README 堆叠内部细节
|
||||
- 不使用 `---` 分隔线;一级标题只在文档开头使用一次
|
||||
- Mermaid 节点 ID 使用英文字符,节点显示文本可以使用中文
|
||||
- 文档描述当前已实现行为;限制、默认值、命令和文件名必须能从代码或测试中验证
|
||||
- 节点组源码位于顶层 `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)。
|
||||
|
||||
107
README.md
107
README.md
@@ -1,101 +1,12 @@
|
||||
# SnapForm
|
||||
# AnyImage
|
||||
|
||||
SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一组序列帧快速变成有真实深度的立体面片。
|
||||
把图片里的空间感带进 Blender。
|
||||
|
||||
## 它能做什么
|
||||
AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。
|
||||
|
||||
- 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度
|
||||
- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset
|
||||
- 一键去除图片背景,生成透明前景图
|
||||
- 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片
|
||||
- 全部操作都在右键菜单里完成,不需要写任何代码
|
||||
|
||||
## 安装
|
||||
|
||||
1. 下载对应系统的扩展压缩包
|
||||
2. 打开 Blender,进入 `Preferences > Extensions`
|
||||
3. 点击 `Install from Disk`,选择下载的压缩包
|
||||
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
|
||||
最快的 DirectML/CoreML provider,否则 CPU。下载超时会自动重试国内镜像,无需手动配置
|
||||
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Environment`
|
||||
|
||||
扩展不内置 uv 或 AI 运行库。首次使用会先下载并校验固定版本的 uv,随后自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和
|
||||
`BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在
|
||||
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
安装时 Environment、MoGe-2 和 BEN2 各自使用独立的 0–100% 进度;Environment 这一轮内部依次完成 uv、Python 环境和 packages。进入后续模型时进度归零,但不显示 `2/2` 等阶段编号。模型源发生瞬时错误时会切换到另一来源继续下载。
|
||||
任一必需模型在两个来源都下载失败时,`Install Environment` 会明确报错;Environment 本身仍会保留。
|
||||
选中 Image Empty 后,右键 `SnapForm` 子菜单会在缺少 `MoGe-2 ViT-S Normal` 或 `BEN2 Base`
|
||||
时显示 `Download Required Models`,一次补齐缺失项,不会重复下载已经就绪的模型。
|
||||
必需模型补齐前,菜单中的 `Convert to Depth Plane` 会保持禁用。
|
||||
这些 UI 状态直接检查 Storage Root 的 `models/` 文件,不会仅因打开菜单或面板而启动 Job Server。
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 转换为立体面片
|
||||
|
||||
1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image)
|
||||
2. 右键打开 `SnapForm` 子菜单,选择 `Convert to Depth Plane`
|
||||
3. 在对话框中选择:
|
||||
- `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 生成 metric depth、normal 与有效区域 mask
|
||||
- `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数
|
||||
- `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景
|
||||
- `Unshaded` 保留为直接显示贴图颜色的选项
|
||||
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
|
||||
4. 点击确认,稍等片刻就得到带位移的立体面片
|
||||
|
||||
### 去除背景
|
||||
|
||||
选中 Image Empty 后,右键打开 `SnapForm` 子菜单并选择 `Remove Background`,完成后 Empty 会自动
|
||||
换成透明前景图。该操作在子菜单中使用独立分割区域。
|
||||
|
||||
### 转换为透视网格
|
||||
|
||||
选中单张图片的 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Convert to Surface`。可选择 MoGe-2
|
||||
ViT-S、ViT-B、ViT-L 或 DA3 Small、Base,其中 MoGe-2 ViT-S 为默认模型。SnapForm 会按模型输出的
|
||||
相机内参与三维点建立透视网格,并匹配原 Empty 的可见尺寸、Offset 与 Transform。MoGe-2 使用内置 NumPy
|
||||
网格构建与深度断层 mask 生成干净拓扑;DA3 保留 confidence 与边拉伸剔除。两者再由独立的 Geometry Nodes
|
||||
修改器完成剔面、快速重拓扑、Relax 与 UV 重投。开启 Remove Background 时,背景三角面会在转换期间
|
||||
直接删除,不再交给 Geometry Nodes。匹配相机默认不创建;开启后会作为 Surface 的子级。
|
||||
MoGe-2 的像素级 normal 会作为 Object Space normal 贴图接入 `O MoGe2 Surface Layer`,并与原图
|
||||
Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。
|
||||
当前此功能仅支持单张图片。
|
||||
|
||||
MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base 与 BEN2 全部使用 ONNX。模型执行、相机与深度恢复、
|
||||
点云重投影和网格生成均不依赖 PyTorch。Windows 使用 DirectML 加速,provider 不可用时会回退到 ONNX Runtime CPU,
|
||||
并在任务状态中显示实际 provider。
|
||||
|
||||
转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Unshaded`;DA3 显示
|
||||
`Resolution Scale`,MoGe-2 显示 `Resolution Level`,
|
||||
以及分割线下方的 `Keep Original`。
|
||||
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
|
||||
Geometry Nodes 修改器中调整;`Unshaded` 可使用与 Depth Plane 相同的 Emission 材质。普通材质的
|
||||
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface attributes 统一使用 `o_` 前缀;Server 准备过程显示
|
||||
`Preparing surface data n/6`,可看到当前阶段与总步骤。
|
||||
|
||||
### 视频与序列帧
|
||||
|
||||
SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。处理范围默认
|
||||
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
|
||||
Session 逐帧推理,显存峰值不随视频帧数平方增长;Debug 多帧输出仍使用多视图联合推理。
|
||||
|
||||
## 任务结果保存在哪里
|
||||
|
||||
任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.snapform/jobs/`。Job Server 接受任务时自动生成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。视频或序列帧生成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 从 0.12.0–0.12.2 升级到 0.12.3 后,请重新执行一次 `Install Environment`,让 Storage Root 中的 Environment 更新 BlendJob 运行依赖
|
||||
- 首次使用需要下载模型,如果下载慢或失败,可在 Image Empty 的 `SnapForm` 右键菜单点击
|
||||
`Download Required Models`,也可在 `Models` panel 里逐项重试
|
||||
- 安装 Environment 时 uv 下载失败会自动尝试 OmooLab 镜像;Python packages 需要指定镜像时设置
|
||||
`UV_INDEX_URL` 后重新安装
|
||||
- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源
|
||||
- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长
|
||||
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
|
||||
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧
|
||||
|
||||
## 技术文档
|
||||
|
||||
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。详细功能见
|
||||
[文档索引](docs/index.md),模块边界、调用流程、项目结构与开发命令见
|
||||
[程序设计、开发与许可证](docs/development.md)。
|
||||
- 按 **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
BIN
docs/assets/favicon.png
LFS
Normal file
Binary file not shown.
BIN
docs/assets/fonts/fontutti.woff2
LFS
Normal file
BIN
docs/assets/fonts/fontutti.woff2
LFS
Normal file
Binary file not shown.
BIN
docs/assets/logo.png
LFS
Normal file
BIN
docs/assets/logo.png
LFS
Normal file
Binary file not shown.
@@ -1,67 +0,0 @@
|
||||
# Depth Plane
|
||||
|
||||
## Image Empty 转深度面片
|
||||
|
||||
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
|
||||
2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源:
|
||||
- `DA3`:显示 Model 和 Resolution Scale。ONNX 模型使用 camera decoder 恢复相机。Process Res 先以原图长边乘以
|
||||
Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比
|
||||
显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。
|
||||
转换导出 `mini_npz`,使用前需要在
|
||||
`Models` panel 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
||||
继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到
|
||||
`Models` panel 下载。
|
||||
- `MoGe-2`:使用所选模型输出 metric depth、normal、validity mask 与 normalized intrinsics。Depth Plane 不保存体积较大的 points;Surface 会在 AI 进程内用内置 NumPy 网格构建生成 `mesh.npz`。Debug 始终保留原始 `results.npz`,并按 Output Format 单独生成 Maps、纹理 GLB 或彩色 PLY。
|
||||
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
|
||||
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
|
||||
`MoGe-2 ViT-S` 是默认深度来源。选择 `DA3` 时,其 Model 与 Resolution Scale
|
||||
参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠;
|
||||
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
|
||||
`BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立
|
||||
Mask,不在 Server 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
|
||||
因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。
|
||||
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
|
||||
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
|
||||
DA3 会复用同一个 ONNX Session 对每帧独立推理,再合并为原有序列输出,避免多帧 Attention 的平方级显存占用。
|
||||
该行为只用于 Depth Plane 转换;Surface 仅支持单张图片,Debug 多帧输出仍联合推理。
|
||||
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
|
||||
一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度,
|
||||
`foreground.0001.png` 只提供前景 Alpha。
|
||||
DA3 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下;MoGe-2 分别使用 `moge_depth`、`moge_mask` 与 `moge_normal` EXR 序列。
|
||||
`Unshaded`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。
|
||||
3. DA3 或 MoGe-2 完成后会自动创建面片。DA3 面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
||||
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。Original 模式直接使用源 Empty
|
||||
的可见尺寸、Offset 与 Transform。DA3 会把用于匹配画面尺寸的缩放预乘到 `.depth` 数值,
|
||||
XY 缩放烘焙到 Mesh,Z 不参与平面匹配;最终对象 Scale 为 `(1, 1, 1)`,视觉深度与旧逻辑一致。
|
||||
关闭 `Keep Original` 时,成功生成后删除源 Empty。生成面片 Object 使用源 Empty 的原名;开启
|
||||
`Keep Original` 时不处理
|
||||
同名冲突,由 Blender 自动为生成面片追加编号。
|
||||
4. Blender 只创建保留图像纵横比的最低密度基础网格,再把 `O DA3 Depth Plane` 作为 Geometry Nodes
|
||||
修改器添加到对象。节点组与 `O DA3 Surface` 一同保存在 `O_SnapForm.blend`,关闭
|
||||
`show_group_selector`,不为每个对象复制节点组。MoGe-2 使用单独的 `O MoGe2 Depth Plane`,只保留
|
||||
网格细分、metric depth 位移、mask 删点和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入
|
||||
模型 normal,不携带 DA3 的材质置换与背景深度分支。DA3 修改器公开以下参数:
|
||||
- `Displacement`:位于 Geometry 之后的第一项,菜单包含 `Geometry`(默认)与 `Material`。`Geometry`
|
||||
在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换;
|
||||
- `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`,
|
||||
长边按宽高比计算并四舍五入;例如 16:9 图片在等级 `5` 时生成 `57×32` 个面;
|
||||
- `Background Depth`:默认 `-1`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或
|
||||
材质置换使用该值;是否存在背景只取决于转换所得色彩贴图的 `Alpha > 0.5` 二值结果;
|
||||
- `Depth Scale`:默认 `1`,允许正负值,Structure Type 为 `Single`,同时控制 Geometry 与 Material
|
||||
两条置换路径。
|
||||
Grid 重建后通过隐藏的 Material 输入重新赋予原图材质,并对所有面执行 Shade Smooth。
|
||||
材质使用显式指向 `UVMap` 的 UV Map 节点连接颜色、前景 Mask 与深度 Image Texture,不依赖活动 UV。
|
||||
前景深度先与 `Background Depth` 取 Maximum,再按色彩贴图 Alpha 与背景深度混合,
|
||||
深度分支在混合前使用 `Alpha > 0.5` 转为二值 Mask,避免半透明前景被背景深度拉扯;
|
||||
材质表面 Alpha 同样使用二值 Mask。材质置换使用相同的二值和钳制顺序。
|
||||
节点组还会把 `o_material_displacement` 与 `o_background_depth` 写入点域,材质通过
|
||||
`Geometry` 类型的 Attribute 节点读取。点域会为顶点保存重复值,但能在应用修改器后继续保留参数;
|
||||
相比自适应网格本身,其占用很小。Depth Image 与 Foreground Image 是修改器隐藏数据输入,
|
||||
由导入流程自动设置。
|
||||
视频与序列帧还会为 Depth 与 Foreground 共同写入隐藏的 `Frame`、`Frame Start`、`Frames` 与 `Cyclic`。
|
||||
节点组内部不再使用 Scene Time。只有原 Image Empty 是视频或序列时,导入流程才直接给修改器的 `Frame`
|
||||
socket 添加 Scripted Expression Driver `frame`;静态图保持默认值 `1`,不创建 Driver。导入内容已经
|
||||
是截取后的帧,因此 Offset 在节点组内恒定为 `0`。
|
||||
节点组根据 `Frame` 计算从 `1` 开始的 Image Texture Frame:场景帧与 Start 都为 `1` 时读取图片第 `1` 帧;非循环输入
|
||||
钳制到有效范围;循环输入使用 Floored Modulo,让 `Frame Start` 前后的负、正相对帧都映射到
|
||||
`1…Frames`,因此开始帧之前也会循环播放。Geometry 置换会像材质 Image Texture 一样随场景帧刷新。
|
||||
@@ -1,115 +0,0 @@
|
||||
# 程序设计、开发与许可证
|
||||
|
||||
## 程序设计
|
||||
|
||||
SnapForm 分成 Blender 主进程与独立 Job Server 两侧。Blender 侧只负责界面、任务参数和 `.blend` 数据修改;模型下载、媒体准备、ONNX 推理和文件输出都在 Python 3.12 Environment 中运行。两侧由随扩展分发的 BlendJob wheel 连接,避免把 FastAPI、ONNX Runtime 等计算依赖载入 Blender Python。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User["Image Empty / Debug Panel"] --> Operator["Blender Operator"]
|
||||
Operator --> Runtime["JobRuntime"]
|
||||
Runtime --> Server["JobServer FIFO Queue"]
|
||||
Server --> Handler["SnapForm Job Handler"]
|
||||
Handler --> Model["ModelManager / ONNX"]
|
||||
Handler --> JobFiles["Job Directory"]
|
||||
JobFiles --> Response["Operator response"]
|
||||
Response --> Scene["Blender Scene"]
|
||||
```
|
||||
|
||||
核心边界如下:
|
||||
|
||||
- `src/snapform/__init__.py` 是注册入口,集中注册 Blender 类型、`Scene.snapform_settings`、右键菜单与 Runtime
|
||||
- `panel.py`、`preferences.py`、`properties.py` 只描述 UI 和 Blender 属性,不直接导入 Server 模型实现
|
||||
- `operators.py` 将 Blender 状态转换为普通字典参数;Job 完成后在主线程导入图片、网格、材质和相机
|
||||
- `blender/depth_plane.py` 与 `blender/surface.py` 实现 Image Empty 输入检查、几何构建和资产挂载
|
||||
- `job_runtime.py` 以入口字符串声明独立 Server,并配置 Environment、Storage Root 与首次安装后的推荐模型下载
|
||||
- `server/__init__.py` 创建 `JobServer`、注册公开 Job,并把 `ModelManager` 注册为 Resource
|
||||
- `server/media.py`、`background.py`、`da3.py`、`moge2.py` 负责业务流程;`onnx_*.py` 只处理模型输入、推理和输出恢复
|
||||
- 根目录 `model_catalog.py` 是 Blender UI 与独立 Server 共用的纯数据声明;`server/model_download.py`、`server/model_manager.py` 分别负责下载和 Session 生命周期
|
||||
- `server/geometry.py` 生成 NPZ、预览、点云和纹理网格;所有任务产物写入 Runtime 分配的 opaque-ID Job 目录
|
||||
|
||||
功能细节继续按主题维护:[Depth Plane](depth-plane.md)、[Surface](surface.md)、[输出格式](outputs.md) 和 [Job Server](server.md)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── AGENTS.md
|
||||
├── README.md
|
||||
├── pack.py
|
||||
├── sync_models.py
|
||||
├── docs/
|
||||
├── src/
|
||||
│ └── snapform/
|
||||
│ ├── __init__.py
|
||||
│ ├── blender_manifest.toml
|
||||
│ ├── job_runtime.py
|
||||
│ ├── operators.py
|
||||
│ ├── panel.py
|
||||
│ ├── preferences.py
|
||||
│ ├── properties.py
|
||||
│ ├── assets/
|
||||
│ │ └── O_SnapForm.blend
|
||||
│ ├── blender/
|
||||
│ │ ├── depth_plane.py
|
||||
│ │ └── surface.py
|
||||
│ ├── model_catalog.py
|
||||
│ ├── server/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── background.py
|
||||
│ │ ├── da3.py
|
||||
│ │ ├── geometry.py
|
||||
│ │ ├── media.py
|
||||
│ │ ├── model_download.py
|
||||
│ │ ├── model_manager.py
|
||||
│ │ ├── moge2.py
|
||||
│ │ ├── onnx_ben2.py
|
||||
│ │ ├── onnx_da3.py
|
||||
│ │ ├── onnx_moge2.py
|
||||
│ │ └── onnx_runtime.py
|
||||
│ └── wheels/
|
||||
│ └── blendjob-0.1.6-py3-none-any.whl
|
||||
├── tests/
|
||||
└── tools/
|
||||
```
|
||||
|
||||
## 常见命令
|
||||
|
||||
在项目根目录使用 Git Bash:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest
|
||||
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
|
||||
uv run python pack.py
|
||||
```
|
||||
|
||||
`pack.py` 根据 `blender_manifest.toml` 分别生成 Windows、macOS 和 Linux 扩展包,例如 `dist/SnapForm.v0.12.3.windows-x64.zip`。从 PyPI 下载并更换 `src/snapform/wheels/` 中随扩展分发的 wheel 后,先刷新锁文件再测试:
|
||||
|
||||
```bash
|
||||
uv lock --refresh
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## 资产构建与验证
|
||||
|
||||
`tools/` 脚本依赖 `bpy`,必须由 Blender 执行。设置本机 Blender 可执行文件后,可以重建或验证 `O_SnapForm.blend`:
|
||||
|
||||
```bash
|
||||
BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
|
||||
|
||||
"$BLENDER_BIN" --background --python tools/build_geometry_nodes_asset.py
|
||||
"$BLENDER_BIN" --background --python tools/validate_depth_plane_asset.py
|
||||
"$BLENDER_BIN" --background --python tools/validate_geometry_nodes_asset.py
|
||||
"$BLENDER_BIN" --background --python tools/validate_moge_depth_plane.py
|
||||
"$BLENDER_BIN" --background --python tools/validate_moge_surface.py
|
||||
```
|
||||
|
||||
只改 Python、文档或打包逻辑时不需要重建 `.blend` 资产;修改节点组、材质组或其接口时,应运行相应资产验证脚本,并再次运行完整测试。
|
||||
|
||||
## 上游与许可证
|
||||
|
||||
- Depth Anything 3 代码采用 Apache 2.0
|
||||
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
|
||||
- SnapForm Extension 代码采用 GPL-3.0-or-later
|
||||
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示
|
||||
110
docs/features.md
110
docs/features.md
@@ -1,110 +0,0 @@
|
||||
# 功能与使用
|
||||
|
||||
本文档面向开发者和需要了解内部实现细节的用户,从原 README 迁移而来。普通用户请阅读项目根目录的
|
||||
`README.md`。
|
||||
|
||||
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
|
||||
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
||||
作为首个 AI 能力,在独立 Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录,
|
||||
也可把 Image Empty 转换成带几何与材质置换的立体面片。MoGe-2 作为独立深度来源,保留其 metric depth、normal、mask 与 intrinsics 输出,不复用 DA3 Prediction 后处理。Debug 面板还可运行
|
||||
[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
|
||||
- 在独立 Environment 中通过常驻 Server 运行 ONNX Runtime;MoGe-2、DA3 与 BEN2 的模型执行及后处理完全不使用 PyTorch,避免污染 Blender Python 进程
|
||||
- Server 的深度模型缓存只保留 DA3 或 MoGe-2 其中一个,切换时主动释放前一个;BEN2 使用独立缓存,
|
||||
可与当前深度模型共存
|
||||
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Server;连接由随机端口与 Instance ID 归属当前 Runtime
|
||||
- 常驻 Server 复用 ONNX Runtime session、BEN2 与最近使用的深度模型,连续执行时不再重复加载
|
||||
- 每个 Blender 实例持有自己的 Job Server;`Job Server` 面板显示状态与模型缓存,并提供安装、启动、清理、重启和停止控制
|
||||
- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
|
||||
- 当前仅启用 FP32 DA3 Small 和 Base;它们不随首次安装下载,Giant 1.1 与 Nested Giant Large 1.1 暂停使用
|
||||
- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时
|
||||
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
|
||||
- 视频按顺序截取前若干帧,可设置参与重建的最大帧数
|
||||
- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型
|
||||
- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行
|
||||
- `Debug` 默认折叠,内部的 `DA3`、`MoGe-2` 与 `BEN2` 也分别默认折叠
|
||||
- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮
|
||||
- DA3 与 MoGe-2 Debug 都先选择单一 Output Format,只显示当前格式的专属参数;GLB 与 PLY 不会在一次测试中同时生成
|
||||
- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型
|
||||
- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带
|
||||
`(Missing)`,已下载模型保持原名
|
||||
- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter
|
||||
- `Textured Surface (GLB)` 直接使用 DA3 Prediction 中的深度、置信度和相机参数,把相邻像素
|
||||
组成三角面并写入 `mesh.glb`;每个输入帧生成一张独立纹理网格,不做多视图融合
|
||||
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
|
||||
- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片
|
||||
- 缺少必需的 `MoGe-2 ViT-S Normal` 或 `BEN2 Base` 时,Image Empty 的 `SnapForm`
|
||||
右键子菜单显示 `Download Required Models`;单次任务只下载缺失项,并在补齐前禁用
|
||||
`Convert to Depth Plane`
|
||||
- 菜单、模型面板和转换对话框直接检查 Storage Root 的 `models/` 必需文件,显示模型状态时不会启动 Job Server
|
||||
- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度,
|
||||
`Remove Background` 逐帧抠图,入口保持不变。DA3 转换时每帧独立执行 ONNX 推理并复用模型 Session;
|
||||
Debug 多帧任务继续联合推理,以保留跨视图相机与深度关系
|
||||
- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧);
|
||||
Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧
|
||||
- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片
|
||||
- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整
|
||||
- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景
|
||||
- 完整保留每次任务生成的所有官方文件
|
||||
- 安装和模型下载使用后台子进程;推理提交给常驻 Server,状态栏实时显示阶段、百分比进度条与取消按钮
|
||||
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
|
||||
|
||||
## 使用
|
||||
|
||||
1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、
|
||||
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.12.3.macos-arm64.zip`)。
|
||||
安装包不携带 uv 或 AI 项目源码;首次安装 Environment 时下载固定版本 uv,官方源建连超过 3 秒后回退 OmooLab 镜像,并校验固定 SHA-256。
|
||||
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
|
||||
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
|
||||
(Image Empty 右键菜单)使用的推理设备,默认取本机最快的 DirectML/CoreML provider,否则 CPU,且只列出
|
||||
本机支持的设备。
|
||||
4. 在 `Job Server` 点击 `Install Environment`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
|
||||
uv 由 Job Runtime 的 Environment 安装器下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
|
||||
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。首次安装
|
||||
依次显示 `Environment`、`MoGe-2 ViT-S Normal` 与 `BEN2 Base`;每项都独立从 0 走到 100%,
|
||||
进入下一项时进度条归零,模型 post-install 不显示 `2/2` 等阶段编号。uv、Python Environment 与 packages 合并在第一轮,
|
||||
两个默认模型由普通的 `post_install(runtime)` 发起正式 Server Job。
|
||||
安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
|
||||
依赖安装显示当前安装输出;模型下载显示速度,例如
|
||||
`Downloading DA3 Small 14.2 MB/s`;镜像来源显示为
|
||||
`Downloading DA3 Small (mirror) 14.2 MB/s`。下载按字节连续推进,重试或切换镜像时进度不会倒退。
|
||||
Environment 安装阶段与 `Models` 面板单独下载使用相同的模型名称格式,不会改写为通用的 `Downloading packages`。
|
||||
Environment 安装后即使模型下载中断也仍会保留,但 `Install Environment` 会以失败结束并显示模型下载错误。
|
||||
可稍后在 Image Empty 的 `SnapForm` 右键菜单用 `Download Required Models` 一次补齐缺失的
|
||||
MoGe-2 ViT-S 与 BEN2,也可在 `Models` 面板单独补下。
|
||||
安装不做启动网络预检;每个模型都先尝试 Hugging Face,失败后尝试 OmooLab R2,两者都失败时当前 Job 失败。
|
||||
下载源不写入持久状态;需要手动指定依赖镜像时可通过
|
||||
`UV_INDEX_URL` 环境变量覆盖。
|
||||
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
|
||||
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。
|
||||
7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。
|
||||
8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。
|
||||
9. 测试背景剔除时,确认 `Models` panel 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
|
||||
`BEN2` 区域选择单张图片并点击 `Remove Background`。
|
||||
完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示
|
||||
BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。
|
||||
|
||||
## Image Empty 去背景
|
||||
|
||||
1. 确认 `Models` panel 中的 `BEN2 Base` 已就绪;首次安装 Environment 时会默认下载。
|
||||
2. 在 3D View 选中 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Remove Background`。
|
||||
3. 完成后以 Fake User 保留原 Image datablock,新建并 Pack 一张透明 PNG 到当前 `.blend`,然后只把
|
||||
当前 Empty 的 `data` 换成新图;Empty 的 Transform、显示尺寸、Offset 及其他设置保持不变。
|
||||
4. 新 Image 使用原图去掉格式后再追加 `.foreground` 的名称。例如 `img.png` 生成
|
||||
`img.foreground`,`img.png.001` 生成 `img.001.foreground`。已有同名 datablock 时由
|
||||
Blender 自动追加编号。
|
||||
|
||||
原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack
|
||||
状态和原始内容。BEN2 的 `foreground.png` 也会保留在对应 job 目录中。
|
||||
|
||||
Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames
|
||||
数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度,
|
||||
会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出
|
||||
`foreground.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把
|
||||
源帧以 `frame.0001.jpg` 等命名复制到 job 目录根下供材质颜色导入。替换 Empty 的 Image 为
|
||||
加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留),
|
||||
Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因此保持外部引用 job 目录;
|
||||
请保留对应 job 目录,否则序列会失效。
|
||||
80
docs/getting-started/installation.md
Normal file
80
docs/getting-started/installation.md
Normal 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&blender_version_min=4.5.0&platforms=windows-x64&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 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&blender_version_min=4.5.0&platforms=macos-arm64&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 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&blender_version_min=4.5.0&platforms=linux-x64&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 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),用一张图片完成第一次转换。
|
||||
@@ -1,10 +1,60 @@
|
||||
# SnapForm 文档
|
||||
# 🖼️ AnyImage
|
||||
|
||||
技术内容已按主题拆分:
|
||||
**IMAGE is All You Need**
|
||||
*图,就够了~*
|
||||
|
||||
- [功能与使用](features.md):能力概览、安装使用和 Image Empty 去背景
|
||||
- [Depth Plane](depth-plane.md):深度面片的数据、节点与材质流程
|
||||
- [Surface](surface.md):透视网格、Geometry Nodes 后处理与材质法线
|
||||
- [输出格式](outputs.md):Debug 输出类型、参数和任务产物
|
||||
- [Job Server](server.md):Environment、模型缓存、任务目录与运行机制
|
||||
- [程序设计、开发与许可证](development.md):模块边界、调用流程、项目结构、常用命令与许可证
|
||||
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&blender_version_min=4.5.0&platforms=windows-x64&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 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&blender_version_min=4.5.0&platforms=macos-arm64&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 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&blender_version_min=4.5.0&platforms=linux-x64&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 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 即可。
|
||||
|
||||
34
docs/internals/architecture.md
Normal file
34
docs/internals/architecture.md
Normal 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
44
docs/internals/common.md
Normal 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)。
|
||||
48
docs/internals/development.md
Normal file
48
docs/internals/development.md
Normal 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
15
docs/internals/index.md
Normal 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。
|
||||
50
docs/internals/node-assets.md
Normal file
50
docs/internals/node-assets.md
Normal 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
|
||||
```
|
||||
42
docs/internals/operators/ai-setup.md
Normal file
42
docs/internals/operators/ai-setup.md
Normal 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。
|
||||
41
docs/internals/operators/clipboard-image.md
Normal file
41
docs/internals/operators/clipboard-image.md
Normal 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` 个材质槽,节点组只生成几何。
|
||||
14
docs/internals/operators/convert-to-panorama.md
Normal file
14
docs/internals/operators/convert-to-panorama.md
Normal 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` 编排推理和产物写入。
|
||||
21
docs/internals/operators/convert-to-plane.md
Normal file
21
docs/internals/operators/convert-to-plane.md
Normal 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)。
|
||||
43
docs/internals/operators/cutout-tool.md
Normal file
43
docs/internals/operators/cutout-tool.md
Normal 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)。
|
||||
21
docs/internals/operators/frame.md
Normal file
21
docs/internals/operators/frame.md
Normal 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) 的共享数据与失败恢复逻辑。
|
||||
16
docs/internals/operators/index.md
Normal file
16
docs/internals/operators/index.md
Normal 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 数据。
|
||||
23
docs/internals/operators/mask.md
Normal file
23
docs/internals/operators/mask.md
Normal 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 通过统一替换入口分流。
|
||||
17
docs/internals/operators/rectify.md
Normal file
17
docs/internals/operators/rectify.md
Normal 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 名称、对象身份和共享引用语义。
|
||||
19
docs/internals/operators/remove-background.md
Normal file
19
docs/internals/operators/remove-background.md
Normal 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 负责临时输入清理。
|
||||
15
docs/internals/operators/upscale.md
Normal file
15
docs/internals/operators/upscale.md
Normal 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
40
docs/internals/outputs.md
Normal 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
36
docs/internals/runtime.md
Normal 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
41
docs/javascripts/extra.js
Normal 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();
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
# 输出格式
|
||||
|
||||
## DA3 ONNX
|
||||
|
||||
`Output Type` 提供以下结果:
|
||||
|
||||
- Point Cloud (GLB):由 depth、confidence、相机参数和原图生成彩色点云
|
||||
- Depth Preview (Images):保存深度着色预览
|
||||
- Depth Data (NPZ):保存 depth、confidence、extrinsics、intrinsics 与处理后的图像
|
||||
- Textured Surface (GLB):SnapForm 根据 Prediction 构建纹理网格
|
||||
|
||||
DA3 Small 与 Base 的 ONNX 图不包含 Gaussian head,因此不提供 Gaussian Splats。默认推理参数为:
|
||||
|
||||
- `process_res=504`
|
||||
- `process_res_method="upper_bound_resize"`
|
||||
- `ref_view_strategy="saddle_balanced"`
|
||||
- `conf_thresh_percentile=40.0`
|
||||
- `num_max_points=1_000_000`
|
||||
- `show_cameras=True`
|
||||
|
||||
相机外参与内参由模型的 camera decoder 输出恢复,不运行 ray-pose 求解器。
|
||||
|
||||
## MoGe-2 ONNX
|
||||
|
||||
MoGe-2 Debug 可保存原始 NPZ、depth/normal/mask maps、纹理 GLB 或彩色 PLY。Surface 输出由模型 points、mask 与 depth edge 在内部构建三角网格。
|
||||
|
||||
## BEN2 ONNX
|
||||
|
||||
BEN2 输出 `foreground.png`,RGB 保留原始输入,Alpha 来自 ONNX mask 并恢复到输入分辨率。
|
||||
123
docs/server.md
123
docs/server.md
@@ -1,123 +0,0 @@
|
||||
# Job Server
|
||||
|
||||
## 运行架构
|
||||
|
||||
SnapForm 在独立的 Python 3.12 Environment 中运行 HTTP Job Server,Blender 只通过标准库 HTTP Client 提交和轮询任务,不把计算依赖载入 Blender Python。Server 只监听 `127.0.0.1`,由外置 BlendJob wheel 提供的 `ServerController` 保存随机端口与 Instance ID;专属本地进程协议不使用额外 Token 鉴权。
|
||||
|
||||
每个 Blender 实例启动并持有一个 SnapForm 专属 Server,不发现或复用其它 Blender、其它插件启动的本地 Server。多个 Blender 可以对应多个进程,但模型、Environment 与任务产物仍可共享同一个 Storage Root。
|
||||
|
||||
Environment、模型和任务结果默认保存在 `~/.snapform`:
|
||||
|
||||
```text
|
||||
~/.snapform/
|
||||
├── .venv/
|
||||
├── environment.json
|
||||
├── models/
|
||||
├── jobs/
|
||||
├── manifest.json
|
||||
├── install.log
|
||||
├── install-status.json # 仅在安装期间存在
|
||||
└── server.log
|
||||
```
|
||||
|
||||
不同 Blender 版本可共享此目录。也可以在 Extension Preferences 的 `Storage Root` 中修改位置。连接端口与 Instance ID 只保存在启动它们的 Blender 进程内存中。
|
||||
|
||||
Blender 侧的通用 HTTP Runtime 由 `src/snapform/wheels/` 中随扩展分发的 BlendJob package 提供;独立 Environment 由 Runtime 从 PyPI 自动安装相同版本。`JobServer` 包装 FastAPI 并提供 Job、FIFO Queue 与 Resource API,`JobClient` 是无第三方依赖的 Blender 侧访问器。Blender 侧只把 `server/__init__.py:server` 入口字符串交给 Runtime,不导入 `server` package;独立进程启动后才取得其中的 `server` 实例、注册全部公开 Job,并创建 `model_manager` Resource。
|
||||
|
||||
SnapForm 根 package 不再有 `client.py`、`environment.py`、`storage.py` 或 `models.py`。这些文件会制造并不存在的业务边界:
|
||||
|
||||
- Storage Root、`.venv`、Manifest、安装状态与日志路径由 `JobRuntime` 唯一管理;
|
||||
- `InstallEnvironment` 由 `runtime.operator_classes()` 提供,Python 与 Job 自身的额外 packages 直接声明在传给 Runtime 的 `environment` 字典中;BlendJob 不需要重复声明;
|
||||
- 模型 catalog 由 `model_catalog.py` 统一声明;下载和 ONNX Session 属于 Server,分别位于 `model_download.py` 与 `model_manager.py`;
|
||||
- Blender UI 复用同一 catalog,直接检查 Storage Root 下的模型必需文件,不为显示下载状态启动 Server;已加载 Session 状态仍读取现有 Server Snapshot。
|
||||
|
||||
BlendJob 的 `JobClient` 只是无依赖 HTTP transport,不是 SnapForm 业务抽象,也不向 Operator 暴露 `JobRequest`。
|
||||
|
||||
SnapForm 的业务 Operator 直接提交参数字典,Job Type 一一对应:
|
||||
|
||||
- `download_model` → `download-model`
|
||||
- `download_required_models` → `download-required-models`
|
||||
- `remove_background` → `remove-background`
|
||||
- `extract_frames` → `extract-frames`
|
||||
- `generate_da3_depth` → `generate-da3-depth`
|
||||
- `generate_moge2_depth` → `generate-moge2-depth`
|
||||
- `generate_da3_surface` → `generate-da3-surface`
|
||||
- `generate_moge2_surface` → `generate-moge2-surface`
|
||||
- `debug_da3` → `debug-da3`
|
||||
- `debug_moge2` → `debug-moge2`
|
||||
- `debug_ben2` → `debug-ben2`
|
||||
|
||||
DA3 与 MoGe-2 的生产任务不再合并成带 Backend 参数的通用请求。每个 Operator 的 `request(context)` 直接构造自己的有效参数,Debug 任务也与生产任务完全分开。
|
||||
|
||||
Server 端没有 `execute_*`、`*_result` 或 CLI worker 中转。全部 `@server.job(...)` 都集中在 `server/__init__.py`:简单任务直接实现,复杂任务调用按领域拆分的 `media.py`、`background.py`、`moge2.py` 或 `da3.py`;共享的纯几何计算位于 `geometry.py`。模型下载由 `ModelManager` 单向调用 `model_download.py`,不存在循环 import。
|
||||
|
||||
## ONNX-only Environment
|
||||
|
||||
扩展不内置 `uv` 可执行文件或 AI 运行库。`Install Environment` 首先下载固定版本的 uv,校验 SHA-256 后解压到共享 Storage Root,再用它创建环境并安装:
|
||||
|
||||
- ONNX Runtime 1.24.4;Windows 使用 `onnxruntime-directml`
|
||||
- NumPy 2.4.2、Pillow 12.1.1、PyAV 17.0.0、trimesh 4.11.2
|
||||
- BlendJob 0.1.6、FastAPI 0.139.2、Uvicorn 0.51.0、huggingface-hub 1.4.1
|
||||
|
||||
运行环境不安装 PyTorch、TorchVision、xFormers,也不安装 DA3、MoGe、BEN2 或 utils3d 的 Python 源码包。模型推理和 SnapForm 所需的相机、点云、网格后处理均由 ONNX Runtime、NumPy 与内部实现完成。
|
||||
|
||||
Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExecutionProvider`。显式指定不可用设备时会安全回退到 CPU。其他平台安装标准 ONNX Runtime,并按可用 provider 选择 CoreML、OpenVINO 或 CPU。
|
||||
|
||||
`environment.json` 保存规范化后的声明,`manifest.json` 保存该声明、BlendJob 版本与基础 Server packages 的 Hash。Python、packages、平台 packages、BlendJob 版本或 Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。
|
||||
|
||||
## 模型
|
||||
|
||||
所有正式功能都使用 ONNX:
|
||||
|
||||
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
|
||||
- DA3 Small、DA3 Base
|
||||
- BEN2 Base
|
||||
|
||||
首次安装在 `post_install(runtime)` 中自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`。它们和 `Models` panel 的后续下载一样,都是 `download-model` Job,与推理共用队列、HTTP 进度和取消协议。模型按声明的目录名保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型先走 Hugging Face `resolve` 直链流式下载,失败后尝试 `https://models.omoolab.xyz/<repository-name>/<relative-path>`;两者都失败时当前 Job 直接失败,不记录或猜测后续下载源。模型直链的 TCP、代理 CONNECT 与 TLS 建连最长等待 10 秒;连接后连续 60 秒没有收到响应数据则视为当前来源失败并切换下一来源。该空闲超时不限制模型总下载时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
|
||||
|
||||
Image Empty 的右键菜单会读取 `model_manager` 状态;缺少 MoGe-2 ViT-S 或 BEN2 时显示
|
||||
`Download Required Models`。对应的 `download-required-models` Job 在 Server 端再次检查就绪状态,
|
||||
只下载缺失项,并把多个下载的进度合并为单次 0–100%。必需模型补齐前,右键菜单中的
|
||||
`Convert to Depth Plane` 保持禁用。
|
||||
|
||||
`model_manager` Resource 在 Server 内持有模型 catalog、磁盘就绪状态和内存 Session。Blender UI 使用 catalog 声明的目录名与 `ready_patterns` 直接检查 `<storage-root>/models/`,因此展开模型面板、打开转换对话框或 Image Empty 右键菜单都不会仅为状态检测启动 Server。真正下载或推理时,Server 仍通过 `ModelManager.ready()` 重新验证文件。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/model_manager` 继续返回 catalog、下载状态和当前加载状态;`POST /resources/model_manager/clear` 只允许在没有 Active 或 Queued Job 时执行。
|
||||
|
||||
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参,不提供 ray-pose 参数。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
|
||||
|
||||
|
||||
## 安装网络与镜像
|
||||
|
||||
安装器直接开始工作,不做阻塞式网络预检。packages 由 uv 安装,可用 `UV_INDEX_URL` 显式指定镜像。Windows 系统代理会注入子进程;同时支持 `HTTP_PROXY`、`HTTPS_PROXY` 与 `ALL_PROXY`。
|
||||
|
||||
uv 默认从 Astral 的 GitHub Release 下载,失败后回退到 `https://mirror.omoolab.xyz/uv/<version>/`。两个来源使用相同的连续字节进度并共用固定 SHA-256 校验。下载压缩包只作临时文件,提取完成后删除。模型下载不经过 GitHub。
|
||||
|
||||
首次安装依次显示 Environment、MoGe-2 下载与 BEN2 下载,每项分别使用 0–100%。Environment 由 Job Runtime 依次完成 uv 下载、Python Environment 创建与 packages 安装,uv 不再占用独立一轮进度。两个模型下载由 SnapForm 的 `post_install(runtime)` 通过 `runtime.request()` 顺序发起;切换模型时进度归零,但不显示 `2/2` 等阶段编号。
|
||||
任一 post-install 模型失败时,Environment 文件仍然保留,但安装 Operator 返回失败并显示聚合后的模型错误,
|
||||
不再把带 `post_install_error` 的终态报告为成功。
|
||||
|
||||
## 生命周期
|
||||
|
||||
扩展启用后不启动 Server;第一次执行 Job 或点击 `Start` 时才为当前 Blender 启动专属实例。正常关闭或禁用扩展时直接停止自己启动的进程;Server 也监视 Blender 父进程 PID,父进程异常退出后会自行停止。
|
||||
|
||||
AI、帧提取和 Models panel 下载的 Job 进度保存在 Server 内存中。Handler 只使用 `context.progress(value, message)` 发布进度,终态由 JobServer 内部管理;SnapForm 不再维护独立的 `server/progress.py` 或 Job 状态 JSON。Blender 每 0.25 秒轮询 `GET /jobs/{job_id}`,任务目录不写 `status.json`。Operator 直接提交参数字典;Job 被接受时由 `JobServer` 自动生成 ID 并创建 `jobs/<opaque-job-id>/`,提交响应同时返回 `job_id` 与 `directory`。路径通过 `JobContext.directory` 传给 Handler,SnapForm Server 强制将输出写入该目录。
|
||||
|
||||
`job_runtime.py` 创建唯一的 `runtime = JobRuntime(...)`,以自身目录为 `entrypoint_root`,传入 `server:server` 相对入口字符串与显式 `snapform` namespace,并导出 `runtime.JobOperatorBase`。Runtime 独占 Storage Root、Environment 路径、安装进程、Server 控制和 Status Bar。Operator 未覆盖 `request(context)` 时会提交全部 Property;覆盖后返回值纯替换默认参数。可选的 `response(context, result)` 在 Blender 主线程直接完成结果导入。SnapForm 不再提供 Client 请求层、Job Mixin、Status Bar 或 Build 中转 Operator。
|
||||
|
||||
Environment 安装是 Server 启动前唯一的 Bootstrap 流程:`environment.json` 记录期望安装的环境声明,`install-status.json` 在安装期间回传进度并在任务收尾后删除,`install.log` 持久记录安装输出。Bootstrap 不创建 Job 目录。Environment 成功后执行普通的 `post_install(runtime)`;默认模型下载此时已经是正式 Server Job,会创建自己的 Job 目录,并使用与其它任务相同的进度和取消协议。
|
||||
|
||||
Server 使用标准库单 Worker FIFO Queue,全局同时只执行一个 Job,包括模型下载。Server 忙碌时新 Job 进入 `queued`,不再返回 409;排队任务可以在执行前取消。取消运行中任务不会重启 Server;流式模型下载会在分块检查点结束,ONNX Runtime 当前调用则在返回后的最近检查点结束。JobServer 最多保留 100 个已完成 Job 的内存状态,后续提交时清理更早记录。
|
||||
|
||||
Server 由 Environment 通过 `python -m blendjob.runner` 启动,避免 BlendJob 内部模块名遮蔽 Python 标准库。启动与崩溃信息写入 `~/.snapform/server.log`。
|
||||
|
||||
## 已知限制
|
||||
|
||||
DA3 ONNX 的注意力是朴素实现(`MatMul → Softmax → MatMul`),峰值显存与 `(帧数 × 每帧 patch 数)²` 成正比。迁移到 ONNX 前的 PyTorch 版使用 `scaled_dot_product_attention`(`XFORMERS_DISABLED=1`),按块计算注意力分数、不落地完整注意力矩阵,显存近似线性,因此视频能处理几十上百帧;ONNX 导出把 SDPA 回退成朴素注意力,多帧在中高分辨率下会显存爆炸(约 12 帧 @ 1008 即需 ~90 GB)。
|
||||
|
||||
Depth Plane 的视频、序列帧转换会复用同一个 ONNX Session 逐帧独立推理,再把结果合并到现有序列输出,因此峰值显存只取决于单帧分辨率。逐帧结果不保留 DA3 的跨视图尺度与相机位姿一致性,适用于当前逐帧动画结构。Surface 只支持单张图片;Debug 多帧输出仍使用联合推理,以保留多视图关系。
|
||||
|
||||
联合推理时,`onnx_da3.infer` 会在执行前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。
|
||||
|
||||
待解决方向:
|
||||
|
||||
- 重新导出 DA3 ONNX,使用 ORT 的 `Attention` / `MultiHeadAttention` 贡献算子,或注入内存高效的注意力算子;
|
||||
- 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。
|
||||
133
docs/stylesheets/extra.css
Normal file
133
docs/stylesheets/extra.css
Normal 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;
|
||||
}
|
||||
190
docs/surface.md
190
docs/surface.md
@@ -1,190 +0,0 @@
|
||||
# Surface
|
||||
|
||||
## Image Empty 转透视网格
|
||||
|
||||
1. 在 3D View 选中仅包含一帧的 Image Empty,右键打开 `SnapForm` 子菜单并选择
|
||||
`Convert to Surface`。该入口独立于
|
||||
`Convert to Depth Plane`,当前不处理视频或序列帧。
|
||||
模型列表包含三个 MoGe-2 Normal 模型和两个 DA3 模型,默认使用 MoGe-2 ViT-S Normal。DA3 的
|
||||
Resolution Scale 与 Depth Plane 使用相同设计;MoGe-2 改为显示共享的 Resolution Level。
|
||||
2. Server 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得
|
||||
前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Server
|
||||
反投影深度网格,建立源三角面,计算 confidence percentile 与 3×3 相对深度不连续度,并写入
|
||||
基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、
|
||||
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Server 端源数据准备。
|
||||
3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器
|
||||
添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成:
|
||||
- 修改器顶层依次显示 `Resolution`、`Relax Iterations`、`Smooth`;默认展开的 `Culling` Panel
|
||||
依次包含 `Depth Continuity` 与 `Confidence`。前者直接读取面域 3×3 相对深度差,并使用指数映射:
|
||||
默认 `50%` 对应官方 `rtol=0.04`,数值越大,内部阈值越小,剔除的断层面越多;
|
||||
- 每轮 Remesh Iteration 依次执行 Connected Merge、循环拓扑修复与一次 Smooth,使新拓扑在进入下一轮
|
||||
Merge 前先稳定;拓扑修复删除非流形面、单面拓扑和小面积 mesh islands;
|
||||
- 默认 `Resolution` 为原始有效三角边长中位数乘以 `sqrt(10) * 2`。`sqrt(10)` 来自原先 90% 面数缩减
|
||||
的二维边距估计,再乘以 2 为迭代合并留出更平滑、稳定的默认尺度;节点内部 Connected Merge 的
|
||||
Distance 为 `Resolution * 0.5`;
|
||||
- Smooth 采用 `O Smooth` 的边界检测和模糊法线权重,固定开放边界与薄面,只保留一个
|
||||
`Smooth` 程度系数;节点组不再额外执行 Flip Faces;
|
||||
- 从修改前的原始表面以 nearest-surface 重新采样 `UVMap`,最终明确写为 Corner 域 `2D Vector`。
|
||||
MoGe-2 使用独立的 `O MoGe2 Surface`。Server 由内置 NumPy 网格构建函数
|
||||
生成有限深度范围内的紧凑三角网格,并保留模型 mask、逐点 depth 及相邻点相对深度差。Geometry Nodes
|
||||
按官方 `mask & ~depth_map_edge(depth, rtol=0.04)` 的含义,先由 `Mask` 删除 mask 外的面,再由
|
||||
`Depth Continuity` 按同一份 3×3 邻域相对深度差删除断层面。DA3 与 MoGe-2 使用相同的属性、
|
||||
名称、指数滑块和 FACE 删除逻辑,不再额外删除孤立点。之后才执行拓扑修复、Relax、孤岛清理与 UV 重投影。
|
||||
节点组不提供 Confidence 或 Remesh 开关;Remesh 仍是节点处理流程的一部分。
|
||||
4. 节点组随插件放在 `O_SnapForm.blend`。Blender 4.5 首次 Append 后复用同一 datablock;
|
||||
Blender 5.0 以 linked ID 载入后调用 Pack,后续 Surface 继续复用。插件不会为每个 Surface 新建节点组,
|
||||
也不会在添加后重命名 Geometry Nodes 修改器。节点组只启用 `Usage > Modifier`,不 Mark as Asset;
|
||||
修改器关闭 `show_group_selector`。`Fix Topology` 位于默认折叠的 `Options` Panel。
|
||||
节点图按 Culling、Remesh、Topology Fix、Island Cleanup 与 UV 分区摆放;各区就近使用独立的
|
||||
Group Input,并隐藏没有连线的接口,避免跨越整个节点图的输入连线。
|
||||
`O DA3 Surface` 与 `O MoGe2 Surface` 的 `Resolution` 默认值均为 `0.1`,所有输入 socket 的
|
||||
Structure Type 均固定为 `Single`。
|
||||
5. Server 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始
|
||||
源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。
|
||||
6. Server 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、
|
||||
`faces`、`uv`、`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。Blender 读取这些
|
||||
数据后直接创建 Surface 与原图材质,不需要 Open3D 或 OpenCV 进入 Blender Python 环境。
|
||||
DA3 Surface 只保留 Geometry Nodes 后处理实际使用的 attributes:点域 `o_depth`、面域
|
||||
`o_face_confidence` 与面域 `o_depth_discontinuity`。Remove Background 已在 Server
|
||||
建面前直接删除背景三角面,不写入任何 background attribute。MoGe-2 的导入网格保留点域 `o_depth`,
|
||||
以及面域 `o_mask`、`o_depth_discontinuity`,不伪造 confidence、stretch 或 background 属性,
|
||||
也不写入 custom normal。完整分辨率的 `normal_map` 保存在 NPZ 中并转换为材质贴图。
|
||||
所有 MoGe-2 Image datablock 都以源图片名而非对象名为基名:depth、normal 与 mask 分别使用
|
||||
`图片名.depth`、`图片名.normal`、`图片名.mask`,不附加 `.moge2`。
|
||||
7. 网格以 `reference_depth` 截面匹配 Image Empty 的显示矩形:该截面的投影四角与 Empty 的可见
|
||||
边界重合,主点同时匹配 Empty Offset。网格对象直接沿用源 Empty 的 `matrix_world`,因此位置、旋转、
|
||||
缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向
|
||||
观察相机,启用 Backface Culling 时可从正面看到。
|
||||
8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、
|
||||
`Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Server
|
||||
使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。
|
||||
该预删除同时适用于 DA3 与 MoGe-2;因此 MoGe-2 转换后即使关闭 `Mask`,BEN2 已删除的背景也不会恢复。
|
||||
`Unshaded` 使用与 Depth Plane 相同的 Emission/Transparent 材质路径;普通材质的
|
||||
Bump Scale 按 `abs(object.scale.z) × camera scale_z` 设置,并在材质组内乘以固定基准 `0.001`,
|
||||
与透视表面实际深度尺度一致;它允许负数以反转凹凸方向。
|
||||
相机默认关闭;开启后会在 Empty 局部 `+Z` 侧创建一台朝向局部 `-Z` 的透视相机,并成为生成 Surface
|
||||
的子级,使二者移动时保持投影关系。关闭 `Keep Original` 时,网格成功创建后删除源 Empty。
|
||||
表面、材质与相机会归入同一个 `Convert to Surface` Undo 步骤。
|
||||
|
||||
Process Res 的设计依据 DA3 官方实现:API 与 CLI 默认使用 `process_res=504` 和
|
||||
`process_res_method="upper_bound_resize"`;输入预处理的 `PATCH_SIZE=14`,会把宽高分别调整到最接近的
|
||||
`14` 倍数。DA3 论文以 `504×504` 为 base resolution,公开的训练尺寸长边为 `504`、`672`、`756` 或
|
||||
`896`。官方 CLI 虽给出 `1024` 示例,但没有声明更高分辨率会提升质量,也没有规定 `1000` 为硬上限。
|
||||
因此转换入口使用相对原图的 Resolution Scale,同时通过上下限避免 Process Res 过低或过高,并把训练范围
|
||||
之外的值明确视为实验设置。参考
|
||||
[DA3 API](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)、
|
||||
[输入预处理实现](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/src/depth_anything_3/utils/io/input_processor.py)
|
||||
与[技术报告](https://depth-anything-3.github.io/assets/da3_tech_report_2025.pdf)。
|
||||
|
||||
Debug 的 Textured Surface GLB 会把 depth、confidence 与 confidence percentile 写入自定义顶点
|
||||
attributes。其他 DA3 Debug 三维输出会同时写出未压缩的 `prediction.attributes.npz`,保存完整
|
||||
depth、confidence、intrinsics、extrinsics 与 confidence percentile。当前 trimesh 的 glTF 点图元
|
||||
导出器只写 Position/Color,无法可靠把任意 attribute 嵌入点云 GLB,因此点云先以 companion NPZ
|
||||
保留完整结构;后续若改为 SnapForm 自有 glTF writer,可再把这些字段直接嵌入点图元。
|
||||
|
||||
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
|
||||
位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
|
||||
进度单调递增。任务进程与 Server 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Environment 启动、输入准备、模型加载、深度估算、缓存
|
||||
写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的
|
||||
半成品 Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。
|
||||
|
||||
导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在
|
||||
`Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到
|
||||
`0..1`,也不再提供视觉归一化模式。
|
||||
|
||||
面片尺寸以有效深度的中位数 `Zref` 作为参考截面。先用 `mini_npz` 中的相机内参计算原始
|
||||
投影面积,再恢复源图纵横比:
|
||||
|
||||
```text
|
||||
RawWidth = depth_width × Zref × abs(Displacement Strength) / fx
|
||||
RawHeight = depth_height × Zref × abs(Displacement Strength) / fy
|
||||
Area = RawWidth × RawHeight
|
||||
Aspect = source_image_width / source_image_height
|
||||
Width = sqrt(Area × Aspect)
|
||||
Height = sqrt(Area / Aspect)
|
||||
```
|
||||
|
||||
这样即使 DA3 估计出的 `fx/fy` 不一致,也不会把正方形原图拉成长方形。主点 `cx/cy` 仍用于
|
||||
确定原始相机面片,随后对象 XY Transform 将它匹配到源 Image Empty。生成对象会记录
|
||||
`snapform_reference_depth`、`snapform_camera_plane_width` 和
|
||||
`snapform_camera_plane_height`,便于检查实际采用的
|
||||
参考深度和尺寸。
|
||||
|
||||
材质始终导入深度 Image Texture,并连接到 Displacement 节点和 Material Output;材质 Surface
|
||||
Displacement 设置为 `Displacement Only`,DA3 与 Original 都使用 `Midlevel = 0`、`Scale = 1`。MoGe-2 的位移只在专用 Geometry Nodes 中处理。
|
||||
`o_material_displacement` 为 `False` 时,材质把 Height 乘以零,置换只在几何节点中生效;为 `True` 时,
|
||||
几何节点关闭结构置换,材质 Height 恢复。色彩贴图的 Alpha 二值结果同时用于选择原始深度或有符号的
|
||||
`Background Depth`,使两种置换路径保持一致;未移除背景的普通不透明图片因此全部视为前景。
|
||||
视频与序列帧的颜色、深度与前景 Image Texture
|
||||
通过 image_user 的 Frames / Offset / Start 把第 i 帧映射到场景 `Start+i` 帧,并启用 Auto Refresh。
|
||||
颜色贴图到 Principled BSDF 之间使用独立的 Shader Node Group。DA3 Surface 使用 `O DA3 Surface Layer`,
|
||||
MoGe-2 Surface 使用独立的 `O MoGe2 Surface Layer`;它额外接收模型输出的 `Normal` 贴图,使用
|
||||
`Normal Map` 节点按 `Object Space` 解码后连接到 Bump 的 `Normal` 输入,使像素级模型法线与原图
|
||||
Bump 合并,不依赖重拓扑后的顶点密度;`Normal Strength` 默认值为 `1.0`,只调节模型 normal map,
|
||||
原图 Bump 仍由独立的 `Bump Scale` 控制;
|
||||
Depth Plane 使用 `O DA3 Depth Plane Layer` 材质组,并把背景剔除、背景深度与材质置换 Attribute
|
||||
读取全部收进组内。两者都接收 `Color`、`Alpha` 与 `Bump Scale`,只有 `O DA3 Depth Plane Layer`
|
||||
额外接收 Float 类型的 `Depth`。
|
||||
`O DA3 Surface Layer` 只输出 `Color`、`Roughness`、`Alpha` 与 `Normal`,以原图 Color 保留 Bump 效果,
|
||||
不创建 Displacement 节点或输出;`O DA3 Depth Plane Layer` 另外输出 `Displacement`。外层材质只保留贴图、
|
||||
材质组、Principled BSDF 与 Material Output,方便继续调整。
|
||||
Depth Plane 的 Bump Height 直接使用材质组的 `Color` 输入,只受独立的 `Bump Scale` 影响,不使用深度贴图,
|
||||
也不经过 `Displacement` 模式开关、`Depth Scale` 或 `Background Depth`;材质 Displacement 仍使用这些参数处理后的深度。
|
||||
无论选择哪种置换方式,原始彩色贴图都会连接
|
||||
Principled BSDF 的 `Base Color`,并通过
|
||||
`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为
|
||||
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
|
||||
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持
|
||||
Principled BSDF 默认值 `1.5`。
|
||||
开启 `Unshaded` 后,色彩贴图 Color 连接 Emission,二值 Alpha 作为 Mix Shader 系数,在
|
||||
Transparent BSDF 与 Emission 之间混合后连接 Material Output;不创建 Principled BSDF、Bump 或
|
||||
Float Curve。开启 `Remove Background` 时色彩贴图使用 BEN2 输出;关闭时使用原图。
|
||||
|
||||
这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应
|
||||
被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的
|
||||
`DA3 Nested Giant Large 1.1`。此外,当前结果仍是固定 XY 平面上的深度位移面,不是使用
|
||||
相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为 Geometry Nodes
|
||||
深度面片提供正确的整体高宽尺度。
|
||||
|
||||
生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择
|
||||
`Convert to Depth Plane` 会按当前 Depth from 再次转换。
|
||||
面片、深度 Image、前景 Image、Material 与 Geometry Nodes 修改器会在同一个 Blender Undo 步骤中创建;转换完成后执行
|
||||
`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。
|
||||
|
||||
Image Empty 使用 Packed Image 时,SnapForm 会通过 Blender 写出临时 PNG 交给 DA3,
|
||||
不会 Unpack 或修改源 Image;任务结束后自动删除临时文件。普通图片仍直接读取源文件。DA3 的原始
|
||||
`results.npz` 保留在对应 job 文件夹根下,生成的浮点深度 Image 会 Pack 到
|
||||
当前 `.blend`。Depth Image 与 Displace Texture 根据源 Image datablock 命名。例如 Empty Object
|
||||
名为 `obj_img`、引用的 Image 名为 `img.png` 时,Depth Image 与 Texture 都命名为 `img.depth`;
|
||||
生成的 Object、Mesh 与 Material 仍命名为 `obj_img`。Pack 使用 OpenEXR,因而不会把大于 `1.0`
|
||||
的原始深度截断。已有同名 datablock 时由 Blender 自动追加 `.001` 等编号。
|
||||
视频输入时 Blender 无法整体 Pack 序列帧,因此 `.depth` 与颜色序列改为引用 job 目录根下的
|
||||
`depth.0001.exr`、`frame.0001.jpg`(`.frame`)或 `foreground.0001.png`,并在每帧保留原始
|
||||
浮点深度;保存 `.blend` 后再次打开仍需对应 job 目录存在。
|
||||
|
||||
选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和
|
||||
`DA3 Nested Giant Large 1.1`。Small 和 Base 没有 Gaussian head,无法由
|
||||
DA3 生成 `gs_ply`。
|
||||
|
||||
每个 Server Job 被接受时,Job Runtime 在 Runtime Storage Root 的 `jobs/` 下创建一个独立文件夹。文件夹名是 32 位 opaque Job ID,不包含任务类型或对象名称。执行逻辑从 `JobContext.directory` 获取路径,输出产物直接写入该目录。
|
||||
|
||||
Blender 为 Packed Image 或序列帧生成的输入副本先保存在系统临时目录,Server 读取完成后由 Blender 清理,不归档到 Job 目录。普通磁盘文件和视频仍直接读取原文件。Server 日志统一写入 `server.log`,进度只保存在 Server 内存中。Environment Bootstrap 使用 Storage Root 下的 `install.log` 与临时的 `install-status.json`,不创建 Job 目录。
|
||||
|
||||
与几何输出相关的 Job Type 为 `generate-da3-depth`、`generate-moge2-depth`、`generate-da3-surface`、`generate-moge2-surface`、`debug-da3` 与 `debug-moge2`。每种 Job 只接收对应模型与输出所需的参数。
|
||||
|
||||
DA3 ONNX exporter 产生的 `depth_vis/`、`exports/mini_npz/` 会在完成后自动平铺,
|
||||
不会保留只含一层产物的子目录:
|
||||
|
||||
```text
|
||||
<opaque-job-id>/
|
||||
├── scene.glb、*.ply、*.jpg 或 results.npz
|
||||
└── 其它对应 Debug 格式的产物
|
||||
|
||||
<opaque-job-id>/
|
||||
├── foreground.png 单图结果
|
||||
├── foreground.0001.png… 视频/序列帧结果
|
||||
├── frame.0001.jpg… 视频/序列帧的源帧副本
|
||||
└── depth.0001.exr… AI 深度序列帧
|
||||
```
|
||||
|
||||
建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重。
|
||||
120
mkdocs.yml
Normal file
120
mkdocs.yml
Normal 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 © 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
1
nodes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Node group definitions used to build the bundled asset."""
|
||||
1
nodes/common/__init__.py
Normal file
1
nodes/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared node graph builders."""
|
||||
116
nodes/common/boundary_smoothing.py
Normal file
116
nodes/common/boundary_smoothing.py
Normal 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
139
nodes/common/cutout.py
Normal 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"])
|
||||
29
nodes/common/cutout_boundary.py
Normal file
29
nodes/common/cutout_boundary.py
Normal 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)
|
||||
473
nodes/common/depth_surface.py
Normal file
473
nodes/common/depth_surface.py
Normal 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
33
nodes/common/direction.py
Normal 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"]
|
||||
25
nodes/common/material_slots.py
Normal file
25
nodes/common/material_slots.py
Normal 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
213
nodes/common/nodes.py
Normal 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"]
|
||||
82
nodes/common/normal_map.py
Normal file
82
nodes/common/normal_map.py
Normal 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]
|
||||
33
nodes/common/reference_plane.py
Normal file
33
nodes/common/reference_plane.py
Normal 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
34
nodes/common/shader.py
Normal 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
173
nodes/common/smoothing.py
Normal 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
10
nodes/common/validity.py
Normal 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
1
nodes/groups/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Named node group definitions."""
|
||||
205
nodes/groups/image_cutout.py
Normal file
205
nodes/groups/image_cutout.py
Normal 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
|
||||
416
nodes/groups/image_cutout_symmetry.py
Normal file
416
nodes/groups/image_cutout_symmetry.py
Normal 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
|
||||
336
nodes/groups/image_depth_cutout.py
Normal file
336
nodes/groups/image_depth_cutout.py
Normal 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
|
||||
90
nodes/groups/image_depth_layer.py
Normal file
90
nodes/groups/image_depth_layer.py
Normal 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
|
||||
212
nodes/groups/image_depth_panorama.py
Normal file
212
nodes/groups/image_depth_panorama.py
Normal 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
|
||||
158
nodes/groups/image_depth_plane.py
Normal file
158
nodes/groups/image_depth_plane.py
Normal 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
103
nodes/groups/image_layer.py
Normal 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
253
nodes/groups/image_plane.py
Normal 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
|
||||
234
nodes/groups/image_relief_plane.py
Normal file
234
nodes/groups/image_relief_plane.py
Normal 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
79
nodes/groups/shadeless.py
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-10
|
||||
@@ -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 推理确认。
|
||||
@@ -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` 及图片目标编辑变更共同使用实施时的真实调用链。
|
||||
@@ -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** 系统按既有帧选择、透明度合成和输出规则完成操作
|
||||
@@ -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` 并确认正常退出,检查最终差异及旧入口引用,记录验证结果。
|
||||
@@ -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` 通过。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-12
|
||||
@@ -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
|
||||
|
||||
实现代码与测试后重建节点资产,执行相关测试及全量测试。回退时将本次代码、测试和资产作为同一变更回退。
|
||||
@@ -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 定义本次行为。
|
||||
@@ -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 初始化不会覆盖该值
|
||||
@@ -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** 本次操作使用用户保存的模式
|
||||
@@ -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 模式引用及最终差异,确认源码、测试和节点资产一致。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
@@ -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,创建时按用户偏好启用,保留每个实例的强度控制。
|
||||
- 工作区已有材质与节点资产修改 → 在当前实现上追加变更,保留已有修改并检查最终差异。
|
||||
- 旧资产缺少输入 → 验证资产接口与运行时代码一致,正式发布配套资产。
|
||||
@@ -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` 需要包含新接口;本轮仅生成提案。
|
||||
@@ -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 使用当前偏好初始化
|
||||
@@ -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 严格校验通过。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-03
|
||||
@@ -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 填充。
|
||||
@@ -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 协议。
|
||||
@@ -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 业务字段
|
||||
@@ -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,不构建文档、节点资产或发布产物
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-10
|
||||
@@ -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
|
||||
|
||||
先基于已完成原型验证动态尺寸、分辨率等级、精度与性能,再完成转换工具和模型登记。沿用现有设置与默认模型,只增加新模型选择及必要分派。实施更新相关测试,检查依赖与打包内容一致性。
|
||||
@@ -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 与现有后处理依赖。正式接入以动态尺寸、分辨率等级及完整流程验收通过为前提。
|
||||
@@ -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** 给出明确状态,不应用部分结果,并允许后续重新运行
|
||||
20
openspec/changes/archive/2026-09-17-add-moge3-model/tasks.md
Normal file
20
openspec/changes/archive/2026-09-17-add-moge3-model/tasks.md
Normal 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,确认正常退出及最终差异范围。
|
||||
@@ -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 实机验收。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
@@ -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 预先处理。
|
||||
@@ -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 环境。
|
||||
@@ -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** 清理本任务暂存及未使用数据块,不应用部分转换结果
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user