Compare commits
79 Commits
ee46d98b93
...
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 |
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
|
||||
33
.github/workflows/build.yml
vendored
33
.github/workflows/build.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: 发布扩展
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: 运行测试
|
||||
run: uv run --frozen pytest
|
||||
|
||||
- name: 构建扩展
|
||||
run: uv run --frozen python pack.py
|
||||
|
||||
- name: 发布扩展包
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1.3.6
|
||||
with:
|
||||
files: |-
|
||||
dist/*.zip
|
||||
54
.github/workflows/docs.yml
vendored
54
.github/workflows/docs.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: 构建并部署文档
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
- .github/workflows/docs.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: 构建文档
|
||||
run: uv run --frozen --only-group docs mkdocs build --strict --site-dir site
|
||||
|
||||
- name: 整理发布目录
|
||||
run: |
|
||||
mkdir -p public/snapform
|
||||
cp -a site/. public/snapform/
|
||||
|
||||
- name: 配置 Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: 部署到 Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_WORKER_NAME: ${{ vars.CLOUDFLARE_WORKER_NAME }}
|
||||
run: |
|
||||
test -n "$CLOUDFLARE_ACCOUNT_ID"
|
||||
test -n "$CLOUDFLARE_API_TOKEN"
|
||||
test -n "$CLOUDFLARE_WORKER_NAME"
|
||||
npx --yes wrangler@4 deploy \
|
||||
--assets ./public \
|
||||
--name "$CLOUDFLARE_WORKER_NAME" \
|
||||
--compatibility-date 2026-08-19 \
|
||||
--keep-vars \
|
||||
--message "SnapForm docs $GITHUB_SHA" \
|
||||
--autoconfig=false
|
||||
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
|
||||
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 运行测试
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,13 +14,16 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 检出代码
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 配置 uv
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: 运行测试
|
||||
run: uv run --frozen pytest
|
||||
- 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)。
|
||||
|
||||
117
README.md
117
README.md
@@ -1,111 +1,12 @@
|
||||
# SnapForm
|
||||
# AnyImage
|
||||
|
||||
SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一组序列帧快速变成有真实深度的立体面片。
|
||||
把图片里的空间感带进 Blender。
|
||||
|
||||
完整使用说明请从[文档首页](docs/index.md)开始;首次使用请依次阅读
|
||||
[安装与首次配置](docs/installation.md)和[五分钟上手](docs/quick-start.md)。
|
||||
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 provider;Apple M4 或更新芯片可选 MPS/CoreML,其他 Mac 只提供 CPU
|
||||
5. 打开 3D 视图右侧的 `SnapForm` 面板:使用官方源点击 `Install Environment`,使用国内镜像点击 `Install Environment (Mirror)`
|
||||
|
||||
扩展不内置 uv 或 AI 运行库。首次使用会先下载并校验固定版本的 uv,随后自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和
|
||||
`BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在
|
||||
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20–50%、packages 为 50–98%,最终验证为 98–100%。三个阶段显示真实的 `1/3`、`2/3`、`3/3`,下载占各阶段的 84–87%;开始与最终验证不显示编号,单阶段状态不显示 `1/1`。官方按钮固定使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像按钮固定使用 CNB uv Release、CNB Python 解释器镜像和清华 PyPI,两种方式都不竞速或自动切换。MoGe-2 与 BEN2 属于 post-install,各自使用独立进度,不计入 Environment 的这轮 0–100%,其模型下载源逻辑不受按钮选择影响。
|
||||
任一必需模型在两个来源都下载失败时,`Install Environment` 会明确报错;Environment 本身仍会保留。
|
||||
选中 Image Empty 后,右键 `SnapForm` 子菜单会在缺少 `MoGe-2 ViT-S Normal` 或 `BEN2 Base`
|
||||
时显示 `Download Required Models`,一次补齐缺失项,不会重复下载已经就绪的模型。
|
||||
Environment 未安装时,同一位置显示两个安装按钮。Environment 或必需模型未就绪时,
|
||||
菜单仍显示 `Convert to Depth Plane`、`Convert to Surface` 和 `Remove Background`,但三个入口都会保持禁用。
|
||||
这些 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 删除背景
|
||||
- `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图颜色
|
||||
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
|
||||
4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用 `.color` 的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。
|
||||
|
||||
### 去除背景
|
||||
|
||||
选中 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。Server 通过统一的
|
||||
Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层 mask,DA3 保留 confidence 与边拉伸
|
||||
数据。两者再由独立的 Geometry Nodes
|
||||
修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background
|
||||
时,BEN2 Alpha 只要大于 `0` 就视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 为 `0`
|
||||
都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用 `.color`。匹配相机默认不创建;开启后会作为 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 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU,
|
||||
并在任务状态中显示实际 provider。
|
||||
|
||||
转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示
|
||||
`Resolution Scale`,MoGe-2 显示 `Resolution Level`,
|
||||
以及分割线下方的 `Keep Original`。
|
||||
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
|
||||
Geometry Nodes 修改器中调整;`Shadeless` 与 Depth Plane 共用 `O Shadeless` 材质节点组。颜色贴图始终使用统一的 `.color` 转换产物;Remove Background 只决定是否由 BEN2 修改 Alpha。颜色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。普通材质的
|
||||
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface 与 Depth Plane 的
|
||||
Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal
|
||||
同时驱动 Normal 与 Coat Normal;Subsurface、Emission、Coat 的其他设置保持默认。Surface attributes
|
||||
统一使用 `o_` 前缀;Server 准备过程显示
|
||||
`Preparing surface data n/6`,可看到当前阶段与总步骤;Surface 与 Depth 单帧模型推理都不显示多余的 `1/1`。
|
||||
|
||||
### 视频与序列帧
|
||||
|
||||
SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。`.color` 与 `.depth` 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认
|
||||
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
|
||||
Session 逐帧推理,显存峰值不随视频帧数平方增长。Debug 只保存模型 API 的直接返回:DA3 多帧输入
|
||||
保留联合推理,MoGe-2 与 BEN2 按各自的单图 API 逐帧保存。
|
||||
|
||||
## 任务结果保存在哪里
|
||||
|
||||
任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.snapform/jobs/`。Job Server 接受任务时自动生成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。视频或序列帧生成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 从 0.12.0–0.12.5 升级到 0.12.6 或更高版本后,请重新执行一次 `Install Environment`,重建不再单独安装 BlendJob 的 Server Environment
|
||||
- 首次使用需要下载模型,如果下载慢或失败,可在 Image Empty 的 `SnapForm` 右键菜单点击
|
||||
`Download Required Models`,也可在 `Models` panel 里逐项重试
|
||||
- 安装 Environment 时下载状态会显示平均速度;网络适合海外源时选择 `Install Environment`,国内网络选择 `Install Environment (Mirror)`。每个按钮固定使用对应来源,失败时不会自动切换
|
||||
- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源
|
||||
- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长
|
||||
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
|
||||
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧
|
||||
|
||||
## 技术文档
|
||||
|
||||
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。详细功能见
|
||||
[文档索引](docs/index.md),模块边界、调用流程、项目结构与开发命令见
|
||||
[程序设计、开发与许可证](docs/development.md)。CI 在 `main` push 与 Pull Request 上运行测试,仅由 `v*` tag 构建并发布扩展包;文档在 `main` 的文档相关文件变化时部署。
|
||||
- 按 **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** 处理单图、视频和序列帧
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# 视频与序列帧
|
||||
|
||||
SnapForm 可以逐帧处理视频和序列帧,并生成随场景时间播放的 Depth Plane 或透明 Image Empty。
|
||||
|
||||
## 支持的工作流
|
||||
|
||||
- **Convert to Depth Plane**:支持视频和序列帧。
|
||||
- **Remove Background**:支持视频和序列帧。
|
||||
- **Convert to Surface**:当前只支持单张图片。
|
||||
|
||||
## 设置处理范围
|
||||
|
||||
处理范围来自 Image Empty 自身的图片设置:
|
||||
|
||||
- **Image Frames** 决定处理的帧数。
|
||||
- **Offset** 决定从源素材的哪个位置开始。
|
||||
- **Start** 决定结果从场景的哪一帧开始播放。
|
||||
- **Cyclic** 会保留到生成的颜色与深度序列中。
|
||||
|
||||
显式把 Image Frames 设为 `1` 时,SnapForm 只处理一帧。范围超过视频长度时,SnapForm 会把窗口收回有效范围内。
|
||||
|
||||
## 性能建议
|
||||
|
||||
- 先用较少帧数和较低分辨率测试效果。
|
||||
- DA3 和 MoGe-2 的 Depth Plane 会逐帧推理并复用模型 Session,显存峰值主要由单帧分辨率决定。
|
||||
- 视频越长,处理时间和任务目录占用越大。
|
||||
- 任务运行期间可以从 Blender 状态栏查看进度并取消。
|
||||
|
||||
## 保存和移动工程
|
||||
|
||||
Blender 无法把整组动画图片序列完整打包进 `.blend`。SnapForm 因此让动画结果直接引用 Storage Root 的 Job 文件夹。
|
||||
|
||||
!!! danger
|
||||
删除、移动或清理仍被工程引用的 Job 文件夹,会导致动画颜色或深度序列丢失。
|
||||
|
||||
需要把工程移动到另一台机器时,请连同对应 Job 文件夹一起复制,并在 Blender 中重新定位缺失文件。也可以先把需要长期保留的序列复制到项目自己的素材目录,再用 Blender 的外部数据工具重新映射路径。
|
||||
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,73 +0,0 @@
|
||||
# Depth Plane
|
||||
|
||||
本页记录 Depth Plane 的数据、节点和材质实现。日常操作与参数说明请阅读
|
||||
[Depth Plane 使用指南](guide-depth-plane.md)。
|
||||
|
||||
## 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 则逐帧保存 API 直接返回的 points、depth、normal、mask 与 intrinsics,不生成网格或预览。
|
||||
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
|
||||
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
|
||||
`MoGe-2 ViT-S` 是默认深度来源。选择 `DA3` 时,其 Model 与 Resolution Scale
|
||||
参数会集中显示在独立边框中。`Remove Background` 与 `Shadeless` 直接显示,不再使用 Options 折叠;
|
||||
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
|
||||
`BEN2 Base`,并把 `.color` 的 Alpha 作为独立 Mask,不在 Server 中改写深度值。无论颜色层是直接
|
||||
复制原图,还是由 BEN2 为单图或序列修改 Alpha,Server 都统一写为 `color.0001.*`;Blender 只导入
|
||||
这一份 `.color`,不复用源 Empty 的 Image。颜色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。
|
||||
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
|
||||
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
|
||||
DA3 会复用同一个 ONNX Session 对每帧独立推理,再合并为原有序列输出,避免多帧 Attention 的平方级显存占用。
|
||||
DA3 与 MoGe-2 均先用未经抠图的原始 RGB 生成深度,再运行可选 BEN2;BEN2 Alpha 只参与最终输出过滤和材质 Mask。
|
||||
该行为只用于 Depth Plane 转换;Surface 仅支持单张图片。Debug 中 DA3 多帧输入联合推理,
|
||||
MoGe-2 与 BEN2 按单图 API 逐帧运行。
|
||||
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
|
||||
一致;Original 模式的视频或序列帧使用 `color.0001.*` 颜色层同时作为颜色与深度输入;开启
|
||||
Remove Background 时同名颜色层包含 BEN2 修改后的 Alpha。
|
||||
DA3 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下;MoGe-2 分别使用 `moge_depth`、`moge_mask` 与 `moge_normal` EXR 序列。
|
||||
DA3 与 MoGe-2 的单帧推理进度只显示模型与阶段名称,不追加 `1/1`;多帧时继续显示当前帧与总数。`Shadeless`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。`Shadeless` 会以 `O Shadeless` 节点组替代普通 Principled BSDF 路径,并继续通过 Alpha 与 Transparent BSDF 混合。
|
||||
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 位移和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入模型 normal。
|
||||
`O MoGe2 Depth Plane` 与 DA3 一样通过隐藏的 Color Image 读取 `.color` Alpha,以 `Alpha > 0.5`
|
||||
划分前景范围,并在范围外改用 `Background Depth`。两个修改器公开以下参数:
|
||||
- `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 与 Color Image 是修改器隐藏数据输入,
|
||||
由导入流程自动设置。
|
||||
`.color` 与 `.depth` 的 Blender Image 类型只依据源 Empty Image:普通单图使用 `IMAGE`,Movie 或 Sequence 使用 `SEQUENCE`;即使动画源这次只处理一帧也仍为 `SEQUENCE`,不按输出文件数量推断。视频与序列帧还会为 Depth 与 Color 共同写入隐藏的 `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,178 +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/jobs/` 按任务组织完整流程;DA3、MoGe-2、BEN2 Debug 各自拥有独立模块和 Job,不用模型分支总控函数
|
||||
- `server/models/` 包含三个模型适配器及其 ONNX 实现,只负责模型载入、推理和 Prediction;不接收 Job Context、不写文件
|
||||
- `server/geometry/` 包含投影、像素网格和唯一 Surface Builder,均为纯数据转换;不负责 Debug 或文件输出
|
||||
- `server/outputs/` 只负责 Depth、Surface 与各模型 Debug 的序列化;Debug writer 原样保存 API 字段,不构建派生几何
|
||||
- `server/media/input.py` 只处理图片、序列和视频输入;共享的可选前景流程保留在 `jobs/foreground.py`
|
||||
- 根目录 `model_catalog.py` 供 Blender UI 与本地就绪检查使用;`server/model_catalog.py` 独立声明下载与推理所需模型,二者的模型键、目录名和 `ready_patterns` 由测试约束一致
|
||||
- `server/__init__.py` 只注册 Job 与 Resource;`jobs/background.py`、`jobs/frames.py` 分别实现去背景和帧提取
|
||||
- 所有任务产物写入 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
|
||||
│ │ ├── jobs/
|
||||
│ │ │ ├── background.py
|
||||
│ │ │ ├── debug_ben2.py
|
||||
│ │ │ ├── debug_da3.py
|
||||
│ │ │ ├── debug_moge2.py
|
||||
│ │ │ ├── depth.py
|
||||
│ │ │ ├── foreground.py
|
||||
│ │ │ ├── frames.py
|
||||
│ │ │ └── surface.py
|
||||
│ │ ├── geometry/
|
||||
│ │ │ ├── projection.py
|
||||
│ │ │ ├── grid_mesh.py
|
||||
│ │ │ └── surface.py
|
||||
│ │ ├── media/
|
||||
│ │ │ └── input.py
|
||||
│ │ ├── model_download.py
|
||||
│ │ ├── model_catalog.py
|
||||
│ │ ├── model_manager.py
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── ben2.py
|
||||
│ │ │ ├── da3.py
|
||||
│ │ │ ├── moge2.py
|
||||
│ │ │ ├── onnx_ben2.py
|
||||
│ │ │ ├── onnx_da3.py
|
||||
│ │ │ ├── onnx_moge2.py
|
||||
│ │ │ └── onnx_runtime.py
|
||||
│ │ └── outputs/
|
||||
│ │ ├── debug_da3.py
|
||||
│ │ ├── debug_moge2.py
|
||||
│ │ ├── depth.py
|
||||
│ │ └── surface.py
|
||||
│ └── wheels/
|
||||
│ └── blendjob-0.1.13-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
|
||||
```
|
||||
|
||||
`.github/workflows/test.yml` 在 `main` push、Pull Request 与手动触发时使用 Python 3.11 和锁定依赖运行完整测试。`.github/workflows/build.yml` 只响应 `v*` tag,在再次通过完整测试后运行 `pack.py`,并把生成的 ZIP 上传到该 tag 对应的 Gitea Release。`pack.py` 从 `pyproject.toml` 的默认 `dependencies` 读取 Extension wheel 需求,按 Manifest 平台从 PyPI 下载 wheel,并把各平台对应的 wheel 列入包内 Manifest;`src/snapform/wheels/` 只保留本地资产,不作为 uv source。`.github/workflows/docs.yml` 在 `main` 的文档内容、MkDocs 配置或自身 Workflow 变化时部署,也可手动触发;它只使用 `docs` 依赖组,因此不安装默认依赖。Server Environment 与测试依赖声明在 `dev` 组,文档工具声明在 `docs` 组;新增依赖后应更新锁文件,不要在 Workflow 中另行维护下载清单。
|
||||
|
||||
本地预览用户文档:
|
||||
|
||||
```bash
|
||||
uv run mkdocs serve
|
||||
```
|
||||
|
||||
构建静态文档或使用 Mike 发布版本化文档:
|
||||
|
||||
```bash
|
||||
uv run mkdocs build
|
||||
uv run mike deploy <version> -p
|
||||
uv run mike set-default <version> -p
|
||||
```
|
||||
|
||||
### 文档部署
|
||||
|
||||
`.github/workflows/docs.yml` 在 `main` 分支的文档相关文件变化后运行,也可从 Gitea Actions 手动触发。工作流使用锁定的 Python 依赖严格构建 `site/`,将其放入与站点 URL 对应的 `public/snapform/`,再通过 Wrangler 部署为 Cloudflare Workers Static Assets。
|
||||
|
||||
在 Gitea 仓库的 **Settings > Actions** 中配置:
|
||||
|
||||
- Secret `CLOUDFLARE_ACCOUNT_ID`:Cloudflare Account ID
|
||||
- Secret `CLOUDFLARE_API_TOKEN`:限制到目标账号的 Cloudflare API Token,使用 **Edit Cloudflare Workers** 模板
|
||||
- Variable `CLOUDFLARE_WORKER_NAME`:承载文档的 Worker 名称
|
||||
|
||||
API Token 不写入仓库。Workflow 只在 `main` 上部署生产文档,不在 Pull Request 中读取 Cloudflare Secret。
|
||||
|
||||
目标 Worker 必须专门用于 SnapForm,并把 `docs.omoolab.xyz/snapform/*` 路由到该 Worker。每次 Wrangler 部署都会更新目标 Worker 的整套静态资源;如果一个 Worker 同时承载多个项目,应改由中央文档仓库聚合全部站点后统一部署,不能从各项目仓库分别覆盖它。
|
||||
|
||||
|
||||
Mike 不依赖 GitHub API;`mike deploy --branch <branch> --push` 也可以向 Gitea 保存版本化静态站点。当前自动部署先发布 `mkdocs build` 生成的单版本站点,等确定版本 URL 规则后再启用 Mike 分支,避免在 Cloudflare 上提前固化不合适的路径结构。
|
||||
|
||||
|
||||
|
||||
`pack.py` 根据 `blender_manifest.toml` 分别生成 Windows、macOS 和 Linux 扩展包,例如 `dist/SnapForm.v0.12.14.windows-x64.zip`。更换 `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
|
||||
```
|
||||
|
||||
验证脚本会自动把 `src/snapform/wheels/` 中的 BlendJob wheel 加入 Blender Python 路径,因此可在
|
||||
`--factory-startup` 的干净环境中直接运行。
|
||||
|
||||
只改 Python、文档或打包逻辑时不需要重建 `.blend` 资产;修改节点组、材质组或其接口时,应运行相应资产验证脚本,并再次运行完整测试。
|
||||
|
||||
## 上游与许可证
|
||||
|
||||
- Depth Anything 3 代码采用 Apache 2.0
|
||||
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
|
||||
- SnapForm Extension 代码采用 GPL-3.0-or-later
|
||||
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示
|
||||
57
docs/faq.md
57
docs/faq.md
@@ -1,57 +0,0 @@
|
||||
# 常见问题
|
||||
|
||||
## 找不到 SnapForm 右键菜单
|
||||
|
||||
SnapForm 菜单只会在选中 Image Empty 时出现。确认对象类型是 Empty,并且显示方式为 Image。普通 Mesh、图片编辑器中的图片或未选中的 Image Empty 都不会显示入口。
|
||||
|
||||
## 转换入口是灰色的
|
||||
|
||||
通常有以下原因:
|
||||
|
||||
- Environment 尚未安装。
|
||||
- 默认必需模型尚未就绪。
|
||||
- 另一个 SnapForm 任务正在运行。
|
||||
- Surface 的输入不是单张图片。
|
||||
|
||||
如果子菜单显示 **Install Environment** 或 **Download Required Models**,先完成对应操作。
|
||||
|
||||
## Environment 安装失败
|
||||
|
||||
- 海外源访问不稳定时尝试 **Install Environment (Mirror)**。
|
||||
- 检查 Blender 的 **Online Access** 是否开启。
|
||||
- 确认 Storage Root 有足够空间且可写。
|
||||
- 查看 Storage Root 下的 `install.log`。
|
||||
|
||||
安装模型失败不会删除已经成功创建的 Environment。可以稍后使用 **Download Required Models** 或 Models 面板重试。
|
||||
|
||||
## 模型下载很慢或失败
|
||||
|
||||
模型会尝试 Hugging Face 和 OmooLab 备用来源。检查网络、代理和磁盘空间后重新点击 Download。模型直链连接或长时间无数据时会自动尝试下一来源。
|
||||
|
||||
## 第一次转换较慢
|
||||
|
||||
第一次任务需要启动 Job Server 并把模型载入内存。Server 运行且模型仍在缓存中时,后续同类任务通常会更快。
|
||||
|
||||
## Depth Plane 太平或深度方向反了
|
||||
|
||||
在 Geometry Nodes 修改器中调整 **Depth Scale**。增大绝对值会加强深度,使用负值可以反转前后关系。
|
||||
|
||||
## Surface 边缘破碎或细节太多
|
||||
|
||||
先调整 **Depth Continuity** 清理断层,再提高 **Resolution** 简化网格,并用 **Smooth** 稳定表面。必要时尝试另一个模型或降低输入分辨率。
|
||||
|
||||
## 动画面片不播放
|
||||
|
||||
检查源 Image Empty 的 **Image Frames** 是否大于 `1`,并确认场景时间正在变化。生成后的动画还需要对应 Job 文件夹存在;文件被移动或删除时应重新定位序列。
|
||||
|
||||
## 可以删除 jobs 文件夹吗
|
||||
|
||||
静态图的部分结果会 Pack 到 `.blend`,但视频与序列帧会直接引用 Job 文件。只有确认没有工程继续引用对应目录时才应删除。
|
||||
|
||||
## 如何完全撤销一次转换
|
||||
|
||||
转换完成后直接按 `Ctrl+Z`。SnapForm 会把网格、材质、图片和修改器的创建合并为一次 Blender Undo 操作。
|
||||
|
||||
## 从旧版本升级后功能不可用
|
||||
|
||||
如果 Environment 声明或依赖发生变化,请重新执行一次 **Install Environment**。从 0.12.0–0.12.5 升级到 0.12.6 或更高版本时需要重建 Environment。
|
||||
105
docs/features.md
105
docs/features.md
@@ -1,105 +0,0 @@
|
||||
# 功能参考
|
||||
|
||||
本页记录完整功能行为和实现边界,主要供开发、测试与故障排查使用。第一次使用 SnapForm 时,请从
|
||||
[安装与首次配置](installation.md)或[五分钟上手](quick-start.md)开始。
|
||||
|
||||
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
|
||||
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
||||
作为首个 AI 能力,在独立 Environment 中运行 DA3,可将模型 API Prediction 保存在独立 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` 也分别默认折叠
|
||||
- DA3、MoGe-2 与 BEN2 分别使用独立 Debug Job,直接保存各自 API 返回,不共享按模型分支的总控函数
|
||||
- DA3 Debug 保存 Prediction NPZ;MoGe-2 Debug 保存逐帧原始预测 NPZ;BEN2 Debug 保存直接返回的透明前景
|
||||
- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带
|
||||
`(Missing)`,已下载模型保持原名
|
||||
- Debug 不运行点云、Surface、深度预览、置信度 percentile 或坐标变换等 SnapForm 后处理
|
||||
- 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`、`Convert to Surface` 和 `Remove Background`。Environment 未安装时,
|
||||
同一位置改为显示 `Install Environment` 与 `Install Environment (Mirror)`,三个禁用的功能入口仍然可见
|
||||
- 菜单、模型面板和转换对话框直接检查 Storage Root 的 `models/` 必需文件,显示模型状态时不会启动 Job Server
|
||||
- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度,
|
||||
`Remove Background` 逐帧抠图,入口保持不变。DA3 转换时每帧独立执行 ONNX 推理并复用模型 Session;
|
||||
DA3 Debug 多帧任务继续联合推理;MoGe-2 与 BEN2 Debug 按单图 API 逐帧运行
|
||||
- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧);
|
||||
Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧
|
||||
- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片
|
||||
- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整
|
||||
- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景
|
||||
- Debug 完整保留模型 API 返回的数组字段,不混入 SnapForm 派生几何
|
||||
- 安装和模型下载使用后台子进程;推理提交给常驻 Server,状态栏实时显示阶段、百分比进度条与取消按钮
|
||||
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
|
||||
|
||||
## 使用
|
||||
|
||||
1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、
|
||||
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.12.17.macos-arm64.zip`)。
|
||||
安装包不携带 uv 或 AI 项目源码;首次安装 Environment 时下载固定版本 uv,并校验固定 SHA-256。官方安装按钮使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像按钮使用 CNB uv Release、CNB Python 解释器镜像和清华 PyPI。
|
||||
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
|
||||
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
|
||||
(Image Empty 右键菜单)使用的推理设备。Windows 默认取 DirectML,否则 CPU;Apple M4 或更新芯片列出 MPS/CoreML 与 CPU,Intel、M1–M3 和无法识别的 Mac 只列出 CPU。
|
||||
4. 在 `Job Server` 选择 `Install Environment` 或 `Install Environment (Mirror)`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
|
||||
uv 由 Job Runtime 的 Environment 安装器下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
|
||||
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。首次安装
|
||||
Environment Bootstrap 的 0–100% 只包含 uv、Python 与 packages:三阶段显示 `1/3`、`2/3`、`3/3`,范围分别为 1–20%、20–50%、50–98%,最终验证使用 98–100%。下载占各阶段的 84–87%;开始与验证不显示编号,单阶段状态不显示 `1/1`。
|
||||
两个默认模型由普通的 `post_install(runtime)` 发起正式 Server Job。
|
||||
安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
|
||||
Environment packages 使用固定的 Resolving、Downloading 与 Installing 状态,原始 uv 输出写入 `install.log`。模型下载显示速度,例如
|
||||
`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 失败。
|
||||
官方安装固定使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像安装固定使用 CNB uv Release、`python-build-standalone` 镜像和清华 PyPI。两种方式都不测速、不竞速、不自动切换,镜像配置只注入当前安装子进程。模型仍按自身的 Hugging Face 与 OmooLab R2 逻辑下载。
|
||||
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
|
||||
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。
|
||||
7. 展开默认收起的 `Debug`,在 DA3、MoGe-2 或 BEN2 区域选择模型与输入,然后直接运行对应 API。
|
||||
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 使用原图去掉格式后再追加 `.color` 的名称。例如 `img.png` 生成
|
||||
`img.color`,`img.png.001` 生成 `img.001.color`。已有同名 datablock 时由
|
||||
Blender 自动追加编号。
|
||||
|
||||
原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack
|
||||
状态和原始内容。正式任务的 `color.0001.png` 也会保留在对应 job 目录中。
|
||||
|
||||
Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames
|
||||
数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度,
|
||||
会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出
|
||||
`color.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把
|
||||
关闭去背景时,原始颜色帧同样以 `color.0001.*` 起始的统一名称写入 job 目录。替换 Empty 的 Image 为
|
||||
加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留)。图片类型只取决于源 Empty:普通单图的单个 `color.0001.*` 仍作为 `IMAGE`,Movie/Sequence 即使只导出一帧也作为 `SEQUENCE`;`.depth` 同样遵循该规则。
|
||||
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,57 +0,0 @@
|
||||
# Depth Plane
|
||||
|
||||
Depth Plane 将图片转换为带位移的立体面片。它适合快速场景搭建、2.5D 合成、立体插画以及视频素材。
|
||||
|
||||
## 创建 Depth Plane
|
||||
|
||||
1. 选中 Image Empty。
|
||||
2. 在 3D View 中右键,选择 **SnapForm > Convert to Depth Plane**。
|
||||
3. 选择深度来源和需要的选项。
|
||||
4. 确认后等待任务完成。
|
||||
|
||||
## 选择 Depth From
|
||||
|
||||
### MoGe-2
|
||||
|
||||
默认推荐。MoGe-2 会估算尺度一致的深度、法线和有效区域,通常适合单张图片和逐帧动画。模型越大,细节潜力和资源占用通常越高。
|
||||
|
||||
- **Resolution Level** 越高,处理更细致,也会更慢并占用更多内存。
|
||||
- 第一次使用建议保持默认值 `5`。
|
||||
|
||||
### DA3
|
||||
|
||||
适合尝试不同的深度估算结果。DA3 使用 **Resolution Scale** 控制处理尺寸;界面会显示实际 Process Res。
|
||||
|
||||
- 较低比例适合预览和显存有限的设备。
|
||||
- 较高比例可能保留更多细节,但不保证每张图片都更好。
|
||||
|
||||
### Original
|
||||
|
||||
直接把原图亮度作为深度来源,不运行深度估算模型。它适合已经准备好灰度高度图或手绘深度图的情况。
|
||||
|
||||
## 转换选项
|
||||
|
||||
- **Remove Background**:转换期间使用 BEN2 去除背景,让 Alpha 同时控制可见区域和背景深度。
|
||||
- **Shadeless**:让贴图颜色不受场景灯光影响,适合插画、合成和需要保持原图观感的场景。
|
||||
- **Keep Original**:保留源 Image Empty。默认关闭,转换成功后由新网格替代原对象。
|
||||
|
||||
## 调整结果
|
||||
|
||||
生成对象的 Geometry Nodes 修改器提供以下常用参数:
|
||||
|
||||
- **Displacement**:`Geometry` 直接移动网格;`Material` 保持平面并使用材质置换。
|
||||
- **Subdivision**:控制网格密度。数值越高,深度细节越多,场景也越重。
|
||||
- **Background Depth**:设置背景区域的位置。
|
||||
- **Depth Scale**:控制深度强度;负值可以反转前后关系。
|
||||
|
||||
调整时建议先降低 Subdivision 提高交互速度,确定 Depth Scale 后再提高细分。
|
||||
|
||||
## 动画输入
|
||||
|
||||
Depth Plane 支持视频和序列帧。处理范围跟随 Image Empty 的 **Image Frames** 与 **Offset**,生成后的颜色和深度会随场景帧播放。
|
||||
|
||||
动画结果会引用任务目录中的图片序列。移动工程或清理磁盘前,请先阅读[视频与序列帧](animation.md)和[模型、设备与存储](models-and-storage.md)。
|
||||
|
||||
## 进一步了解
|
||||
|
||||
需要查看节点、数据字段、色彩空间和位移计算方式时,请阅读[Depth Plane 实现参考](depth-plane.md)。
|
||||
@@ -1,46 +0,0 @@
|
||||
# Surface
|
||||
|
||||
Surface 从单张图片构建带透视关系的纹理网格。它适合建筑、室内、街景和需要匹配原始观察视角的画面。
|
||||
|
||||
!!! warning
|
||||
Surface 当前只支持单张图片。视频和序列帧请使用 [Depth Plane](guide-depth-plane.md)。
|
||||
|
||||
## 创建 Surface
|
||||
|
||||
1. 选中只包含一帧的 Image Empty。
|
||||
2. 在 3D View 中右键,选择 **SnapForm > Convert to Surface**。
|
||||
3. 选择模型和转换选项。
|
||||
4. 确认后等待网格、材质和 Geometry Nodes 修改器创建完成。
|
||||
|
||||
## 模型与分辨率
|
||||
|
||||
默认的 **MoGe-2 ViT-S Normal** 适合多数图片。MoGe-2 使用 **Resolution Level**,DA3 使用 **Resolution Scale**。提高分辨率会增加耗时和内存占用,建议先用默认值判断整体结构。
|
||||
|
||||
如果某个模型名称后显示 **(Missing)**,请先在 3D View 的 **SnapForm > Models** 面板下载它。
|
||||
|
||||
## 转换选项
|
||||
|
||||
- **Create Matching Camera**:创建与图片投影相匹配的透视相机。相机会成为 Surface 的子级,移动 Surface 时仍保持对应关系。
|
||||
- **Remove Background**:在生成网格前删除背景三角面。转换后关闭节点中的 Mask 也不会恢复这些面。
|
||||
- **Shadeless**:让贴图颜色不受场景灯光影响。
|
||||
- **Keep Original**:保留源 Image Empty。
|
||||
|
||||
## 调整网格
|
||||
|
||||
生成对象的 Geometry Nodes 修改器负责剔除深度断层、合并网格、修复拓扑、平滑和重新投影 UV。常用参数包括:
|
||||
|
||||
- **Iterations**:控制重拓扑迭代次数。设为 `0` 可以跳过迭代,便于观察原始结构或提高速度。
|
||||
- **Resolution**:控制合并后的网格密度。值越大,结果越简化。
|
||||
- **Smooth**:控制表面平滑程度。
|
||||
- **Depth Continuity**:剔除深度断层处的面。提高数值会更积极地清理边缘。
|
||||
- **Confidence**:DA3 Surface 可用,用于过滤低置信度区域。
|
||||
|
||||
先调整 Culling 让主体边缘干净,再调整 Resolution 和 Smooth,通常更容易获得稳定结果。
|
||||
|
||||
## 匹配相机的使用方式
|
||||
|
||||
开启 **Create Matching Camera** 后,切换到生成的相机视图即可重新对齐原图构图。Surface 与相机存在父子关系,建议把二者作为一个整体移动或缩放。
|
||||
|
||||
## 进一步了解
|
||||
|
||||
需要查看源网格属性、重拓扑过程、相机匹配和材质法线实现时,请阅读[Surface 实现参考](surface.md)。
|
||||
@@ -1,54 +1,60 @@
|
||||
# SnapForm
|
||||
# 🖼️ AnyImage
|
||||
|
||||
把图片里的空间感带进 Blender。
|
||||
**IMAGE is All You Need**
|
||||
*图,就够了~*
|
||||
|
||||
SnapForm 是一款 Blender 扩展,可以把照片、视频或序列帧转换为带真实深度变化的立体面片。你只需导入图片、选中 Image Empty,然后从右键菜单选择需要的结果。
|
||||
AnyImage 是 OmooLab 为 Blender 准备的图片工具。它让图片不再只是参考图:粘贴进来,选出需要的部分,再把它变成带材质、轮廓与深度的场景元素。
|
||||
|
||||
- 用 **Depth Plane** 快速得到可调节的浮雕式面片
|
||||
- 用 **Surface** 从单张图片生成透视纹理网格
|
||||
- 用 **Remove Background** 一键获得透明前景
|
||||
- 处理视频和序列帧,生成可随时间播放的 Depth Plane
|
||||
- 使用 Geometry Nodes 继续调整深度、细分、重拓扑与平滑效果
|
||||
## 从图片直接开始
|
||||
|
||||
!!! info "运行环境与 Blender 分离"
|
||||
SnapForm 的 AI 计算运行在独立环境中,不会把 ONNX Runtime 等计算依赖装进 Blender Python。首次配置完成后,日常操作都可以从 Image Empty 的右键菜单完成。
|
||||
在 3D View 或 Node Editor 中按 **Ctrl+V**(macOS 为 **Cmd+V**),剪贴板里的图片会直接进入当前工作区。不必先保存文件,也不用中断正在进行的搭建。
|
||||
|
||||
## 从这里开始
|
||||
已有图片同样可以继续使用。选中 Image Empty 后,右键打开 **AnyImage** 菜单,常用操作都在这里。
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
## 一张图,多种空间表达
|
||||
|
||||
- **安装 SnapForm**
|
||||
- **Plane / Depth Plane**:把图片变成平面或立体面片。
|
||||
- **Cutout**:圈选主体,创建 Surface、Balloon 或 Depth Shape。
|
||||
- **图片工具**:重新取景、编辑透明区域、校正透视、去背景和高清放大。
|
||||
|
||||
下载与你的系统匹配的扩展包,完成环境和默认模型配置。
|
||||
## 快速开始
|
||||
|
||||
[阅读安装指南](installation.md)
|
||||
按住下方按钮并拖到 Blender 窗口即可安装。首次拖入会提示添加 AnyImage 扩展仓库,确认后再拖入一次。
|
||||
|
||||
- **五分钟上手**
|
||||
|
||||
从导入一张图片开始,完成第一次 Depth Plane 转换。
|
||||
|
||||
[开始第一个作品](quick-start.md)
|
||||
|
||||
- **选择合适的结果**
|
||||
|
||||
了解 Depth Plane、Surface 和去背景之间的区别。
|
||||
|
||||
[查看工作流对比](workflows.md)
|
||||
|
||||
- **遇到问题?**
|
||||
|
||||
检查模型下载、菜单状态、动画文件和运行日志。
|
||||
|
||||
[查看常见问题](faq.md)
|
||||
|
||||
<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 4.5 或更高版本
|
||||
- Windows x64、macOS Apple Silicon 或 Linux x64
|
||||
- 首次安装环境与模型时需要网络连接
|
||||
- 正式使用前需要安装独立 Environment,以及默认的 MoGe-2 和 BEN2 模型
|
||||
复制一张图片。在 Blender 的 3D View 中按 **Ctrl+V**(macOS 为 **Cmd+V**)。
|
||||
|
||||
!!! tip
|
||||
不确定从哪里开始时,先使用默认设置将一张人物或物体照片转换为 Depth Plane。它速度快、参数直观,也最适合熟悉 SnapForm 的基本工作方式。
|
||||
选中图片并右键,打开 **AnyImage**,选择 **Convert to Plane**。
|
||||
|
||||
使用 Depth Plane、Remove Background 或 Upscale 时,按提示安装 AI Environment 即可。
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# 安装与首次配置
|
||||
|
||||
SnapForm 的安装分为两步:先安装 Blender Extension,再安装独立运行环境和默认模型。第二步只需在首次使用或环境版本发生变化时执行。
|
||||
|
||||
## 安装 Blender Extension
|
||||
|
||||
1. 下载与你的系统匹配的 ZIP 文件:
|
||||
- `SnapForm.v版本号.windows-x64.zip`
|
||||
- `SnapForm.v版本号.macos-arm64.zip`
|
||||
- `SnapForm.v版本号.linux-x64.zip`
|
||||
2. 打开 Blender,进入 **Edit > Preferences > Extensions**。
|
||||
3. 点击右上角菜单,选择 **Install from Disk**。
|
||||
4. 选择下载的 ZIP 文件并完成安装。
|
||||
|
||||
!!! warning
|
||||
不要解压扩展包。Blender 需要直接安装 ZIP 文件,并且安装包必须与你的操作系统匹配。
|
||||
|
||||
## 检查扩展偏好设置
|
||||
|
||||
在 SnapForm 的扩展偏好设置中可以看到两个选项:
|
||||
|
||||
- **Storage Root**:保存运行环境、模型和任务文件的位置。默认是用户目录下的 `.snapform`,一般无需修改。
|
||||
- **Device**:正式转换任务使用的计算设备。SnapForm 默认选择本机可用的最快设备;如果 GPU Provider 不可用,会使用 CPU。
|
||||
|
||||
如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。
|
||||
|
||||
## 安装 Environment
|
||||
|
||||
打开 3D View,按 `N` 显示侧栏并切换到 **SnapForm** 页签。在 **Job Server** 面板中选择一个安装入口:
|
||||
|
||||
- **Install Environment**:使用官方源,适合能稳定访问 GitHub、Python 下载源和 PyPI 的网络。
|
||||
- **Install Environment (Mirror)**:使用国内镜像下载 uv、Python 和 Python 包。
|
||||
|
||||
安装程序会依次准备 uv、独立 Python 和运行包。Environment 完成后,SnapForm 还会下载日常功能需要的 **MoGe-2 ViT-S Normal** 与 **BEN2 Base**。
|
||||
|
||||
!!! note
|
||||
两个安装按钮只影响 Environment 的下载源。模型会按自己的下载源与备用镜像规则获取。
|
||||
|
||||
安装过程中请保持 Blender 打开。状态栏会显示当前阶段、进度和下载速度。首次安装耗时取决于网络与磁盘速度。
|
||||
|
||||
## 确认安装完成
|
||||
|
||||
完成后可以用以下方式确认:
|
||||
|
||||
- **Job Server** 不再显示 “Environment is not installed”。
|
||||
- 展开 **Models** 面板,MoGe-2 ViT-S Normal 和 BEN2 Base 显示 **Ready**。
|
||||
- 选中 Image Empty 后,右键菜单中的 **Convert to Depth Plane**、**Convert to Surface** 和 **Remove Background** 可以点击。
|
||||
|
||||
如果 Environment 已安装但默认模型缺失,Image Empty 的 **SnapForm** 右键子菜单会显示 **Download Required Models**。点击一次即可补齐缺失项。
|
||||
|
||||
## 下一步
|
||||
|
||||
继续阅读[五分钟上手](quick-start.md),用一张图片完成第一次转换。
|
||||
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,53 +0,0 @@
|
||||
# 模型、设备与存储
|
||||
|
||||
SnapForm 把运行环境、模型和任务结果放在 Blender 之外,多个 Blender 版本可以共享同一套数据。
|
||||
|
||||
## Models 面板
|
||||
|
||||
打开 3D View 侧栏中的 **SnapForm > Models**,可以查看和下载模型。
|
||||
|
||||
- **Ready** 表示模型文件已经就绪。
|
||||
- **Download** 用于按需下载模型。
|
||||
- 默认安装会准备 MoGe-2 ViT-S Normal 和 BEN2 Base。
|
||||
- DA3 与更大的 MoGe-2 模型需要时再下载即可。
|
||||
|
||||
较大的模型不一定适合所有图片。建议先用默认模型完成构图,再针对细节与性能需求比较其他模型。
|
||||
|
||||
## Device
|
||||
|
||||
扩展偏好设置中的 **Device** 控制 Depth Plane、Surface 和 Remove Background 等正式任务。
|
||||
|
||||
- Windows 通常优先使用 DirectML。
|
||||
- Apple Silicon macOS 通常优先使用 CoreML。
|
||||
- Provider 不可用时会回退到 CPU。
|
||||
|
||||
Debug 面板中的 **Debug Device** 只影响 Debug 任务,不会覆盖正式任务的 Device。
|
||||
|
||||
## Storage Root
|
||||
|
||||
默认 Storage Root 是 `~/.snapform`,主要包含:
|
||||
|
||||
```text
|
||||
~/.snapform/
|
||||
├── .venv/ # 独立 Python Environment
|
||||
├── models/ # 模型文件
|
||||
├── jobs/ # 每次任务的结果
|
||||
├── install.log # Environment 安装日志
|
||||
└── server.log # Job Server 日志
|
||||
```
|
||||
|
||||
更改 Storage Root 相当于切换到另一套环境、模型和任务数据。SnapForm 不会自动移动旧目录。
|
||||
|
||||
## Job 文件夹
|
||||
|
||||
每个任务会在 `jobs/` 下创建一个独立目录。单张图生成的关键贴图通常会 Pack 到 `.blend`;视频和序列帧则继续引用 Job 目录中的文件。
|
||||
|
||||
可以清理已经确认不再使用的任务目录,但清理前应检查是否有 `.blend` 仍引用其中的序列帧。
|
||||
|
||||
## 日志
|
||||
|
||||
遇到安装或 Server 问题时:
|
||||
|
||||
- 在扩展偏好设置中点击 **Open Server Log** 查看运行日志。
|
||||
- Environment 安装问题查看 Storage Root 下的 `install.log`。
|
||||
- 修改 Storage Root 后,确认你检查的是当前目录中的日志。
|
||||
@@ -1,37 +0,0 @@
|
||||
# 输出格式
|
||||
|
||||
本页说明任务文件的字段与序列化约定,主要用于开发与数据检查。普通使用者只需了解
|
||||
[模型、设备与存储](models-and-storage.md)中的 Job 文件夹说明。
|
||||
|
||||
## Debug
|
||||
|
||||
Debug 用于直接比较 DA3、MoGe-2 与 BEN2 模型 API 的返回效果,不复用 Depth、Surface 或 Geometry
|
||||
后处理。三个入口对应三个独立 Job:
|
||||
|
||||
- debug-da3:保存 prediction.npz,字段直接来自 DA3 Prediction,包括 depth、conf、
|
||||
extrinsics、intrinsics、processed_images 及模型实际返回的可选标量或数组
|
||||
- debug-moge2:每张输入保存一个 prediction.npz 或 prediction.0001.npz,完整保留 MoGe-2 API
|
||||
字典中的 points、depth、normal、mask 与 intrinsics
|
||||
- debug-ben2:直接保存 BEN2 API 返回的 foreground.png 或 foreground.0001.png
|
||||
|
||||
Debug 不生成点云、三角网格、深度预览、置信度 percentile 或坐标变换后的派生产物。输入为多帧时,
|
||||
DA3 保留联合推理;MoGe-2 与 BEN2 按其单图 API 逐帧保存结果。
|
||||
|
||||
## Depth
|
||||
|
||||
generate-depth 输出 Blender Depth Plane 所需的 results.npz。DA3 保存 depth、conf、
|
||||
extrinsics 与 intrinsics;MoGe-2 保存 depth、normal、mask 与 intrinsics,并额外写出
|
||||
Depth Plane 导入所需的贴图。这里的文件转换属于正式 Depth 工作流,不属于 Debug。
|
||||
|
||||
## Surface
|
||||
|
||||
generate-surface 统一写出 `mesh.npz`,不再生成 `texture.png`。DA3 与 MoGe-2 都使用
|
||||
`geometry/surface.py` 的同一套 Surface 构建算法;序列化位于 `outputs/surface.py`。MoGe-2 normal
|
||||
仍由 Server 单独导出为 `normal.png`,Blender 只负责加载并用于材质,不写入几何 NPZ。未去背景时
|
||||
无论颜色来自原图复制还是 BEN2,Server 都统一写为 `color.0001.*` 起始的颜色层。Blender 不依据文件数量判断图片类型:源 Empty 为普通单图时载入为 `IMAGE`,源 Empty 为 Movie/Sequence 时即使只有一帧也载入为 `SEQUENCE`;`.depth` 使用相同规则。材质只在 Blender
|
||||
端创建。MoGe-2 的 `normal.png` 仍由 Server 生成,Blender 只负责加载使用。
|
||||
|
||||
## BEN2
|
||||
|
||||
正式 remove-background 工作流输出 `color.0001.png` 起始的透明颜色层。BEN2 ONNX API 返回的 Alpha
|
||||
恢复到输入分辨率,RGB 保留输入图像颜色。Debug BEN2 仍保留模型 API 语义,使用 `foreground` 文件名。
|
||||
@@ -1,48 +0,0 @@
|
||||
# 五分钟上手
|
||||
|
||||
这个流程会把一张图片转换为可继续编辑的 Depth Plane。
|
||||
|
||||
## 1. 导入图片
|
||||
|
||||
在 3D View 中选择 **Add > Image > Reference**,导入一张图片。Blender 会创建一个显示方式为 Image 的 Empty。
|
||||
|
||||
建议第一次尝试使用主体清晰、前后景关系明确的图片。人物、产品、建筑立面和插画通常都比较适合。
|
||||
|
||||
## 2. 打开 SnapForm 菜单
|
||||
|
||||
保持 Image Empty 处于选中状态,在 3D View 中点击右键,选择 **SnapForm > Convert to Depth Plane**。
|
||||
|
||||
如果菜单不存在,请确认当前对象是 Image Empty;如果转换入口是灰色,请先完成[安装与首次配置](installation.md)。
|
||||
|
||||
## 3. 使用默认设置转换
|
||||
|
||||
第一次使用建议保留默认设置:
|
||||
|
||||
- **Depth From**:MoGe-2 ViT-S Normal
|
||||
- **Resolution Level**:5
|
||||
- **Remove Background**:关闭
|
||||
- **Shadeless**:关闭
|
||||
- **Keep Original**:关闭
|
||||
|
||||
点击确认后,状态栏会显示模型加载和处理进度。首次推理需要加载模型,之后连续处理通常会更快。
|
||||
|
||||
## 4. 查看和调整结果
|
||||
|
||||
转换完成后,原 Image Empty 会被新的网格对象替代。打开 Geometry Nodes 修改器,可以调整:
|
||||
|
||||
- **Displacement**:选择直接改变几何,或改用材质置换。
|
||||
- **Subdivision**:控制面片细分程度。
|
||||
- **Background Depth**:控制透明区域或背景区域的深度。
|
||||
- **Depth Scale**:放大、减小或反转深度变化。
|
||||
|
||||
如果希望保留原 Image Empty,转换前开启 **Keep Original**。
|
||||
|
||||
## 5. 让画面不受灯光影响
|
||||
|
||||
希望颜色尽量贴近原图时,在转换对话框中开启 **Shadeless**。需要让结果参与场景光照、阴影和材质表现时,则保持关闭。
|
||||
|
||||
## 接下来可以尝试
|
||||
|
||||
- 主体需要透明背景:[转换时去除背景](remove-background.md)
|
||||
- 想要更强的透视结构:[转换为 Surface](guide-surface.md)
|
||||
- 输入是视频或序列帧:[制作动画 Depth Plane](animation.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
# 去除背景
|
||||
|
||||
Remove Background 使用 BEN2 为 Image Empty 生成透明前景,不创建网格。它适合在转换前检查抠图效果,或继续把结果当作普通 Image Empty 使用。
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 选中 Image Empty。
|
||||
2. 在 3D View 中右键,选择 **SnapForm > Remove Background**。
|
||||
3. 等待状态栏提示任务完成。
|
||||
|
||||
SnapForm 会创建新的透明颜色图,并把当前 Empty 切换到新图。对象的 Transform、显示尺寸、Offset 和其他显示设置保持不变。
|
||||
|
||||
## 在转换时去除背景
|
||||
|
||||
Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**:
|
||||
|
||||
- Depth Plane 会使用透明 Alpha 区分前景和背景深度。
|
||||
- Surface 会在写出网格前删除背景三角面。
|
||||
- 深度估算仍使用未经抠图的原始 RGB,避免透明区域提前影响模型判断。
|
||||
|
||||
## 图片、视频和序列帧
|
||||
|
||||
普通图片会得到一张透明 PNG。视频或序列帧会得到透明图片序列,并从源 Empty 的 Start 位置开始播放。处理范围跟随 **Image Frames** 和 **Offset**。
|
||||
|
||||
!!! note
|
||||
动画结果引用 Storage Root 中任务目录里的序列帧。不要在仍使用这些素材时删除对应的 Job 文件夹。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Remove Background 是灰色的
|
||||
|
||||
确认 Environment 已安装,且 **MoGe-2 ViT-S Normal** 与 **BEN2 Base** 都显示 Ready。正式功能会等默认必需模型全部就绪后启用。
|
||||
|
||||
### 透明边缘需要调整
|
||||
|
||||
当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。
|
||||
129
docs/server.md
129
docs/server.md
@@ -1,129 +0,0 @@
|
||||
# Job Server
|
||||
|
||||
本页记录 Environment、模型缓存和 Job Runtime 的内部设计。安装或使用遇到问题时,请先查看
|
||||
[安装与首次配置](installation.md)和[常见问题](faq.md)。
|
||||
|
||||
## 运行架构
|
||||
|
||||
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 Client 与独立 Server 的通用 HTTP Runtime 都复用 `src/snapform/wheels/` 中随扩展分发的同一份 BlendJob package;Server Environment 不再从 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 不需要重复声明;
|
||||
- Blender Client 使用根目录 `model_catalog.py` 构造 UI 并检查 Storage Root 下的模型必需文件;
|
||||
- 独立 Server 使用 `server/model_catalog.py` 下载和验证模型,不依赖 Extension 根 package。两份声明的共享存储字段由测试保持一致,已加载 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_depth` → `generate-depth`
|
||||
- `generate_surface` → `generate-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`,这里只负责注册 Job 与 Resource。完整工作流位于 `jobs/`;模型适配器和
|
||||
ONNX 实现位于 `models/`;纯投影、网格与唯一 Surface Builder 位于 `geometry/`;文件序列化位于
|
||||
`outputs/`;输入准备位于 `media/`。模型下载由 `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
|
||||
- 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。Blender 侧通过 `sysctl` 识别 Apple Silicon 代际,只有 M4 或更新芯片才提供 MPS 选项;其他 Mac 只提供 CPU。M4 或更新芯片的 CoreML Session 使用 `MLProgram` 模型格式、全部可用计算单元并允许动态输入 Shape。
|
||||
|
||||
`environment.json` 保存规范化后的声明,`manifest.json` 保存该声明与基础 Server packages 的 Hash。Python、packages、平台 packages、基础 Server packages 或 Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。BlendJob 直接随 Extension 更新,不参与 Environment Hash。
|
||||
|
||||
## 模型
|
||||
|
||||
所有正式功能都使用 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 内持有磁盘就绪状态和内存 Session。Blender UI 使用 Client catalog 声明的目录名与 `ready_patterns` 直接检查 `<storage-root>/models/`,因此展开模型面板、打开转换对话框或 Image Empty 右键菜单都不会仅为状态检测启动 Server。真正下载或推理时,Server 仍按自身 catalog 通过 `ModelManager.ready()` 重新验证文件。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/model_manager` 只返回当前加载状态;`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 后处理中恢复。
|
||||
|
||||
|
||||
## 安装网络与镜像
|
||||
|
||||
安装器不做带宽测速、来源竞速或失败后的自动切换。`Install Environment` 固定使用 Astral GitHub Release、uv 默认 Python 下载源和 PyPI;`Install Environment (Mirror)` 固定使用 CNB uv Release、CNB `python-build-standalone` 与清华 PyPI。两个 uv 来源使用同一固定版本和 SHA-256;下载压缩包只作临时文件,安装完成后删除。
|
||||
|
||||
镜像模式只在当前安装子进程中设置 `UV_PYTHON_INSTALL_MIRROR` 与 `UV_INDEX_URL`,不修改用户环境变量。Windows 系统代理会注入子进程;同时支持 `HTTP_PROXY`、`HTTPS_PROXY` 与 `ALL_PROXY`。post-install 模型下载保持 Hugging Face 与 OmooLab R2 的独立来源逻辑,不读取 Environment 安装模式。
|
||||
|
||||
Environment Bootstrap 的单次 0–100% 只包含 uv、Python 与 packages,不包含后续模型下载。开始显示 `Starting installation ...`;uv 使用 1–20%,显示 `1/3 Starting uv download ...`、`1/3 Downloading uv [速度]` 或镜像形式、`1/3 Installing uv ...`;Python 使用 20–50%,显示 Checking、Downloading、Installing,镜像下载带 `(mirror)`;packages 使用 50–98%,显示 Resolving、`Downloading packages [已开始数]/[解析总数]`、Installing,镜像模式带 `(mirror)`;98–100% 显示 `Verifying installation ...`。三个下载区间分别占对应阶段的 84–87%,缓存命中时直接跳过。单阶段状态不显示 `1/1`。
|
||||
|
||||
首次安装依次显示 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 返回失败并显示原始模型错误。
|
||||
|
||||
## 生命周期
|
||||
|
||||
扩展启用后不启动 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 只支持单张图片。DA3 Debug 多帧输入仍联合推理;MoGe-2 与 BEN2 Debug 按单图 API 逐帧保存直接返回。
|
||||
|
||||
联合推理时,`onnx_da3.infer` 会在执行前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。
|
||||
|
||||
待解决方向:
|
||||
|
||||
- 重新导出 DA3 ONNX,使用 ORT 的 `Attention` / `MultiHeadAttention` 贡献算子,或注入内存高效的注意力算子;
|
||||
- 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。
|
||||
@@ -1,13 +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: "Open Sans", "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
--md-code-font: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
--md-text-font: "LXGW WenKai";
|
||||
--md-code-font: "LXGW WenKai Mono";
|
||||
}
|
||||
|
||||
.md-typeset h1 {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
/* 首页的扩展安装拖拽组件,数值取自 extensions.blender.org 的按钮。 */
|
||||
@font-face {
|
||||
font-family: "fontutti";
|
||||
src: url("../assets/fonts/fontutti.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.md-typeset .grid.cards > ul > li {
|
||||
border-radius: 0.5rem;
|
||||
.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;
|
||||
}
|
||||
|
||||
194
docs/surface.md
194
docs/surface.md
@@ -1,194 +0,0 @@
|
||||
# Surface
|
||||
|
||||
本页记录 Surface 的网格、相机、Geometry Nodes 和材质实现。日常操作与参数说明请阅读
|
||||
[Surface 使用指南](guide-surface.md)。
|
||||
|
||||
## 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 先用未经抠图的原始 RGB 请求 DA3 的 `depth`、`conf` 与 `intrinsics`,随后仅在开启
|
||||
`Remove Background` 时运行 BEN2 取得前景 Alpha;Alpha 经目标网格分辨率缩放与轻微模糊后,只要
|
||||
大于 `0` 就把对应点视为前景;每个三角形的三个顶点必须全部为前景,任一顶点 Alpha 为 `0` 就在输出前删除该面;
|
||||
关闭时不加载 BEN2。Depth 不做 smoothing,Server 反投影深度网格,建立源三角面,
|
||||
计算 confidence percentile 与 3×3 相对深度不连续度,并写入
|
||||
基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、
|
||||
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Server 端源数据准备。
|
||||
DA3 与 MoGe-2 最终都由 `server/geometry/surface.py` 构建同一份 Surface 数据;模型模块只提供各自的
|
||||
深度、点、法线、Mask 与相机数据,不再自行拥有 Surface 网格输出实现。
|
||||
3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器
|
||||
添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成:
|
||||
- 修改器顶层依次显示 `Iterations`、`Resolution`、`Smooth`;`Iterations` 默认 `2`、最小 `0`,
|
||||
默认展开的 `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` 的 `Iterations` 默认值均为 `2`、最小值为 `0`,
|
||||
`Resolution` 默认值均为 `0.1`,所有输入 socket 的 Structure Type 均固定为 `Single`。
|
||||
5. Surface Job 统一按输入准备、模型推理、可选 BEN2、构建 SurfaceMesh、写出产物五个阶段更新进度;单帧推理只显示模型与阶段名称,不追加 `1/1`,仅多帧时显示当前帧与总数;
|
||||
Surface Builder 是不接收 Job Context 或 progress callback 的纯数据转换。
|
||||
6. Server 在 job 根目录写出 `mesh.npz`。NPZ 保存 camera-space `vertices`、`faces`、`uv`、
|
||||
`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。颜色层无论来自原图复制还是 BEN2,
|
||||
Server 都统一写为 `color.0001.*`。Blender 读取网格数据后额外导入为 `.color`,材质不复用源
|
||||
Empty 的 Image。颜色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。 Server 不生成 `texture.png`,材质只在
|
||||
Blender 端创建。
|
||||
DA3 Surface 只保留 Geometry Nodes 后处理实际使用的 attributes:点域 `o_depth`、面域
|
||||
`o_confidence` 与面域 `o_depth_discontinuity`。Remove Background 已在 Server
|
||||
建面前直接删除背景三角面,不写入任何 background attribute。MoGe-2 的导入网格保留点域 `o_depth`,
|
||||
以及面域 `o_mask`、`o_depth_discontinuity`,不伪造 confidence、stretch 或 background 属性,
|
||||
也不写入 custom normal。MoGe-2 推理返回的 normal 直接编码为独立的 `normal.png`,Blender 将它
|
||||
作为 Non-Color 贴图导入现有材质;normal 不进入 `mesh.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`、`Shadeless`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Server
|
||||
使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。
|
||||
该预删除同时适用于 DA3 与 MoGe-2;因此 MoGe-2 转换后即使关闭 `Mask`,BEN2 已删除的背景也不会恢复。
|
||||
Surface 材质的 Color Image Texture 始终使用 `.color`;Remove Background 只决定该层是否带 BEN2 Alpha,原 Empty 始终保持原图。颜色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。
|
||||
`Shadeless` 使用与 Depth Plane 相同的 `O Shadeless` 节点组,并继续通过 Alpha 与 Transparent BSDF 混合;普通材质的
|
||||
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)。
|
||||
|
||||
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
|
||||
位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
|
||||
进度单调递增。任务进程与 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 仍使用这些参数处理后的深度。
|
||||
无论选择哪种置换方式,Layer 的 `Color` 都会连接 Principled BSDF 的 `Base Color`、
|
||||
`Subsurface Radius` 与 `Emission Color`;Layer 的 `Normal` 同时连接 `Normal` 与 `Coat Normal`,
|
||||
通过 `Bump.Height → Bump.Normal` 提供表面细节。Subsurface、Emission 与 Coat 的强度等其他输入
|
||||
保持 Principled BSDF 默认值。Bump 的基础 Distance 为
|
||||
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
|
||||
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持
|
||||
Principled BSDF 默认值 `1.5`。
|
||||
开启 `Shadeless` 后,色彩贴图 Color 接入 `O Shadeless`。该组沿用 Blender Images as Planes 的
|
||||
Diffuse、Emission、Light Path 与射线判断连接;二值 Alpha 仍作为 Mix Shader 系数,在 Transparent BSDF
|
||||
与 `O Shadeless` 之间混合后连接 Material Output。外层不再创建 Emission、Principled BSDF、Bump 或
|
||||
Float Curve。开启 `Remove Background` 时色彩贴图使用 `.color`;关闭时使用 `.color`。
|
||||
|
||||
这里的“原始距离”是 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`、`color.0001.jpg`(`.color`)或 `color.0001.png`,并在每帧保留原始
|
||||
浮点深度;保存 `.blend` 后再次打开仍需对应 job 目录存在。
|
||||
|
||||
每个 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 目录。
|
||||
|
||||
正式几何输出由 `generate-depth` 与 `generate-surface` 负责。Debug 使用彼此独立的
|
||||
`debug-da3`、`debug-moge2` 与 `debug-ben2`,只保存模型 API 的直接返回,不进入 Geometry 或
|
||||
Surface 输出流程。
|
||||
|
||||
Job 目录中的典型正式产物如下:
|
||||
|
||||
```text
|
||||
<opaque-job-id>/
|
||||
├── results.npz、mesh.npz 或 normal.png
|
||||
└── prediction.npz Debug 原始 API 返回
|
||||
|
||||
<opaque-job-id>/
|
||||
├── color.0001.jpg/png… 正式工作流颜色层(原图复制或 BEN2 Alpha)
|
||||
├── depth.0001.exr… AI 深度序列帧
|
||||
└── normal.png MoGe-2 Surface 法线贴图
|
||||
```
|
||||
|
||||
建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重。
|
||||
@@ -1,27 +0,0 @@
|
||||
# 选择转换方式
|
||||
|
||||
SnapForm 提供三种日常工作流。选择结果时,先考虑你需要的是可调节的“深度面片”、带透视结构的“空间网格”,还是只需要透明前景。
|
||||
|
||||
| 工作流 | 适合场景 | 输入 | 结果 | 主要优点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 |
|
||||
| Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 |
|
||||
| Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 |
|
||||
|
||||
## 什么时候选择 Depth Plane
|
||||
|
||||
Depth Plane 把估算深度作为面片位移,特别适合需要快速获得层次感、又希望继续用少量参数控制结果的场景。它也支持视频和序列帧,是制作动态立体素材的首选。
|
||||
|
||||
[阅读 Depth Plane 指南](guide-depth-plane.md)
|
||||
|
||||
## 什么时候选择 Surface
|
||||
|
||||
Surface 会根据模型估算的相机和深度构建透视网格。相较 Depth Plane,它能更自然地表达近大远小和画面内部的空间关系,但目前只支持单张图片。
|
||||
|
||||
[阅读 Surface 指南](guide-surface.md)
|
||||
|
||||
## 什么时候只去除背景
|
||||
|
||||
如果只想把 Image Empty 换成透明前景图,不需要任何几何结构,直接选择 **Remove Background**。对象的 Transform、显示尺寸和 Offset 会保留。
|
||||
|
||||
[阅读去除背景指南](remove-background.md)
|
||||
100
mkdocs.yml
100
mkdocs.yml
@@ -1,57 +1,71 @@
|
||||
site_name: SnapForm 文档
|
||||
site_url: https://docs.omoolab.xyz/snapform/
|
||||
site_name: AnyImage 文档
|
||||
site_url: https://docs.omoolab.xyz/anyimage/
|
||||
site_author: OmooLab
|
||||
site_description: SnapForm 是一款将图片、视频和序列帧转换为立体面片的 Blender 扩展。
|
||||
site_description: AnyImage 是一款将图片、视频和序列帧转换为立体面片的 Blender 扩展。
|
||||
|
||||
repo_name: OmooLab/SnapForm
|
||||
repo_url: https://git.omoolab.xyz/icrdr/SnapForm
|
||||
repo_name: OmooLab/AnyImage
|
||||
repo_url: https://git.omoolab.xyz/icrdr/AnyImage
|
||||
|
||||
nav:
|
||||
- SnapForm:
|
||||
- AnyImage:
|
||||
- index.md
|
||||
- 开始使用:
|
||||
- 安装与首次配置: installation.md
|
||||
- 五分钟上手: quick-start.md
|
||||
- 使用指南:
|
||||
- 选择转换方式: workflows.md
|
||||
- Depth Plane: guide-depth-plane.md
|
||||
- Surface: guide-surface.md
|
||||
- 去除背景: remove-background.md
|
||||
- 视频与序列帧: animation.md
|
||||
- 模型、设备与存储: models-and-storage.md
|
||||
- 常见问题: faq.md
|
||||
- 参考与开发:
|
||||
- 功能参考: features.md
|
||||
- Depth Plane 实现: depth-plane.md
|
||||
- Surface 实现: surface.md
|
||||
- 输出格式: outputs.md
|
||||
- Job Server: server.md
|
||||
- 程序设计与开发: development.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: 切换到浅色模式
|
||||
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: 切换到深色模式
|
||||
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: 跟随系统外观
|
||||
name: Switch to system preference
|
||||
|
||||
features:
|
||||
- header.autohide
|
||||
- navigation.footer
|
||||
@@ -59,10 +73,8 @@ theme:
|
||||
- navigation.sections
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.top
|
||||
- search.highlight
|
||||
- search.suggest
|
||||
- content.code.copy
|
||||
- content.code.select
|
||||
|
||||
extra:
|
||||
version:
|
||||
@@ -70,17 +82,39 @@ extra:
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
|
||||
extra_javascript:
|
||||
- javascripts/extra.js
|
||||
|
||||
copyright: Copyright © 2026 OmooLab
|
||||
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:
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- mkdocs-click
|
||||
- admonition
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
line_spans: __span
|
||||
pygments_lang_class: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- 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,不构建文档、节点资产或发布产物
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user