10 Commits
main ... v2.0.0

Author SHA1 Message Date
nan
5dbc2462bf feat: Update Help operator and panel labels to use English terminology 2026-05-25 19:58:47 +08:00
nan
2671f48489 feat: Downgrade h5py version to 3.14.0 in requirements and manifest 2026-05-25 19:41:43 +08:00
nan
3a18d01d17 feat: Refactor asset library management and update manifest for improved organization 2026-05-25 16:18:52 +08:00
nan
564a47da69 feat: Add OPSX commands for archiving, exploring, and proposing changes 2026-05-25 14:18:01 +08:00
nan
9ba0c6abeb feat: Update .gitignore and add binary files for scipy_ndimage across multiple platforms 2026-05-25 14:16:56 +08:00
nan
cd00c2bfef feat: Update release workflow and manifest for improved dependency management and layer handling 2026-05-25 14:09:44 +08:00
nan
e06cadc67e feat: Update .gitignore to include scipy_ndimage directory 2026-05-25 14:08:38 +08:00
nan
b83dd0acd9 feat: Update .gitattributes and .gitignore for improved asset management and ignore patterns
chore: Add app.json and omoolab.css for Obsidian integration

refactor: Clean up AGENTS.md and remove outdated test files
2026-05-25 09:15:47 +08:00
ced0898f84 Add unit tests for bioxel module and documentation
- Created README.md for test instructions and categories.
- Implemented comprehensive unit tests for the Data, Layer, and utility functions in the bioxel module.
- Added tests for reading NRRD and DICOM formats, data properties, and edge cases.
- Included tests for real data files to ensure functionality with actual datasets.
2026-01-22 09:38:07 +08:00
a558d1ae30 feat: Add Bioxel asset catalog and layer management functionality
- Introduced a new asset catalog definition file for Blender to manage Bioxel assets.
- Implemented layer caching and snapshot functionality in `layer.py`, allowing for VDB file generation and low-resolution snapshots.
- Created node management functions in `node.py` for handling Bioxel nodes within Blender's Geometry Node Editor.
- Added an operator to extract mesh data from selected nodes in `structure.py`.
- Developed UI panels in `panels.py` for layer management, including import options and layer library display.
2026-01-21 12:42:12 +08:00
64 changed files with 5358 additions and 3737 deletions

View File

@@ -0,0 +1,152 @@
---
name: "OPSX: Apply"
description: Implement tasks from an OpenSpec change (Experimental)
category: Workflow
tags: [workflow, artifacts, experimental]
---
Implement tasks from an OpenSpec change.
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). 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)
- 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 `/opsx:continue`
- 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! You can archive this change with `/opsx:archive`.
```
**Output On Pause (Issue Encountered)**
```
## Implementation Paused
**Change:** <change-name>
**Schema:** <schema-name>
**Progress:** 4/7 tasks complete
### Issue Encountered
<description of the issue>
**Options:**
1. <option 1>
2. <option 2>
3. Other approach
What would you like to do?
```
**Guardrails**
- Keep going through tasks until done or blocked
- Always read context files before starting (from the apply instructions output)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
- Use contextFiles from CLI output, don't assume specific file names
**Fluid Workflow Integration**
This skill supports the "actions on a change" model:
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly

View File

@@ -0,0 +1,157 @@
---
name: "OPSX: Archive"
description: Archive a completed change in the experimental workflow
category: Workflow
tags: [workflow, archive, experimental]
---
Archive a completed change in the experimental workflow.
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). 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
- Prompt user for confirmation to continue
- 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
- Prompt user for confirmation to continue
- 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
- Spec sync status (synced / sync skipped / no delta specs)
- 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
All artifacts complete. All tasks complete.
```
**Output On Success (No Delta Specs)**
```
## Archive Complete
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** No delta specs
All artifacts complete. All tasks complete.
```
**Output On Success With Warnings**
```
## Archive Complete (with warnings)
**Change:** <change-name>
**Schema:** <schema-name>
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
**Specs:** Sync skipped (user chose to skip)
**Warnings:**
- Archived with 2 incomplete artifacts
- Archived with 3 incomplete tasks
- Delta spec sync was skipped (user chose to skip)
Review the archive if this was not intentional.
```
**Output On Error (Archive Exists)**
```
## Archive Failed
**Change:** <change-name>
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
Target archive directory already exists.
**Options:**
1. Rename the existing archive
2. Delete the existing archive if it's a duplicate
3. Wait until a different date to archive
```
**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 the Skill tool to invoke `openspec-sync-specs` (agent-driven)
- If delta specs exist, always run the sync assessment and show the combined summary before prompting

View File

@@ -0,0 +1,173 @@
---
name: "OPSX: Explore"
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
category: Workflow
tags: [workflow, explore, experimental, thinking]
---
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.
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
- A vague idea: "real-time collaboration"
- A specific problem: "the auth system is getting unwieldy"
- A change name: "add-dark-mode" (to explore in context of that change)
- A comparison: "postgres vs sqlite for this"
- Nothing (just enter explore mode)
---
## 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
If the user mentioned a specific change name, read its artifacts for context.
### 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)
---
## 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 things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
---
## Guardrails
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
- **Don't fake understanding** - If something is unclear, dig deeper
- **Don't rush** - Discovery is thinking time, not task time
- **Don't force structure** - Let patterns emerge naturally
- **Don't auto-capture** - Offer to save insights, don't just do it
- **Do visualize** - A good diagram is worth many paragraphs
- **Do explore the codebase** - Ground discussions in reality
- **Do question assumptions** - Including the user's and your own

View File

@@ -0,0 +1,106 @@
---
name: "OPSX: Propose"
description: Propose a new change - create it and generate all artifacts in one step
category: Workflow
tags: [workflow, artifacts, experimental]
---
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 argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
**Steps**
1. **If no 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` to start implementing."
**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

View File

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

View File

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

View File

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

View File

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

132
.gitattributes vendored
View File

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

View File

@@ -3,6 +3,7 @@ name: Release Version
on:
push:
tags:
# Publish on any tag starting with a `v`, e.g., v0.1.0
- 'v*'
jobs:
@@ -42,14 +43,17 @@ jobs:
uses: actions/checkout@v4
with:
lfs: "true"
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.11
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install Dependencies
run: |
uv python install 3.11
uv python pin 3.11
uv sync
- name: Build Extension
run: | # build.py need tomlkit to edit blender_manifest.toml
pip install tomlkit
python build.py ${{ matrix.platform }}
uv run build.py ${{ matrix.platform }} 3.11
uv run build.py ${{ matrix.platform }} 3.13
cd src/bioxelnodes
zip -r ../../package.zip .
- name: Upload Extension
@@ -60,5 +64,5 @@ jobs:
with:
upload_url: ${{ needs.draft_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
asset_path: ./package.zip
asset_name: BioxelNodes_${{ needs.draft_release.outputs.version }}_${{ matrix.platform }}.zip
asset_name: BioxelNodes.${{ needs.draft_release.outputs.version }}.${{ matrix.platform }}.zip
asset_content_type: application/zip

302
.gitignore vendored
View File

@@ -1,6 +1,51 @@
# Tempary files
temp
sandbox.*
notes/
# Environments
.env
.envrc
.venv
.env.*
!.env.example
env/
venv/
ENV/
env.bak/
venv.bak/
.secrets
.secrets.*
!.secrets.example
secrets/
SECRETS/
secrets.bak/
# Claude
.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_*
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.py[codz]
*$py.class
# C extensions
@@ -20,7 +65,6 @@ parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
@@ -28,8 +72,8 @@ share/python-wheels/
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.
# 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
@@ -47,8 +91,10 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
@@ -71,6 +117,7 @@ instance/
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
@@ -81,30 +128,70 @@ profile_default/
ipython_config.py
# pyenv
.python-version
# 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
# Pipfile.lock
# celery beat schedule file
# 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
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
@@ -123,16 +210,187 @@ dmypy.json
# Pyre type checker
.pyre/
pythonlib*
# pytype static type analyzer
.pytype/
*.blend1
# Cython debug symbols
cython_debug/
blendcache_*
# 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/
*.ipynb
# 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/
.secrets
# 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/
.vdb
# 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/
# Scipy
!scipy_ndimage/*/**

22
.obsidian/app.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"showInlineTitle": false,
"useMarkdownLinks": true,
"pdfExportSettings": {
"pageSize": "Letter",
"landscape": false,
"margin": "0",
"downscalePercent": 100
},
"userIgnoreFilters": [
"notes/assets/"
],
"showUnsupportedFiles": false,
"attachmentFolderPath": "notes/assets",
"livePreview": true,
"showIndentGuide": false,
"alwaysUpdateLinks": true,
"openBehavior": "file:README",
"newFileLocation": "folder",
"newFileFolderPath": "notes",
"newLinkFormat": "relative"
}

30
.obsidian/snippets/omoolab.css vendored Normal file
View File

@@ -0,0 +1,30 @@
/* not show label in canvas */
.canvas-node-label {
display: none;
}
.canvas-node-container:hover + .canvas-node-label {
display: inherit;
}
/* image caption */
.image-embed {
flex-wrap: wrap;
}
.image-embed[alt]:after {
content: attr(alt);
display: block;
padding-bottom: 12px;
line-height: 2.0;
font-style: italic;
color: var(--text-faint);
text-align: center;
width: 100%;
}
/* fix image in canvas */
.image-embed {
padding: 0;
}

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

19
AGENTS.md Normal file
View File

@@ -0,0 +1,19 @@
## 总规范
- 说明性内容(例如提交说明、文档、注释、mermaid),以中文为主,专有名词、约定俗称可用英文
## Markdown 规范
- 不使用`---`分割器
- `# Heading` 一级标题仅用于开头
- mermaid 节点名用英文字符
## 代码规范
- 避免嵌套结构
- 特殊的、非常规的需要写注释
- 变量、函数名、Docstring 用全英文
- 同一事物用统一表达
- 使用的依赖越少越好
- 选用简单直接的方式实现
- 代码、执行方式改变,调整已有的文档、计划,而非新建

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -12,12 +12,14 @@ class Platform:
blender_tag: str
required_packages = ["SimpleITK==2.3.1",
required_packages = ["SimpleITK==2.5.5",
"pyometiff==1.0.0",
"mrcfile==1.5.1",
"h5py==3.11.0",
"h5py==3.14.0",
"transforms3d==0.4.2",
"tifffile==2024.7.24"]
"tifffile==2024.7.24",
"matplotlib==3.10.7",
"pillow==11.2.1"]
platforms = {"windows-x64": Platform(pypi_suffix="win_amd64",
@@ -40,7 +42,7 @@ def run_python(args: str):
subprocess.run([python] + args.split(" "))
def build_extension(platform: Platform, python_version="3.11") -> None:
def build_extension(platform: Platform, python_version: str) -> None:
wheel_dirpath = Path("./src/bioxelnodes/wheels")
toml_filepath = Path("./src/bioxelnodes/blender_manifest.toml")
scipy_ndimage_dirpath = Path("./scipy_ndimage", platform.blender_tag)
@@ -50,26 +52,28 @@ def build_extension(platform: Platform, python_version="3.11") -> None:
f"-m pip download {' '.join(required_packages)} --dest {wheel_dirpath.as_posix()} --only-binary=:all: --python-version={python_version} --platform={platform.pypi_suffix}"
)
for f in wheel_dirpath.glob('*.whl'):
for f in wheel_dirpath.glob("*.whl"):
if any([package in f.name for package in packages_to_remove]):
f.unlink(missing_ok=True)
elif platform.blender_tag == "macos-arm64" and \
"lxml" in f.name and "universal2" in f.name:
f.rename(Path(f.parent,
f.name.replace("universal2", "arm64")))
elif (
platform.blender_tag == "macos-arm64"
and "lxml" in f.name
and "universal2" in f.name
):
f.rename(Path(f.parent, f.name.replace("universal2", "arm64")))
for ndimage_filepath in scipy_ndimage_dirpath.iterdir():
to_filepath = Path("./src/bioxelnodes/bioxel/scipy", ndimage_filepath.name)
shutil.copy(ndimage_filepath, to_filepath)
# Load the TOML file
with toml_filepath.open("r") as file:
manifest = tomlkit.parse(file.read())
manifest["platforms"] = [platform.blender_tag]
manifest["wheels"] = [f"./wheels/{f.name}"
for f in wheel_dirpath.glob('*.whl')]
manifest["wheels"] = [
f"./wheels/{f.name}" for f in wheel_dirpath.glob("*.whl")]
# build.append('generated', generated)
# manifest.append('build', build)
@@ -82,8 +86,9 @@ def build_extension(platform: Platform, python_version="3.11") -> None:
def main():
platform_name = sys.argv[1]
python_version = sys.argv[2] or "3.11"
platform = platforms[platform_name]
build_extension(platform)
build_extension(platform, python_version)
if __name__ == "__main__":

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

92
pack.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Create a zip archive of the src/bioxelnodes package with a top-level folder
named `bioxelnodes/`. Excludes specified temporary/cache files and folders.
Does not modify any files in the project.
Usage:
python build_package.py <platform_name> [output_zip_path]
Example:
python build_package.py windows-x64
"""
from pathlib import Path
import sys
import zipfile
import fnmatch
from datetime import datetime
platforms = {
"windows-x64": "windows-x64",
"linux-x64": "linux-x64",
"macos-arm64": "macos-arm64",
"macos-x64": "macos-x64",
}
# patterns to exclude (filename matching)
EXCLUDE_FILENAME_PATTERNS = [
"*.blend1",
"blendcache_*",
"*.cats.txt~",
"*.pyc",
"*.pyo",
"*$py.class",
]
# directory names to exclude entirely
EXCLUDE_DIR_NAMES = {
"__pycache__",
}
def should_exclude(path: Path) -> bool:
name = path.name
# exclude directories by name anywhere in the path
if any(part in EXCLUDE_DIR_NAMES for part in path.parts):
return True
# only apply filename patterns to files
if path.is_file():
for pat in EXCLUDE_FILENAME_PATTERNS:
if fnmatch.fnmatch(name, pat):
return True
return False
def build_package(platform_name: str, out_zip: Path):
src_root = Path("src") / "bioxelnodes"
if not src_root.exists():
raise SystemExit(f"Source folder not found: {src_root.resolve()}")
out_zip.parent.mkdir(parents=True, exist_ok=True)
total = 0
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for p in src_root.rglob("*"):
if p.is_dir():
continue
if should_exclude(p):
continue
# arcname should place files under top-level 'bioxelnodes/...'
rel = p.relative_to(src_root)
arcname = Path("bioxelnodes") / rel
zf.write(p, arcname.as_posix())
total += 1
print(f"Packaged {total} files into {out_zip}")
def main():
if len(sys.argv) < 2:
print("Usage: python build_package.py <platform_name> [output_zip_path]")
print("Available platforms:", ", ".join(platforms.keys()))
sys.exit(1)
platform_key = sys.argv[1]
if platform_key not in platforms:
print("Unknown platform:", platform_key)
print("Available platforms:", ", ".join(platforms.keys()))
sys.exit(1)
tag = platforms[platform_key]
timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
default_name = f"bioxelnodes-{tag}-{timestamp}.zip"
out_path = Path(sys.argv[2]) if len(sys.argv) >= 3 else Path(default_name)
build_package(platform_key, out_path)
if __name__ == "__main__":
main()

View File

@@ -12,6 +12,7 @@ dependencies = [
"mrcfile==1.5.1",
"h5py==3.11.0",
"transforms3d==0.4.2",
"matplotlib==3.10.7",
]
[dependency-groups]
@@ -25,6 +26,7 @@ dev = [
"autopep8>=2.3.1,<3",
"mike>=2.1.2,<3",
"tomlkit>=0.13.0,<0.14",
"pip>=25.3",
]
[build-system]

Binary file not shown.

Binary file not shown.

View File

@@ -1,26 +1,92 @@
import bpy
import bpy.utils.previews as previews
from .constants import NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS
from .props import _bioxel_layer_items, _update_layer_gallery, _update_snapshot_z
from .props import BIOXELNODES_LayerListUL
from . import auto_load
from . import menus
auto_load.init()
def add_asset_library():
"""Add Bioxel asset library, ensuring only one exists by removing duplicates first."""
if not NODE_LIB_DIRPATH.exists():
print(f"Node library path does not exist - {NODE_LIB_DIRPATH}")
return
lib_path_str = str(NODE_LIB_DIRPATH)
prefs = bpy.context.preferences.filepaths.asset_libraries
# Add new library
new_lib = prefs.new()
new_lib.name = "O Bioxel"
new_lib.path = lib_path_str
new_lib.import_method = "PACK"
print(f"Add Bioxel Nodes library: {lib_path_str}")
def remove_asset_library_if_exists():
"""Remove the Bioxel asset library entry if it was added (by path or name)."""
lib_path_str = str(NODE_LIB_DIRPATH)
prefs = bpy.context.preferences.filepaths.asset_libraries
for lib in list(prefs):
try:
if "Bioxel" in lib.name or lib.path == lib_path_str:
prefs.remove(lib)
except Exception:
continue
def register():
pcoll = previews.new()
pcoll.layer_previews = ()
PREVIEW_COLLECTIONS["layers"] = pcoll
bpy.types.WindowManager.bioxel_progress_factor = bpy.props.FloatProperty(
default=1.0
)
bpy.types.WindowManager.bioxel_progress_text = bpy.props.StringProperty()
bpy.types.WindowManager.bioxel_layer_library = bpy.props.EnumProperty(
name="Bioxel Layer Gallery",
items=_bioxel_layer_items,
update=_update_layer_gallery,
)
bpy.types.WindowManager.bioxel_snapshot_z = bpy.props.FloatProperty(
name="Snapshot Z",
default=0.5,
min=0.0,
max=1.0,
subtype="FACTOR",
update=_update_snapshot_z,
)
bpy.types.WindowManager.bioxel_layer_list_index = bpy.props.IntProperty(
default=0)
auto_load.register()
bpy.types.WindowManager.bioxelnodes_progress_factor = bpy.props.FloatProperty(
default=1.0)
bpy.types.WindowManager.bioxelnodes_progress_text = bpy.props.StringProperty()
bpy.types.WindowManager.bioxelnodes_layer_list_UL = bpy.props.PointerProperty(
type=BIOXELNODES_LayerListUL)
menus.add()
remove_asset_library_if_exists()
add_asset_library()
def unregister():
remove_asset_library_if_exists()
menus.remove()
del bpy.types.WindowManager.bioxelnodes_progress_factor
del bpy.types.WindowManager.bioxelnodes_progress_text
del bpy.types.WindowManager.bioxelnodes_layer_list_UL
auto_load.unregister()
del bpy.types.WindowManager.bioxel_progress_factor
del bpy.types.WindowManager.bioxel_progress_text
del bpy.types.WindowManager.bioxel_layer_library
del bpy.types.WindowManager.bioxel_snapshot_z
del bpy.types.WindowManager.bioxel_layer_list_index
for pcoll in PREVIEW_COLLECTIONS.values():
previews.remove(pcoll)
PREVIEW_COLLECTIONS.clear()

Binary file not shown.

View File

@@ -0,0 +1,12 @@
# This is an Asset Catalog Definition file for Blender.
#
# Empty lines and lines starting with `#` will be ignored.
# The first non-ignored line should be the version indicator.
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
VERSION 1
c0699a8b-6fd6-4d10-bdde-5ea0fa36724d:O Bioxel:O Bioxel
5ea24e10-5a2b-44d1-b67b-19014c412793:O Bioxel/Layer:O Bioxel-Layer
17383d65-f1a0-4122-b73a-0baaad4b8ecf:O Bioxel/Structure:O Bioxel-Structure
d03fc5b0-465c-45ba-ba8e-524a1c52ae3d:O Bioxel/Utilities:O Bioxel-Utilities

View File

@@ -80,11 +80,13 @@ def get_ordered_classes_to_register(modules):
def get_register_deps_dict(modules):
my_classes = set(iter_my_classes(modules))
my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
my_classes_by_idname = {
cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")}
deps_dict = {}
for cls in my_classes:
deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname))
deps_dict[cls] = set(iter_my_register_deps(
cls, my_classes, my_classes_by_idname))
return deps_dict
@@ -181,7 +183,8 @@ def toposort(deps_dict):
sorted_values.add(value)
else:
unsorted.append(value)
deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted}
deps_dict = {value: deps_dict[value] -
sorted_values for value in unsorted}
sorted_list_sub.sort(key=lambda cls: getattr(cls, "bl_order", 0))
sorted_list.extend(sorted_list_sub)
return sorted_list

View File

@@ -0,0 +1,34 @@
from .layer import Layer
from .container import Container
from .io import load_container, save_container
from .data import (
Data,
read,
read_meta,
calc_layer_shape,
calc_layer_size,
get_ext,
SUPPORT_EXTS,
DICOM_EXTS,
OME_EXTS,
MRC_EXTS,
SEQUENCE_EXTS
)
__all__ = [
'Layer',
'Container',
'load_container',
'save_container',
'Data',
'read',
'read_meta',
'calc_layer_shape',
'calc_layer_size',
'get_ext',
'SUPPORT_EXTS',
'DICOM_EXTS',
'OME_EXTS',
'MRC_EXTS',
'SEQUENCE_EXTS'
]

View File

@@ -0,0 +1,831 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Callable, List
import numpy as np
import SimpleITK as sitk
from pyometiff import OMETIFFReader
import mrcfile
import transforms3d
from .layer import Layer
# 类型定义
ProgressCallback = Optional[Callable[[float, str], None]]
SUPPORT_EXTS = [
"",
".dcm",
".DCM",
".DICOM",
".ima",
".IMA",
".ome.tiff",
".ome.tif",
".tif",
".TIF",
".tiff",
".TIFF",
".mrc",
".mrc.gz",
".map",
".map.gz",
".bmp",
".BMP",
".png",
".PNG",
".jpg",
".JPG",
".jpeg",
".JPEG",
".PIC",
".pic",
".gipl",
".gipl.gz",
".lsm",
".LSM",
".mnc",
".MNC",
".mrc",
".rec",
".mha",
".mhd",
".hdf",
".h4",
".hdf4",
".he2",
".h5",
".hdf5",
".he5",
".nia",
".nii",
".nii.gz",
".hdr",
".img",
".img.gz",
".nrrd",
".nhdr",
".vtk",
".gz",
]
OME_EXTS = [".ome.tiff", ".ome.tif", ".tif", ".TIF", ".tiff", ".TIFF"]
MRC_EXTS = [".mrc", ".mrc.gz", ".map", ".map.gz"]
DICOM_EXTS = ["", ".dcm", ".DCM", ".DICOM", ".ima", ".IMA"]
SEQUENCE_EXTS = [
".bmp",
".BMP",
".jpg",
".JPG",
".jpeg",
".JPEG",
".tif",
".TIF",
".tiff",
".TIFF",
".png",
".PNG",
".mrc",
]
@dataclass
class Data:
filepath: str
series_id: str
_data: Optional[np.ndarray] = field(default=None, repr=False)
_meta: Optional[dict] = field(default=None, repr=False)
@property
def data(self) -> np.ndarray:
if self._data is None:
self.load_data()
return self._data
@property
def meta(self) -> dict:
if self._meta is None:
self.load_meta()
return self._meta
@property
def name(self) -> str:
return self.meta["name"]
@property
def description(self) -> str:
return self.meta["description"]
@property
def shape(self) -> tuple:
return (
self.meta["frame_count"],
*self.meta["xyz_shape"],
self.meta["channel_count"],
)
@property
def xyz_shape(self) -> tuple:
return self.meta["xyz_shape"]
@property
def spacing(self) -> tuple:
return self.meta["spacing"]
@property
def affine(self) -> np.ndarray:
return self.meta["affine"]
@property
def frame_count(self) -> int:
return self.meta["frame_count"]
@property
def channel_count(self) -> int:
return self.meta["channel_count"]
@property
def dtype(self) -> np.dtype:
return self.meta.get("dtype", np.float32)
def load_meta(self, progress_callback: ProgressCallback = None):
data, meta = self._parse_file(progress_callback)
self._meta = meta
del data
def load_data(self, progress_callback: ProgressCallback = None):
if not self.is_loaded():
data, meta = self._parse_file(progress_callback)
self._data = data
self._meta = meta
def is_loaded(self) -> bool:
return self._data is not None
def to_layers(
self,
kind: str = "scalar",
layer_name: str = "",
bioxel_size: float = 1.0,
smooth: int = 0,
remap: bool = False,
split_channel: bool = False,
frame_source: str = "-1",
progress_callback: ProgressCallback = None,
) -> List[Layer]:
from .layer import Layer
data = self.data.copy()
data, layer_shape = self._transform_shape(data, frame_source)
data = self._preprocess(data, kind, remap)
mat_scale = transforms3d.zooms.zfdir2aff(bioxel_size)
affine = np.dot(self.meta["affine"], mat_scale)
base_name = layer_name or kind.capitalize()
if kind == "label":
return self._create_label_layers(
data, base_name, layer_shape, affine, smooth, progress_callback
)
elif kind == "color":
return self._create_color_layers(
data, base_name, layer_shape, affine, progress_callback
)
elif kind == "scalar":
return self._create_scalar_layers(
data,
base_name,
layer_shape,
affine,
smooth,
split_channel,
progress_callback,
)
return []
def _parse_file(self, progress_callback: ProgressCallback = None):
data_path = Path(self.filepath).resolve()
ext = self._get_ext(data_path)
if progress_callback:
progress_callback(0.0, "Reading Data...")
is_sequence = False
sequence = None
if ext in SEQUENCE_EXTS:
sequence = self._collect_sequence(data_path)
if sequence and len(sequence) > 1:
is_sequence = True
data = None
name = ""
description = ""
affine = np.identity(4)
spacing = (1, 1, 1)
origin = (0, 0, 0)
direction = (1, 0, 0, 0, 1, 0, 0, 0, 1)
if data is None and ext in MRC_EXTS and not is_sequence:
data, name, spacing = self._parse_mrc(data_path)
if data is None and ext in OME_EXTS and not is_sequence:
data, name, spacing = self._parse_ome_tiff(data_path, progress_callback)
if data is None:
data, name, description, spacing, origin, direction = self._parse_sitk(
data_path, ext, is_sequence, sequence, progress_callback
)
t = origin
r = np.array(direction).reshape((3, 3))
affine = np.dot(affine, transforms3d.affines.compose(t, r, [1, 1, 1]))
meta = {
"name": name,
"description": description,
"spacing": spacing,
"affine": affine,
"xyz_shape": data.shape[1:4],
"frame_count": data.shape[0],
"channel_count": data.shape[-1],
"dtype": data.dtype,
}
return data, meta
def _parse_mrc(self, data_path: Path):
print("Parsing with mrcfile...")
with mrcfile.open(data_path, "r") as mrc:
mrc_data = mrc.data
if mrc.is_single_image():
data = np.expand_dims(np.asarray(mrc_data), axis=0)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
elif mrc.is_image_stack():
data = np.expand_dims(np.asarray(mrc_data), axis=-1)
data = np.expand_dims(data, axis=-1)
elif mrc.is_volume():
data = np.expand_dims(np.asarray(mrc_data), axis=0)
data = np.expand_dims(data, axis=-1)
elif mrc.is_volume_stack():
data = np.expand_dims(np.asarray(mrc_data), axis=-1)
name = self._get_file_no_digits_name(data_path)
spacing = (mrc.voxel_size.x, mrc.voxel_size.y, mrc.voxel_size.z)
return data, name, spacing
def _parse_ome_tiff(self, data_path: Path, progress_callback=None):
print("Parsing with OMETIFFReader...")
reader = OMETIFFReader(fpath=data_path)
ome_image, metadata, xml_metadata = reader.read()
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
try:
ome_order = metadata["DimOrder BF Array"]
data = self._transpose_ome_image(ome_image, ome_order)
try:
spacing = (
metadata["PhysicalSizeX"],
metadata["PhysicalSizeY"],
metadata["PhysicalSizeZ"],
)
except:
spacing = (1, 1, 1)
name = self._get_file_no_digits_name(data_path)
except Exception as e:
print(f"Error parsing OME-TIFF: {e}")
data = np.zeros((1, 1, 1, 1, 1), dtype=np.float32)
name = ""
spacing = (1, 1, 1)
return data, name, spacing
def _parse_sitk(
self,
data_path: Path,
ext: str,
is_sequence: bool,
sequence: Optional[list],
progress_callback=None,
):
print("Parsing with SimpleITK...")
if ext in DICOM_EXTS:
data_dirpath = data_path.parent
reader = sitk.ImageSeriesReader()
reader.MetaDataDictionaryArrayUpdateOn()
reader.LoadPrivateTagsOn()
series_files = reader.GetGDCMSeriesFileNames(
str(data_dirpath), self.series_id
)
reader.SetFileNames(series_files)
itk_image = reader.Execute()
name, description = self._extract_dicom_meta(reader, data_dirpath)
elif ext in SEQUENCE_EXTS and is_sequence and sequence:
sequence_list = sequence if sequence else []
itk_image = sitk.ReadImage(sequence_list)
name = self._get_file_no_digits_name(data_path)
description = ""
else:
itk_image = sitk.ReadImage(data_path)
name = self._get_filename(data_path)
description = ""
if progress_callback:
progress_callback(0.5, "Transpose to 'TXYZC'...")
data = self._convert_sitk_to_numpy(itk_image, ext, is_sequence)
if itk_image.GetDimension() >= 3 and ext not in SEQUENCE_EXTS:
spacing = tuple(itk_image.GetSpacing())
origin = tuple(itk_image.GetOrigin())
direction = tuple(itk_image.GetDirection())
else:
spacing = (1, 1, 1)
origin = (0, 0, 0)
direction = (1, 0, 0, 0, 1, 0, 0, 0, 1)
return data, name, description, spacing, origin, direction
def _transpose_ome_image(self, ome_image, ome_order: str):
if ome_image.ndim == 2:
ome_order = ome_order.replace("T", "").replace("C", "").replace("Z", "")
bioxel_order = (ome_order.index("X"), ome_order.index("Y"))
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
elif ome_image.ndim == 3:
ome_order = ome_order.replace("T", "").replace("C", "")
bioxel_order = (
ome_order.index("X"),
ome_order.index("Y"),
ome_order.index("Z"),
)
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0)
data = np.expand_dims(data, axis=-1)
elif ome_image.ndim == 4:
ome_order = ome_order.replace("T", "")
bioxel_order = (
ome_order.index("X"),
ome_order.index("Y"),
ome_order.index("Z"),
ome_order.index("C"),
)
data = np.transpose(ome_image, bioxel_order)
data = np.expand_dims(data, axis=0)
elif ome_image.ndim == 5:
bioxel_order = (
ome_order.index("T"),
ome_order.index("X"),
ome_order.index("Y"),
ome_order.index("Z"),
ome_order.index("C"),
)
data = np.transpose(ome_image, bioxel_order)
return data
def _convert_sitk_to_numpy(self, itk_image, ext: str, is_sequence: bool):
if itk_image.GetDimension() == 2:
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 3:
data = np.transpose(data, (1, 0, 2))
data = np.expand_dims(data, axis=-2)
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=0)
elif itk_image.GetDimension() == 3:
if ext not in SEQUENCE_EXTS:
itk_image = sitk.DICOMOrient(itk_image, "RAS")
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 4:
data = np.transpose(data, (2, 1, 0, 3))
else:
data = np.transpose(data)
data = np.expand_dims(data, axis=-1)
data = np.expand_dims(data, axis=0)
elif itk_image.GetDimension() == 4:
data = sitk.GetArrayFromImage(itk_image)
if data.ndim == 5:
data = np.transpose(data, (0, 3, 2, 1, 4))
else:
data = np.transpose(data, (0, 3, 2, 1))
data = np.expand_dims(data, axis=-1)
return data
def _extract_dicom_meta(self, reader, data_dirpath: Path):
def get_meta(key):
try:
string = reader.GetMetaData(0, key).removesuffix(" ")
string.encode("utf-8")
if string in ["No study description", "No series description", ""]:
return None
else:
return string
except:
return None
study_description = get_meta("0008|1030") or data_dirpath.name
series_description = get_meta("0008|103e")
series_modality = get_meta("0008|0060")
if series_description and series_modality:
description = f"{series_description}-{series_modality}"
elif series_description:
description = series_description
elif series_modality:
description = series_modality
else:
description = ""
name = study_description.replace(" ", "-")
description = description.replace(" ", "-")
return name, description
@staticmethod
def _get_ext(filepath: Path) -> str:
if filepath.name.endswith(".nii.gz"):
return ".nii.gz"
elif filepath.name.endswith(".img.gz"):
return ".img.gz"
elif filepath.name.endswith(".gipl.gz"):
return ".gipl.gz"
elif filepath.name.endswith(".ome.tiff"):
return ".ome.tiff"
elif filepath.name.endswith(".ome.tif"):
return ".ome.tif"
elif filepath.name.endswith(".mrc.gz"):
return ".mrc.gz"
elif filepath.name.endswith(".map.gz"):
return ".map.gz"
else:
suffix = filepath.suffix
return "" if len(suffix) > 5 else suffix
@staticmethod
def _get_filename(filepath: Path) -> str:
ext = Data._get_ext(filepath)
return filepath.name.removesuffix(ext)
@staticmethod
def _get_file_no_digits_name(filepath: Path) -> str:
prefix, digits, suffix = Data._get_filename_parts(filepath)
prefix = Data._remove_end_str(prefix, "_")
prefix = Data._remove_end_str(prefix, ".")
prefix = Data._remove_end_str(prefix, "-")
prefix = Data._remove_end_str(prefix, " ")
return prefix + suffix
@staticmethod
def _get_filename_parts(filepath: Path) -> tuple:
def has_digits(s):
return any(char.isdigit() for char in s)
name = Data._get_filename(filepath)
parts = name.replace(".", " ").replace("_", " ").split(" ")
skip_prefixs = ["CH", "ch", "channel"]
number_part = None
number_part_i = None
for i, part in enumerate(parts[::-1]):
if has_digits(part):
if not any([part.startswith(prefix) for prefix in skip_prefixs]):
number_part = part
number_part_i = len(parts) - i
break
if number_part is None:
return name, "", ""
prefix_parts = parts[: number_part_i - 1]
prefix_parts_count = sum([len(part) + 1 for part in prefix_parts])
digits = ""
suffix = ""
started = False
for char in number_part[::-1]:
if char.isdigit():
started = True
digits += char
else:
if started:
break
else:
suffix += char
digits = digits[::-1]
prefix_parts_count += len(number_part) - len(digits) - len(suffix)
prefix = name[:prefix_parts_count]
suffix = name[prefix_parts_count + len(digits) :]
return prefix, digits, suffix
@staticmethod
def _get_file_index(filepath: Path) -> int:
prefix, digits, suffix = Data._get_filename_parts(filepath)
return int(digits) if digits != "" else 0
@staticmethod
def _collect_sequence(filepath: Path):
file_dict = {}
for f in filepath.parent.iterdir():
if (
f.is_file()
and Data._get_ext(filepath) == Data._get_ext(f)
and Data._get_file_no_digits_name(filepath)
== Data._get_file_no_digits_name(f)
):
index = Data._get_file_index(f)
file_dict[index] = f
for key in list(file_dict.keys()):
if not file_dict.get(key + 1) and not file_dict.get(key - 1):
del file_dict[key]
file_dict = dict(sorted(file_dict.items()))
sequence = [str(f) for f in file_dict.values()]
if len(sequence) == 0:
sequence = [str(filepath)]
return sequence
@staticmethod
def _remove_end_str(string: str, end: str) -> str:
while string.endswith(end) and len(string) > 0:
string = string.removesuffix(end)
return string
def _transform_shape(self, data, frame_source: str) -> tuple:
orig_shape = self.xyz_shape
if frame_source == "-1":
data = data[0:1, :, :, :, :]
new_shape = orig_shape
elif frame_source == "0":
new_shape = orig_shape
elif frame_source == "1":
data = data.transpose(1, 0, 2, 3, 4)
new_shape = (1, orig_shape[1], orig_shape[2])
elif frame_source == "2":
data = data.transpose(2, 1, 0, 3, 4)
new_shape = (orig_shape[0], 1, orig_shape[2])
elif frame_source == "3":
data = data.transpose(3, 1, 2, 0, 4)
new_shape = (orig_shape[0], orig_shape[1], 1)
else:
data = data.transpose(4, 1, 2, 3, 0)
new_shape = orig_shape
return data, new_shape
def _preprocess(self, data, kind: str, remap: bool) -> np.ndarray:
if kind == "color":
if np.issubdtype(data.dtype, np.uint8):
data = np.multiply(data, 1.0 / 256, dtype=np.float32)
elif data.dtype.kind in ["u", "i"]:
data = data.astype(np.float32)
min_val, max_val = data.min(), data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
else:
data = data.astype(np.float32)
if data.shape[4] == 1:
data = np.repeat(data, repeats=3, axis=4)
elif data.shape[4] == 2:
zeros_shape = list(data.shape[:4]) + [1]
zeros = np.zeros(zeros_shape, dtype=np.float32)
data = np.concatenate((data, zeros), axis=-1)
elif data.shape[4] > 3:
data = data[:, :, :, :, :3]
elif kind == "scalar":
if remap:
data = data.astype(np.float32)
min_val, max_val = data.min(), data.max()
if max_val != min_val:
data = (data - min_val) / (max_val - min_val)
else:
data = np.zeros_like(data, dtype=np.float32)
elif kind == "label":
data = data.astype(int)
return data
def _create_progress_cb_factory(self, progress_callback):
def factory(name, progress, progress_step):
def callback(frame, total):
if progress_callback:
sub_progress = progress + frame * (progress_step / total)
progress_callback(
sub_progress, f"Processing {name} Frame {frame+1}..."
)
return callback
return factory
def _create_label_layers(
self,
data,
base_name,
layer_shape,
affine,
smooth,
progress_callback: ProgressCallback,
):
from .layer import Layer
layers = []
label_count = int(np.max(data))
if label_count == 0:
return layers
progress_step = 0.7 / label_count
cb_factory = self._create_progress_cb_factory(progress_callback)
for i in range(label_count):
name_i = f"{base_name}_{i+1}"
progress = 0.2 + i * progress_step
if progress_callback:
progress_callback(progress, f"Processing {name_i}...")
label_data = data == np.full_like(data, i + 1)
progress_cb = cb_factory(name_i, progress, progress_step)
layer = Layer(data=label_data, name=name_i, kind="label")
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
return layers
def _create_color_layers(
self, data, base_name, layer_shape, affine, progress_callback: ProgressCallback
):
from .layer import Layer
if progress_callback:
progress_callback(0.2, f"Processing {base_name}...")
cb_factory = self._create_progress_cb_factory(progress_callback)
progress_cb = cb_factory(base_name, 0.2, 0.7)
layer = Layer(data=data, name=base_name, kind="color")
layer.resize(shape=layer_shape, progress_callback=progress_cb)
layer.affine = affine
return [layer]
def _create_scalar_layers(
self,
data,
base_name,
layer_shape,
affine,
smooth,
split_channel,
progress_callback: ProgressCallback,
):
from .layer import Layer
layers = []
cb_factory = self._create_progress_cb_factory(progress_callback)
if split_channel:
channel_count = data.shape[-1]
progress_step = 0.7 / channel_count
for i in range(channel_count):
name_i = f"{base_name}_{i+1}"
progress = 0.2 + i * progress_step
if progress_callback:
progress_callback(progress, f"Processing {name_i}...")
progress_cb = cb_factory(name_i, progress, progress_step)
layer = Layer(
data=data[:, :, :, :, i : i + 1], name=name_i, kind="scalar"
)
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
else:
if progress_callback:
progress_callback(0.2, f"Processing {base_name}...")
progress_cb = cb_factory(base_name, 0.2, 0.7)
layer = Layer(data=data, name=base_name, kind="scalar")
layer.resize(
shape=layer_shape, smooth=smooth, progress_callback=progress_cb
)
layer.affine = affine
layers.append(layer)
return layers
# 模块级函数签名
def read(
filepath: str, series_id: str = "", progress_callback: ProgressCallback = None
) -> Data:
filepath = Path(filepath).resolve()
if not filepath.exists():
raise FileNotFoundError(f"File not found: {filepath}")
data_obj = Data(filepath=filepath, series_id=series_id)
data_obj.load_data(progress_callback)
return data_obj
def read_meta(filepath: str, series_id: str = "") -> Data:
data_obj = Data(filepath=filepath, series_id=series_id)
data_obj.load_meta()
return data_obj
def calc_layer_shape(
bioxel_size: float, orig_shape: tuple, orig_spacing: tuple
) -> tuple:
shape = (
int(orig_shape[0] / bioxel_size * orig_spacing[0]),
int(orig_shape[1] / bioxel_size * orig_spacing[1]),
int(orig_shape[2] / bioxel_size * orig_spacing[2]),
)
return (
shape[0] if shape[0] > 0 else 1,
shape[1] if shape[1] > 0 else 1,
shape[2] if shape[2] > 0 else 1,
)
def calc_layer_size(shape: tuple, bioxel_size: float, scale: float = 1.0) -> tuple:
size = (
float(shape[0] * bioxel_size * scale),
float(shape[1] * bioxel_size * scale),
float(shape[2] * bioxel_size * scale),
)
return size
def get_ext(filepath: Path) -> str:
if filepath.name.endswith(".nii.gz"):
return ".nii.gz"
elif filepath.name.endswith(".img.gz"):
return ".img.gz"
elif filepath.name.endswith(".gipl.gz"):
return ".gipl.gz"
elif filepath.name.endswith(".ome.tiff"):
return ".ome.tiff"
elif filepath.name.endswith(".ome.tif"):
return ".ome.tif"
elif filepath.name.endswith(".mrc.gz"):
return ".mrc.gz"
elif filepath.name.endswith(".map.gz"):
return ".map.gz"
else:
suffix = filepath.suffix
return "" if len(suffix) > 5 else suffix

View File

@@ -1,6 +1,12 @@
import copy
import numpy as np
# 条件导入 openvdb(只在 Blender 环境中存在)
try:
import openvdb as vdb
except ImportError:
vdb = None
from . import scipy
from . import scipy as ndi
@@ -78,8 +84,8 @@ class Layer():
mask_frame = mask[f, :, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
mode="nearest",
size=smooth)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
@@ -88,8 +94,8 @@ class Layer():
mask_frame = mask[:, :, :]
if smooth > 0:
mask_frame = scipy.median_filter(mask_frame.astype(np.float32),
mode="nearest",
size=smooth)
mode="nearest",
size=smooth)
# mask_frame = scipy.median_filter(
# mask_frame.astype(np.float32), size=2)
mask_frames += (mask_frame,)
@@ -150,3 +156,32 @@ class Layer():
mat_scale = transforms3d.zooms.zfdir2aff(factors[0])
self.affine = np.dot(self.affine, mat_scale)
def snapshot(self, shape: tuple, smooth: int = 0):
if len(shape) != 3:
raise Exception("Shape must be 3 dim")
data = self.data
snapshot = data[0, :, :, :, :]
factors = np.divide(self.shape, shape)
zoom_factors = [1 / f for f in factors]
order = 0 if snapshot.dtype == bool else 1
snapshot = ndi.zoom(snapshot,
zoom_factors+[1.0],
mode="nearest",
grid_mode=False,
order=order)
snapshot = snapshot.astype(np.float32)
if self.kind in ['scalar', 'vector', 'color']:
mn = float(np.nanmin(snapshot))
mx = float(np.nanmax(snapshot))
if mx - mn > 1e-8:
snapshot = (snapshot - mn) / (mx - mn)
else:
snapshot = np.clip(snapshot, 0.0, 1.0)
return snapshot

View File

@@ -39,7 +39,6 @@ from . import _ni_support
from . import _nd_image
def _invalid_origin(origin, lenw):
return (origin < -(lenw // 2)) or (origin > (lenw - 1) // 2)
@@ -73,7 +72,6 @@ def _complex_via_real_components(func, input, weights, output, cval, **kwargs):
return output
def correlate1d(input, weights, axis=-1, output=None, mode="reflect",
cval=0.0, origin=0):
"""Calculate a 1-D correlation along the given axis.
@@ -133,7 +131,6 @@ def correlate1d(input, weights, axis=-1, output=None, mode="reflect",
return output
def convolve1d(input, weights, axis=-1, output=None, mode="reflect",
cval=0.0, origin=0):
"""Calculate a 1-D convolution along the given axis.
@@ -205,7 +202,6 @@ def _gaussian_kernel1d(sigma, order, radius):
return q * phi_x
def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
mode="reflect", cval=0.0, truncate=4.0, *, radius=None):
"""1-D Gaussian filter.
@@ -274,7 +270,6 @@ def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
return correlate1d(input, weights, axis, output, mode, cval, 0)
def gaussian_filter(input, sigma, order=0, output=None,
mode="reflect", cval=0.0, truncate=4.0, *, radius=None,
axes=None):
@@ -381,7 +376,6 @@ def gaussian_filter(input, sigma, order=0, output=None,
return output
def prewitt(input, axis=-1, output=None, mode="reflect", cval=0.0):
"""Calculate a Prewitt filter.
@@ -443,7 +437,6 @@ def prewitt(input, axis=-1, output=None, mode="reflect", cval=0.0):
return output
def sobel(input, axis=-1, output=None, mode="reflect", cval=0.0):
"""Calculate a Sobel filter.
@@ -501,7 +494,6 @@ def sobel(input, axis=-1, output=None, mode="reflect", cval=0.0):
return output
def generic_laplace(input, derivative2, output=None, mode="reflect",
cval=0.0,
extra_arguments=(),
@@ -549,7 +541,6 @@ def generic_laplace(input, derivative2, output=None, mode="reflect",
return output
def laplace(input, output=None, mode="reflect", cval=0.0):
"""N-D Laplace filter based on approximate second derivatives.
@@ -584,7 +575,6 @@ def laplace(input, output=None, mode="reflect", cval=0.0):
return generic_laplace(input, derivative2, output, mode, cval)
def gaussian_laplace(input, sigma, output=None, mode="reflect",
cval=0.0, **kwargs):
"""Multidimensional Laplace filter using Gaussian second derivatives.
@@ -637,7 +627,6 @@ def gaussian_laplace(input, sigma, output=None, mode="reflect",
extra_keywords=kwargs)
def generic_gradient_magnitude(input, derivative, output=None,
mode="reflect", cval=0.0,
extra_arguments=(), extra_keywords=None):
@@ -690,7 +679,6 @@ def generic_gradient_magnitude(input, derivative, output=None,
return output
def gaussian_gradient_magnitude(input, sigma, output=None,
mode="reflect", cval=0.0, **kwargs):
"""Multidimensional gradient magnitude using Gaussian derivatives.
@@ -792,7 +780,6 @@ def _correlate_or_convolve(input, weights, output, mode, cval, origin,
return output
def correlate(input, weights, output=None, mode='reflect', cval=0.0,
origin=0):
"""
@@ -856,7 +843,6 @@ def correlate(input, weights, output=None, mode='reflect', cval=0.0,
origin, False)
def convolve(input, weights, output=None, mode='reflect', cval=0.0,
origin=0):
"""
@@ -967,7 +953,6 @@ def convolve(input, weights, output=None, mode='reflect', cval=0.0,
origin, True)
def uniform_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D uniform filter along the given axis.
@@ -1018,7 +1003,6 @@ def uniform_filter1d(input, size, axis=-1, output=None,
return output
def uniform_filter(input, size=3, output=None, mode="reflect",
cval=0.0, origin=0, *, axes=None):
"""Multidimensional uniform filter.
@@ -1088,7 +1072,6 @@ def uniform_filter(input, size=3, output=None, mode="reflect",
return output
def minimum_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D minimum filter along the given axis.
@@ -1145,7 +1128,6 @@ def minimum_filter1d(input, size, axis=-1, output=None,
return output
def maximum_filter1d(input, size, axis=-1, output=None,
mode="reflect", cval=0.0, origin=0):
"""Calculate a 1-D maximum filter along the given axis.
@@ -1302,7 +1284,6 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
return output
def minimum_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional minimum filter.
@@ -1350,7 +1331,6 @@ def minimum_filter(input, size=None, footprint=None, output=None,
cval, origin, 1, axes)
def maximum_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional maximum filter.
@@ -1398,7 +1378,6 @@ def maximum_filter(input, size=None, footprint=None, output=None,
cval, origin, 0, axes)
def _rank_filter(input, rank, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, operation='rank',
axes=None):
@@ -1491,7 +1470,6 @@ def _rank_filter(input, rank, size=None, footprint=None, output=None,
return output
def rank_filter(input, rank, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""Calculate a multidimensional rank filter.
@@ -1535,7 +1513,6 @@ def rank_filter(input, rank, size=None, footprint=None, output=None,
origin, 'rank', axes=axes)
def median_filter(input, size=None, footprint=None, output=None,
mode="reflect", cval=0.0, origin=0, *, axes=None):
"""
@@ -1586,7 +1563,6 @@ def median_filter(input, size=None, footprint=None, output=None,
origin, 'median', axes=axes)
def percentile_filter(input, percentile, size=None, footprint=None,
output=None, mode="reflect", cval=0.0, origin=0, *,
axes=None):
@@ -1630,7 +1606,6 @@ def percentile_filter(input, percentile, size=None, footprint=None,
cval, origin, 'percentile', axes=axes)
def generic_filter1d(input, function, filter_size, axis=-1,
output=None, mode="reflect", cval=0.0, origin=0,
extra_arguments=(), extra_keywords=None):
@@ -1718,7 +1693,6 @@ def generic_filter1d(input, function, filter_size, axis=-1,
return output
def generic_filter(input, function, size=None, footprint=None,
output=None, mode="reflect", cval=0.0, origin=0,
extra_arguments=(), extra_keywords=None):
@@ -1846,4 +1820,4 @@ def generic_filter(input, function, size=None, footprint=None,
mode = _ni_support._extend_mode_to_code(mode)
_nd_image.generic_filter(input, function, footprint, output, mode,
cval, origins, extra_arguments, extra_keywords)
return output
return output

View File

@@ -11,7 +11,7 @@ def _prepad_for_spline_filter(input, mode, cval):
npad = 12
if mode == 'grid-constant':
padded = np.pad(input, npad, mode='constant',
constant_values=cval)
constant_values=cval)
elif mode == 'nearest':
padded = np.pad(input, npad, mode='edge')
else:
@@ -21,6 +21,7 @@ def _prepad_for_spline_filter(input, mode, cval):
padded = input
return padded, npad
def spline_filter1d(input, order=3, axis=-1, output=np.float64,
mode='mirror'):
"""

View File

@@ -83,7 +83,8 @@ def _get_output(output, input, shape=None, complex_output=False):
elif isinstance(output, (type, np.dtype)):
# Classes (like `np.float32`) and dtypes are interpreted as dtype
if complex_output and np.dtype(output).kind != 'c':
warnings.warn("promoting specified output dtype to complex", stacklevel=3)
warnings.warn(
"promoting specified output dtype to complex", stacklevel=3)
output = np.promote_types(output, np.complex64)
output = np.zeros(shape, dtype=output)
elif isinstance(output, str):

View File

@@ -7,4 +7,4 @@ def normalize_axis_index(axis, ndim):
if axis < 0:
axis = axis + ndim
return axis
return axis

View File

@@ -3,6 +3,7 @@ import numpy as np
from .. import scipy as ndi
from ._utils import convert_to_float
def _clip_warp_output(input_image, output_image, mode, cval, clip):
"""Clip output image to range of values of input image.

View File

@@ -39,7 +39,8 @@ _integer_types = (
np.uintp,
np.uintc,
)
_integer_ranges = {t: (np.iinfo(t).min, np.iinfo(t).max) for t in _integer_types}
_integer_ranges = {t: (np.iinfo(t).min, np.iinfo(t).max)
for t in _integer_types}
dtype_range = {
bool: (False, True),
np.bool_: (False, True),
@@ -175,7 +176,8 @@ def _scale(a, n, m, copy=True):
# downscale with precision loss
if copy:
b = np.empty(a.shape, _dtype_bits(kind, m))
np.floor_divide(a, 2 ** (n - m), out=b, dtype=a.dtype, casting='unsafe')
np.floor_divide(a, 2 ** (n - m), out=b,
dtype=a.dtype, casting='unsafe')
return b
else:
a //= 2 ** (n - m)
@@ -282,7 +284,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
return image
if not (dtype_in in _supported_types and dtype_out in _supported_types):
raise ValueError(f'Cannot convert from {dtypeobj_in} to ' f'{dtypeobj_out}.')
raise ValueError(
f'Cannot convert from {dtypeobj_in} to ' f'{dtypeobj_out}.')
if kind_in in 'ui':
imin_in = np.iinfo(dtype_in).min
@@ -318,7 +321,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
if not uniform:
if kind_out == 'u':
image_out = np.multiply(image, imax_out, dtype=computation_type)
image_out = np.multiply(
image, imax_out, dtype=computation_type)
else:
image_out = np.multiply(
image, (imax_out - imin_out) / 2, dtype=computation_type
@@ -327,7 +331,8 @@ def _convert(image, dtype, force_copy=False, uniform=False):
np.rint(image_out, out=image_out)
np.clip(image_out, imin_out, imax_out, out=image_out)
elif kind_out == 'u':
image_out = np.multiply(image, imax_out + 1, dtype=computation_type)
image_out = np.multiply(
image, imax_out + 1, dtype=computation_type)
np.clip(image_out, 0, imax_out, out=image_out)
else:
image_out = np.multiply(

View File

@@ -1,270 +0,0 @@
from ast import literal_eval
from pathlib import Path
import bpy
from ..constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, VERSIONS
from ..utils import get_cache_dir
def move_node_to_node(node, target_node, offset=(0, 0)):
node.location.x = target_node.location.x + offset[0]
node.location.y = target_node.location.y + offset[1]
def move_node_between_nodes(node, target_nodes, offset=(0, 0)):
xs = []
ys = []
for target_node in target_nodes:
xs.append(target_node.location.x)
ys.append(target_node.location.y)
node.location.x = sum(xs) / len(xs) + offset[0]
node.location.y = sum(ys) / len(ys) + offset[1]
def get_node_type(node):
node_type = type(node).__name__
if node_type == "GeometryNodeGroup":
node_type = node.node_tree.name
return node_type
def get_nodes_by_type(node_group, type_name: str):
return [node for node in node_group.nodes if get_node_type(node) == type_name]
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.data.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
node_version = get_node_version()
if node_version is None:
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
return True
else:
addon_version = VERSIONS[0]["node_version"]
if node_version[0] != addon_version[0]\
or node_version[1] != addon_version[1]:
return True
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
use_local = False
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
use_local = True
break
not_update = get_node_version() != addon_version
return use_local and not_update
def get_output_node(node_group):
try:
output_node = get_nodes_by_type(node_group,
'NodeGroupOutput')[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
return output_node

View File

@@ -1,186 +0,0 @@
import bpy
from ..bioxel.container import Container
from bpy_extras.io_utils import axis_conversion
from mathutils import Matrix, Vector
from .layer import Layer, layer_to_obj, obj_to_layer
from .common import (get_container_layer_objs,
get_layer_prop_value,
get_nodes_by_type, get_output_node,
move_node_to_node)
from .node import add_node_to_graph
from ..utils import get_use_link
NODE_TYPE = {
"label": "BioxelNodes_MaskByLabel",
"scalar": "BioxelNodes_MaskByThreshold"
}
def calc_bbox_verts(origin: tuple, size: tuple):
bbox_origin = Vector(
(origin[0], origin[1], origin[2]))
bbox_size = Vector(
(size[0], size[1], size[2]))
bbox_verts = [
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0
),
(
bbox_origin[0] + 0,
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2]
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + 0
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + 0,
bbox_origin[2] + bbox_size[2],
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + 0,
),
(
bbox_origin[0] + bbox_size[0],
bbox_origin[1] + bbox_size[1],
bbox_origin[2] + bbox_size[2],
),
]
return bbox_verts
def obj_to_container(container_obj: bpy.types.Object):
layer_objs = get_container_layer_objs(container_obj)
layers = [obj_to_layer(obj) for obj in layer_objs]
container = Container(name=container_obj.name,
layers=layers)
return container
def add_layers(layers: list[Layer],
container_obj: bpy.types.Object,
cache_dir: str):
node_group = container_obj.modifiers[0].node_group
output_node = get_output_node(node_group)
for i, layer in enumerate(layers):
layer_obj = layer_to_obj(layer, container_obj, cache_dir)
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
if len(output_node.inputs[0].links) == 0:
node_group.links.new(fetch_node.outputs[0],
output_node.inputs[0])
move_node_to_node(fetch_node, output_node, (-900, 0))
else:
move_node_to_node(fetch_node, output_node, (0, -100 * (i+1)))
return container_obj
def container_to_obj(container: Container,
scene_scale: float,
step_size: float,
cache_dir: str):
# Wrapper a Container
# Make transformation
# (S)uperior -Z -> Y
# (A)osterior Y -> Z
mat_ras2blender = axis_conversion(from_forward='-Z',
from_up='Y',
to_forward='Y',
to_up='Z').to_4x4()
mat_scene_scale = Matrix.Scale(scene_scale,
4)
bpy.ops.mesh.primitive_cube_add(enter_editmode=False,
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
container_obj = bpy.context.active_object
bbox_verts = calc_bbox_verts((0, 0, 0), container.layers[0].shape)
for i, vert in enumerate(container_obj.data.vertices):
transform = Matrix(container.layers[0].affine)
vert.co = transform @ Vector(bbox_verts[i])
container_obj.matrix_world = mat_ras2blender @ mat_scene_scale
container_obj.name = container.name
container_obj.data.name = container.name
container_obj.show_in_front = True
container_obj.lock_location[0] = True
container_obj.lock_location[1] = True
container_obj.lock_location[2] = True
container_obj.lock_rotation[0] = True
container_obj.lock_rotation[1] = True
container_obj.lock_rotation[2] = True
container_obj.lock_scale[0] = True
container_obj.lock_scale[1] = True
container_obj.lock_scale[2] = True
container_obj['bioxel_container'] = True
container_obj["scene_scale"] = scene_scale
container_obj["step_size"] = step_size
modifier = container_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Output",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
node_group.nodes.new("NodeGroupOutput")
container_obj = add_layers(container.layers,
container_obj=container_obj,
cache_dir=cache_dir)
try:
layer_node = get_nodes_by_type(
node_group, "BioxelNodes_FetchLayer")[0]
center_node = add_node_to_graph("ReCenter",
node_group,
use_link=get_use_link())
output_node = get_output_node(node_group)
node_group.links.new(layer_node.outputs[0],
center_node.inputs[0])
node_group.links.new(center_node.outputs[0],
output_node.inputs[0])
move_node_to_node(center_node, layer_node, (300, 0))
bpy.ops.bioxelnodes.add_slicer('EXEC_DEFAULT')
except:
pass
return container_obj

View File

@@ -1,215 +0,0 @@
import random
import re
import bpy
import numpy as np
try:
import pyopenvdb as vdb
except:
import openvdb as vdb
from pathlib import Path
from uuid import uuid4
from ..bioxel.layer import Layer
from ..utils import get_use_link
from .node import add_node_to_graph
from .common import (get_layer_prop_value,
move_node_between_nodes)
def obj_to_layer(layer_obj: bpy.types.Object):
cache_filepath = Path(bpy.path.abspath(layer_obj.data.filepath)).resolve()
is_sequence = re.search(r'\.\d{4}\.',
cache_filepath.name) is not None
if is_sequence:
cache_path = cache_filepath.parent
data_frames = ()
for f in cache_path.iterdir():
if not f.is_file() or f.suffix != ".vdb":
continue
grids, base_metadata = vdb.readAll(str(f))
grid = grids[0]
metadata = grid.metadata
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data_frame = np.ndarray(data_shape, np.float32)
grid.copyToArray(data_frame)
data_frames += (data_frame,)
data = np.stack(data_frames)
else:
grids, base_metadata = vdb.readAll(str(cache_filepath))
grid = grids[0]
metadata = grid.metadata
if grid["layer_kind"] in ['label', 'scalar']:
data_shape = grid["data_shape"]
else:
data_shape = tuple(list(grid["data_shape"]) + [3])
data = np.ndarray(data_shape, np.float32)
grid.copyToArray(data)
data = np.expand_dims(data, axis=0) # expend frame
name = get_layer_prop_value(layer_obj, "name") \
or metadata["layer_name"]
kind = get_layer_prop_value(layer_obj, "kind") \
or metadata["layer_kind"]
affine = metadata["layer_affine"]
dtype = get_layer_prop_value(layer_obj, "dtype") \
or metadata.get("data_dtype") or "float32"
offset = get_layer_prop_value(layer_obj, "offset") \
or metadata.get("data_offset") or 0
data = data - np.full_like(data, offset)
data = data.astype(dtype)
if kind in ["scalar", "label"]:
data = np.expand_dims(data, axis=-1) # expend channel
layer = Layer(data=data,
name=name,
kind=kind,
affine=affine)
return layer
def layer_to_obj(layer: Layer,
container_obj: bpy.types.Object,
cache_dir: str):
data = layer.data
# TXYZC > TXYZ
if layer.kind in ['label', 'scalar']:
data = np.amax(data, -1)
offset = 0
if layer.kind in ['scalar']:
data = data.astype(np.float32)
orig_min = float(np.min(data))
if orig_min < 0:
offset = -orig_min
data = data + np.full_like(data, offset)
metadata = {
"layer_name": layer.name,
"layer_kind": layer.kind,
"layer_affine": layer.affine.tolist(),
"data_shape": layer.shape,
"data_dtype": layer.data.dtype.str,
"data_offset": offset
}
layer_display_name = f"{container_obj.name}_{layer.name}"
if layer.frame_count > 1:
print(f"Saving the Cache of {layer.name}...")
vdb_name = str(uuid4())
sequence_path = Path(cache_dir, vdb_name)
sequence_path.mkdir(parents=True, exist_ok=True)
cache_filepaths = []
for f in range(layer.frame_count):
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
grid.copyFromArray(
data[f, :, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
cache_filepath = Path(sequence_path,
f"{vdb_name}.{str(f+1).zfill(4)}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths.append(cache_filepath)
else:
if layer.kind in ['label', 'scalar']:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
else:
# color
grid = vdb.Vec3SGrid()
grid.copyFromArray(data[0, :, :, :, :].copy().astype(np.float32))
grid.transform = vdb.createLinearTransform(
layer.affine.transpose())
grid.metadata = metadata
grid.name = layer.kind
print(f"Saving the Cache of {layer.name}...")
cache_filepath = Path(cache_dir, f"{uuid4()}.vdb")
vdb.write(str(cache_filepath), grids=[grid])
cache_filepaths = [cache_filepath]
layer_data = bpy.data.volumes.new(layer_display_name)
layer_data.render.space = 'WORLD'
scene_scale = container_obj.get("scene_scale") or 0.01
step_size = container_obj.get("step_size") or 1
layer_data.render.step_size = scene_scale * step_size
layer_data.sequence_mode = 'REPEAT'
layer_data.filepath = str(cache_filepaths[0])
if layer.frame_count > 1:
layer_data.is_sequence = True
layer_data.frame_duration = layer.frame_count
else:
layer_data.is_sequence = False
layer_obj = bpy.data.objects.new(layer_display_name, layer_data)
layer_obj['bioxel_layer'] = True
print(f"Creating Node for {layer.name}...")
modifier = layer_obj.modifiers.new("GeometryNodes", 'NODES')
node_group = bpy.data.node_groups.new('GeometryNodes', 'GeometryNodeTree')
node_group.interface.new_socket(name="Cache",
in_out="INPUT",
socket_type="NodeSocketGeometry")
node_group.interface.new_socket(name="Layer",
in_out="OUTPUT",
socket_type="NodeSocketGeometry")
modifier.node_group = node_group
layer_node = add_node_to_graph("_Layer",
node_group,
use_link=get_use_link())
layer_node.inputs['name'].default_value = layer.name
layer_node.inputs['shape'].default_value = layer.shape
layer_node.inputs['kind'].default_value = layer.kind
for i in range(layer.affine.shape[1]):
for j in range(layer.affine.shape[0]):
affine_key = f"affine{i}{j}"
layer_node.inputs[affine_key].default_value = layer.affine[j, i]
layer_node.inputs['unique'].default_value = random.uniform(0, 1)
layer_node.inputs['bioxel_size'].default_value = layer.bioxel_size[0]
layer_node.inputs['dtype'].default_value = layer.dtype.str
layer_node.inputs['dtype_num'].default_value = layer.dtype.num
layer_node.inputs['frame_count'].default_value = layer.frame_count
layer_node.inputs['channel_count'].default_value = layer.channel_count
layer_node.inputs['offset'].default_value = max(0, -layer.min)
layer_node.inputs['min'].default_value = layer.min
layer_node.inputs['max'].default_value = layer.max
input_node = node_group.nodes.new("NodeGroupInput")
output_node = node_group.nodes.new("NodeGroupOutput")
node_group.links.new(input_node.outputs[0],
layer_node.inputs[0])
node_group.links.new(layer_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(
layer_node, [input_node, output_node])
layer_obj.parent = container_obj
return layer_obj

View File

@@ -1,71 +0,0 @@
from pathlib import Path
import bpy
from .common import get_file_prop, set_file_prop
from ..exceptions import NoFound
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
def get_node_tree(node_type: str, use_link=True):
# unannotate below for local debug in node lib file.
# node_group = bpy.data.node_groups[node_type]
# return node_group
# added node is always from latest node version
addon_version = VERSIONS[0]["node_version"]
addon_lib_path = LATEST_NODE_LIB_PATH
if get_file_prop("node_version") is None:
set_file_prop("node_version", addon_version)
local_lib = None
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
lib = node_group.library
if lib:
lib_path = Path(bpy.path.abspath(lib.filepath)).resolve()
if lib_path != addon_lib_path:
local_lib = lib.filepath
break
# local lib first
lib_path_str = local_lib or str(addon_lib_path)
with bpy.data.libraries.load(lib_path_str,
link=use_link,
relative=True) as (data_from, data_to):
data_to.node_groups = [n for n in data_from.node_groups
if n == node_type]
node_tree = data_to.node_groups[0]
if node_tree is None:
raise NoFound('No custom node found')
return node_tree
def assign_node_tree(node, node_tree):
node.node_tree = node_tree
node.width = 200.0
node.name = node_tree.name
return node
def add_node_to_graph(node_name: str, node_group, node_label=None, use_link=True):
node_type = f"BioxelNodes_{node_name}"
node_label = node_label or node_name
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
node_tree = get_node_tree(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_tree(node, node_tree)
node.label = node_label
node.show_options = False
return node

View File

@@ -1,20 +1,45 @@
schema_version = "1.0.0"
id = "bioxelnodes"
version = "1.0.9"
version = "2.0.0"
name = "Bioxel Nodes"
tagline = "For scientific volumetric data visualization in Blender"
maintainer = "Ma Nan <icrdr2010@outlook.com>"
maintainer = "Nan <icrdr2010@outlook.com>"
type = "add-on"
website = "https://omoolab.github.io/BioxelNodes/latest"
tags = ["Geometry Nodes", "Render", "Import-Export"]
blender_version_min = "4.2.0"
blender_version_min = "5.0.0"
license = ['SPDX:GPL-3.0-or-later']
copyright = ["2024 OmooLab"]
platforms = ["windows-x64"]
wheels = ["./wheels/h5py-3.11.0-cp311-cp311-win_amd64.whl", "./wheels/lxml-5.3.2-cp311-cp311-win_amd64.whl", "./wheels/mrcfile-1.5.1-py2.py3-none-any.whl", "./wheels/pyometiff-1.0.0-py3-none-any.whl", "./wheels/SimpleITK-2.3.1-cp311-cp311-win_amd64.whl", "./wheels/tifffile-2024.7.24-py3-none-any.whl", "./wheels/transforms3d-0.4.2-py3-none-any.whl"]
wheels = [
"./wheels/contourpy-1.3.3-cp311-cp311-win_amd64.whl",
"./wheels/contourpy-1.3.3-cp313-cp313-win_amd64.whl",
"./wheels/cycler-0.12.1-py3-none-any.whl",
"./wheels/fonttools-4.63.0-cp311-cp311-win_amd64.whl",
"./wheels/fonttools-4.63.0-cp313-cp313-win_amd64.whl",
"./wheels/h5py-3.14.0-cp311-cp311-win_amd64.whl",
"./wheels/h5py-3.14.0-cp313-cp313-win_amd64.whl",
"./wheels/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl",
"./wheels/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl",
"./wheels/lxml-6.1.1-cp311-cp311-win_amd64.whl",
"./wheels/lxml-6.1.1-cp313-cp313-win_amd64.whl",
"./wheels/matplotlib-3.10.7-cp311-cp311-win_amd64.whl",
"./wheels/matplotlib-3.10.7-cp313-cp313-win_amd64.whl",
"./wheels/mrcfile-1.5.1-py2.py3-none-any.whl",
"./wheels/packaging-26.2-py3-none-any.whl",
"./wheels/pillow-11.2.1-cp311-cp311-win_amd64.whl",
"./wheels/pillow-11.2.1-cp313-cp313-win_amd64.whl",
"./wheels/pyometiff-1.0.0-py3-none-any.whl",
"./wheels/pyparsing-3.3.2-py3-none-any.whl",
"./wheels/python_dateutil-2.9.0.post0-py2.py3-none-any.whl",
"./wheels/simpleitk-2.5.5-cp311-abi3-win_amd64.whl",
"./wheels/six-1.17.0-py2.py3-none-any.whl",
"./wheels/tifffile-2024.7.24-py3-none-any.whl",
"./wheels/transforms3d-0.4.2-py3-none-any.whl",
]
[permissions]
files = "Import/export volume data from/to disk"
files = "Import/export volume data from/to disk"

View File

@@ -1,257 +1,11 @@
from pathlib import Path
VERSIONS = [{"label": "Latest", "node_version": (1, 0, 7)},
{"label": "v0.3.x", "node_version": (0, 3, 3)},
{"label": "v0.2.x", "node_version": (0, 2, 9)}]
NODE_LIB_FILENAME = "O_Bioxel_Nodes.blend"
NODE_LIB_DIRPATH = Path(Path(__file__).parent,
"assets/Nodes").resolve()
"assets/O_Bioxel").resolve()
LATEST_NODE_LIB_PATH = lib_path = Path(NODE_LIB_DIRPATH,
"BioxelNodes_latest.blend").resolve()
LATEST_NODE_LIB_PATH = Path(NODE_LIB_DIRPATH,
NODE_LIB_FILENAME).resolve()
COMPONENT_OUTPUT_NODES = [
"CutoutByThreshold",
"CutoutByRange",
"CutoutByHue",
"JoinComponent",
"SetProperties",
"SetColor",
"SetColorByColor",
"SetColorByColor",
"SetColorByRamp2",
"SetColorByRamp3",
"SetColorByRamp4",
"SetColorByRamp5",
"Cut",
]
MENU_ITEMS = [
{
'label': 'Component',
'icon': 'OUTLINER_DATA_VOLUME',
'items': [
{
'label': 'Cutout by Threshold',
'icon': 'EMPTY_SINGLE_ARROW',
'name': 'CutoutByThreshold',
'description': ''
},
{
'label': 'Cutout by Range',
'icon': 'IPO_CONSTANT',
'name': 'CutoutByRange',
'description': ''
},
{
'label': 'Cutout by Hue',
'icon': 'COLOR',
'name': 'CutoutByHue',
'description': ''
},
"separator",
{
'label': 'To Surface',
'icon': 'MESH_DATA',
'name': 'ToSurface',
'description': ''
},
{
'label': 'Join Component',
'icon': 'CONSTRAINT_BONE',
'name': 'JoinComponent',
'description': ''
},
{
'label': 'Slice',
'icon': 'TEXTURE',
'name': 'Slice',
'description': ''
}
]
},
{
'label': 'Property',
'icon': 'PROPERTIES',
'items': [
{
'label': 'Set Properties',
'icon': 'PROPERTIES',
'name': 'SetProperties',
'description': ''
},
"separator",
{
'label': 'Set Color',
'icon': 'IPO_SINE',
'name': 'SetColor',
'description': ''
},
{
'label': 'Set Color by Color',
'icon': 'IPO_QUINT',
'name': 'SetColorByColor',
'description': ''
},
{
'label': 'Set Color by Ramp 2',
'icon': 'IPO_QUAD',
'name': 'SetColorByRamp2',
'description': ''
},
{
'label': 'Set Color by Ramp 3',
'icon': 'IPO_CUBIC',
'name': 'SetColorByRamp3',
'description': ''
},
{
'label': 'Set Color by Ramp 4',
'icon': 'IPO_QUART',
'name': 'SetColorByRamp4',
'description': ''
},
{
'label': 'Set Color by Ramp 5',
'icon': 'IPO_QUINT',
'name': 'SetColorByRamp5',
'description': ''
}
]
},
{
'label': 'Surface',
'icon': 'MESH_DATA',
'items': [
{
'label': 'Membrane Shader',
'icon': 'NODE_MATERIAL',
'name': 'AssignMembraneShader',
'description': ''
},
{
'label': 'Solid Shader',
'icon': 'SHADING_SOLID',
'name': 'AssignSolidShader',
'description': ''
},
{
'label': 'Slime Shader',
'icon': 'OUTLINER_DATA_META',
'name': 'AssignSlimeShader',
'description': ''
},
]
},
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'items': [
{
'label': 'Transform',
'icon': 'EMPTY_AXIS',
'name': 'Transform',
'description': ''
},
{
'label': 'Transform Parent',
'icon': 'ORIENTATION_PARENT',
'name': 'TransformParent',
'description': ''
},
{
'label': 'ReCenter',
'icon': 'PROP_CON',
'name': 'ReCenter',
'description': ''
},
{
'label': 'Copy Transform',
'icon': 'EMPTY_AXIS',
'name': 'CopyTransform',
'description': ''
},
{
'label': 'Extract Transform',
'icon': 'EMPTY_AXIS',
'name': 'ExtractTransform',
'description': ''
},
]
},
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'items': [
{
'label': 'Cut',
'icon': 'MOD_BEVEL',
'name': 'Cut',
'description': ''
},
"separator",
{
'label': 'Primitive Cutter',
'icon': 'MOD_LINEART',
'name': 'PrimitiveCutter',
'description': ''
},
{
'label': 'Object Cutter',
'icon': 'MESH_PLANE',
'name': 'ObjectCutter',
'description': ''
}
]
},
{
'label': 'Extra',
'icon': 'MODIFIER',
'items': [
{
'label': 'Fetch Mesh',
'icon': 'OUTLINER_OB_MESH',
'name': 'FetchMesh',
'description': ''
},
{
'label': 'Fetch Volume',
'icon': 'OUTLINER_OB_VOLUME',
'name': 'FetchVolume',
'description': ''
},
{
'label': 'Fetch Shape Wire',
'icon': 'FILE_VOLUME',
'name': 'FetchShapeWire',
'description': ''
},
{
'label': 'Fetch Bbox Wire',
'icon': 'MESH_CUBE',
'name': 'FetchBboxWire',
'description': ''
},
"separator",
{
'label': 'Inflate',
'icon': 'OUTLINER_OB_META',
'name': 'Inflate',
'description': ''
},
{
'label': 'Smooth',
'icon': 'MOD_SMOOTH',
'name': 'Smooth',
'description': ''
},
{
'label': 'Remove Small Island',
'icon': 'FORCE_LENNARDJONES',
'name': 'RemoveSmallIsland',
'description': ''
}
]
}
]
PREVIEW_COLLECTIONS = {}

View File

@@ -1,22 +1,6 @@
class CancelledByUser(Exception):
def __init__(self):
message = 'Cancelled by user'
super().__init__(message)
class NoContent(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class NoFound(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
class Incompatible(Exception):
def __init__(self, message):
super().__init__(message)
self.message = message
# This file is kept for backward compatibility
# Custom exceptions have been replaced with built-in exceptions:
# CancelledByUser -> CancelledError
# NoContent -> ValueError
# NoFound -> LookupError
# Incompatible -> ValueError

278
src/bioxelnodes/layer.py Normal file
View File

@@ -0,0 +1,278 @@
import json
import time
from typing import Any, List, Dict
from pathlib import Path
import matplotlib.pyplot as plt
import bpy
import numpy as np
try:
import openvdb as vdb
except ImportError:
vdb = None
from .bioxel.layer import Layer
from .utils import ndarray_to_png
LAYERS_JSON = "bioxel_layers"
def cache_layer_data(layer: Layer, cache_path: str):
"""
Cache the given Layer's data as one or more VDB files.
- For multi-frame layers, writes per-frame VDB files named data.0001.vdb, data.0002.vdb, ...
- For single-frame layers, writes a single data.vdb file.
- Applies basic type handling:
- label/scalar layers collapse channel dimension via max.
- scalar layers are offset to avoid negative values.
- The VDB grids will have their transform set from layer.affine but no additional metadata is written.
Parameters:
- layer: Layer object containing ndarray data and metadata.
- cache_path: directory path where VDB files will be written (created if missing).
"""
data = layer.data
# 处理标量/标签类型(去除通道维度)
if layer.kind in ["label", "scalar"]:
data = np.amax(data, -1)
# 标量类型偏移处理(避免负值)
offset = 0
if layer.kind in ["scalar"]:
data = data.astype(np.float32)
orig_min = float(np.min(data))
if orig_min < 0:
offset = -orig_min
data = data + np.full_like(data, offset)
# 创建缓存目录
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
# 多帧序列处理
if layer.frame_count > 1:
for f in range(layer.frame_count):
# 根据图层类型创建VDB网格
if layer.kind in ["label", "scalar"]:
grid = vdb.FloatGrid()
grid.copyFromArray(data[f, :, :, :].copy().astype(np.float32))
else: # 颜色类型
grid = vdb.Vec3SGrid()
grid.copyFromArray(data[f, :, :, :, :].copy().astype(np.float32))
# 仅设置transform,不存储metadata
grid.transform = vdb.createLinearTransform(layer.affine.transpose())
grid.name = layer.kind
# 保存序列帧VDB
data_filepath = cache_path / f"data.{str(f+1).zfill(4)}.vdb"
vdb.write(str(data_filepath), grids=[grid])
else:
# 单帧处理
if layer.kind in ["label", "scalar"]:
grid = vdb.FloatGrid()
grid.copyFromArray(data[0, :, :, :].copy().astype(np.float32))
else: # 颜色类型
grid = vdb.Vec3SGrid()
grid.copyFromArray(data[0, :, :, :, :].copy().astype(np.float32))
# 仅设置transform,不存储metadata
grid.transform = vdb.createLinearTransform(layer.affine.transpose())
grid.name = layer.kind
# 保存单帧VDB
data_filepath = cache_path / "data.vdb"
vdb.write(str(data_filepath), grids=[grid])
def cache_layer_snapshot(layer: Layer, cache_path: str):
"""
Create and save a low-resolution snapshot (numpy .npy) of the layer and generate PNG slices.
- Saves a 3D numpy ndarray snapshot to <cache_path>/snapshot.npy.
- Then creates per-Z PNGs in the same directory via snapshot_to_pngs.
Parameters:
- layer: Layer to snapshot (expects Layer.snapshot method).
- cache_path: destination directory where snapshot.npy and PNGs will be created.
"""
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
snapshot_filepath = cache_path / "snapshot.npy"
snapshot = layer.snapshot((64, 64, 32))
np.save(str(snapshot_filepath), snapshot)
snapshot_to_pngs(
snapshot, str(cache_path), normalize=layer.kind in ["scalar", "vector"]
)
if layer.kind in ["scalar", "vector", "color"]:
plt.figure(figsize=(8, 8))
def plot_histogram(plt, data, color="black"):
flattened = data.flatten()
x_min = np.percentile(flattened, 1)
x_max = np.percentile(flattened, 99)
n, bins, _ = plt.hist(
flattened,
bins=50, # 自动根据数据密度调整bins数量(避免空柱)
range=(x_min, x_max), # 只显示核心区域,省略稀疏部分
alpha=0,
edgecolor="none", # 去除边框,使柱形更紧凑
)
bin_centers = 0.5 * (bins[1:] + bins[:-1]) # 计算每个bin的中点
plt.plot(bin_centers, n, color=color, linewidth=4) # 折线
plt.fill_between(bin_centers, n, color=color, alpha=0.1)
if layer.kind == "scalar":
plot_histogram(plt, layer.data[:, :, :, 0], color="black")
elif layer.kind in ["vector", "color"]:
plot_histogram(plt, layer.data[:, :, :, 0], color="tomato")
plot_histogram(plt, layer.data[:, :, :, 1], color="yellowgreen")
plot_histogram(plt, layer.data[:, :, :, 2], color="xkcd:azure")
plt.yscale("log")
plt.gca().get_yaxis().set_visible(False)
plt.xticks(fontsize=32, rotation=45) # 调整字体大小(数值越大字体越大)
ax = plt.gca()
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_visible(False) # 隐藏左边框
# 调整布局避免标签被截断
plt.tight_layout()
# 保存图片到指定路径
save_path = cache_path / "histogram.png"
plt.savefig(save_path, bbox_inches="tight", pad_inches=0.1) # 减少边缘留白)
# 关闭图像释放资源
plt.close()
def snapshot_to_pngs(snapshot, cache_path: str, normalize=False):
"""
Convert a 3D/4D snapshot ndarray into per-slice PNG files.
- snapshot is expected with axes order X, Y, Z, (C optional).
- Writes files snapshot_0.png ... snapshot_{Z-1}.png into cache_path.
- Uses ndarray_to_png to perform the per-slice conversion.
Parameters:
- snapshot: numpy ndarray (X, Y, Z[, C])
- cache_path: directory where generated PNGs are stored.
"""
cache_path = Path(cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
if normalize:
mn = float(np.percentile(snapshot, 50))
mx = float(np.percentile(snapshot, 99))
if mx - mn > 1e-8:
snapshot = (snapshot - mn) / (mx - mn)
else:
snapshot = np.clip(snapshot, 0.0, 1.0)
shape = snapshot.shape
for zidx in range(shape[2]):
array = snapshot[:, :, zidx, :]
ndarray_to_png(array, str(cache_path / f"snapshot_{zidx}.png"))
def get_layer_caches() -> List[Dict[str, Any]]:
"""
Read the saved layer metadata list from Blender's internal text datablock.
- Looks for a text datablock named by LAYERS_JSON ("bioxel_layers").
- Returns the parsed list of layer dictionaries if present; otherwise returns an empty list.
Returns:
- list of layer metadata dictionaries (possibly empty)
"""
# 检查文本数据块是否存在
if LAYERS_JSON not in bpy.data.texts:
return []
# 加载并解析数据
layers_text = bpy.data.texts[LAYERS_JSON]
try:
layers_data = json.loads(layers_text.as_string())
# 确保返回格式为列表
return layers_data if isinstance(layers_data, list) else []
except:
return []
def set_layer_caches(layers_data: List[Dict[str, Any]]):
"""
Write the provided list of layer metadata dictionaries into the Blender text datablock.
- Ensures the LAYERS_JSON text datablock exists, then overwrites it with pretty JSON.
Parameters:
- layers_data: list of serializable dictionaries describing the layers
"""
# 获取或创建文本数据块
if LAYERS_JSON not in bpy.data.texts:
bpy.data.texts.new(LAYERS_JSON)
layers_text = bpy.data.texts[LAYERS_JSON]
# 写入数据
layers_text.clear()
layers_text.write(json.dumps(layers_data, indent=4))
def save_layers_to_json(layers: List[Layer], cache_dir: str) -> List[int]:
"""
Save multiple Layer objects into the internal layers text datablock.
For each layer:
- Generates a unique cache id.
- Writes VDB files and a low-resolution snapshot (.npy) plus PNG slices under cache_dir/<cache_id>/.
- Appends an entry describing the layer into the b i o x e l _ l a y e r s text datablock.
Returns:
- List of generated cache ids for the saved layers.
"""
existing_data = get_layer_caches()
added_ids = []
cache_dir_path = Path(cache_dir)
cache_dir_path.mkdir(parents=True, exist_ok=True)
for idx, layer in enumerate(layers):
cache_id = str(int(time.time())) + str(idx)
cache_path = cache_dir_path / str(cache_id)
cache_layer_data(layer, cache_path)
cache_layer_snapshot(layer, cache_path)
# build layer_info
cache_info = {
"id": cache_id,
"name": layer.name,
"kind": layer.kind,
"shape": layer.shape,
"affine": layer.affine.tolist(),
"bioxel_size": layer.bioxel_size[0],
"dtype": layer.dtype.str,
"dtype_num": layer.dtype.num,
"frame_count": layer.frame_count,
"channel_count": layer.channel_count,
"offset": max(0, -layer.min),
"min": layer.min,
"max": layer.max,
"path": bpy.path.abspath(str(cache_path)),
"snapshot_z": 0.5,
}
existing_data.append(cache_info)
added_ids.append(cache_id)
set_layer_caches(existing_data)
print(f"Successfully added {len(added_ids)} layers to the internal text datablock")
return added_ids

View File

@@ -1,187 +1,40 @@
import bpy
from .operators.structure import ExtractMesh
from .constants import MENU_ITEMS, VERSIONS
from .node_menu import NodeMenu
from .bioxelutils.common import (get_container_obj,
get_container_layer_objs, get_layer_label,
get_layer_prop_value, get_node_version, is_incompatible)
from .operators.layer import (FetchLayer, RelocateLayer, RetimeLayer, RenameLayer,
RemoveSelectedLayers, SaveSelectedLayersCache,
ResampleLayer, SignScalar, CombineLabels,
FillByLabel, FillByThreshold, FillByRange)
from .operators.container import (AddLocator, AddSlicer, ContainerProps,
SaveContainerLayersCache, RemoveContainerMissingLayers,
SaveContainer, LoadContainer,
AddPieCutter, AddPlaneCutter,
AddCylinderCutter, AddCubeCutter, AddSphereCutter,
ExtractBboxWire, ExtractMesh, ExtractShapeWire,
ExtractNodeMesh, ExtractNodeBboxWire, ExtractNodeShapeWire)
from .operators.io import (ImportAsLabel, ImportAsScalar, ImportAsColor)
from .operators.misc import (AddEeveeEnv, CleanTemp, Help,
ReLinkNodeLib, RemoveAllMissingLayers,
RenderSettingPreset, SaveAllLayersCache,
SaveNodeLib, SliceViewer)
class IncompatibleMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_INCOMPATIBLE"
bl_label = "Bioxel Nodes"
def draw(self, context):
tip_text = "please downgrade addon version."
node_version = get_node_version()
if node_version:
version_str = "v"+".".join([str(i) for i in list(node_version)])
tip_text = f"please downgrade addon version to {version_str}."
layout = self.layout
layout.label(text="Incompatible node version detected.")
layout.separator()
layout.label(
text="If you still want to edit this file with bioxel nodes, ")
layout.label(text=tip_text)
layout.separator()
layout.label(text="If this file is archived, "
"please relink node library, ")
layout.label(text="check if it still works, "
"then save node library.")
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
class FetchLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADD_LAYER"
bl_label = "Fetch Layer"
def draw(self, context):
container_obj = get_container_obj(bpy.context.active_object)
layer_objs = get_container_layer_objs(container_obj)
layout = self.layout
for layer_obj in layer_objs:
op = layout.operator(FetchLayer.bl_idname,
text=get_layer_label(layer_obj))
op.layer_obj_name = layer_obj.name
class ExtractFromContainerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_PICK"
bl_label = "Extract from Container"
def draw(self, context):
layout = self.layout
layout.operator(ExtractMesh.bl_idname)
layout.operator(ExtractShapeWire.bl_idname)
layout.operator(ExtractBboxWire.bl_idname)
class AddCutterMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_CUTTERS"
bl_label = "Add a Cutter"
def draw(self, context):
layout = self.layout
layout.operator(AddPlaneCutter.bl_idname,
icon=AddPlaneCutter.bl_icon, text="Plane Cutter")
layout.operator(AddCylinderCutter.bl_idname,
icon=AddCylinderCutter.bl_icon, text="Cylinder Cutter")
layout.operator(AddCubeCutter.bl_idname,
icon=AddCubeCutter.bl_icon, text="Cube Cutter")
layout.operator(AddSphereCutter.bl_idname,
icon=AddSphereCutter.bl_icon, text="Sphere Cutter")
layout.operator(AddPieCutter.bl_idname,
icon=AddPieCutter.bl_icon, text="Pie Cutter")
from .operators.io import ImportAsLabel, ImportAsScalar, ImportAsColor
from .operators.misc import (
CleanTemp,
Help,
RenderSettingPreset,
TogglePhantom,
)
class ImportLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_IMPORTLAYER"
bl_idname = "BIOXEL_MT_IMPORTLAYER"
bl_label = "Import Volumetric Data (Init)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class AddLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_ADDLAYER"
bl_label = "Import Volumetric Data (Add)"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="as Scalar")
layout.operator(ImportAsLabel.bl_idname,
text="as Label")
layout.operator(ImportAsColor.bl_idname,
text="as Color")
class ModifyLayerMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_MODIFYLAYER"
bl_label = "Modify Layer"
bl_icon = "FILE_NEW"
def draw(self, context):
layout = self.layout
layout.operator(SignScalar.bl_idname,
icon=SignScalar.bl_icon)
layout.operator(FillByThreshold.bl_idname,
icon=FillByThreshold.bl_icon)
layout.operator(FillByRange.bl_idname,
icon=FillByRange.bl_icon)
layout.operator(FillByLabel.bl_idname,
icon=FillByLabel.bl_icon)
layout.operator(CombineLabels.bl_idname,
icon=CombineLabels.bl_icon)
class ReLinkNodeLibMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RELINK"
bl_label = "Relink Node Library"
def draw(self, context):
layout = self.layout
for index, version in enumerate(VERSIONS):
op = layout.operator(ReLinkNodeLib.bl_idname,
text=version["label"])
op.index = index
layout.operator(ImportAsScalar.bl_idname, text="as Scalar")
layout.operator(ImportAsLabel.bl_idname, text="as Label")
layout.operator(ImportAsColor.bl_idname, text="as Color")
class RenderSettingMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_RENDER"
bl_idname = "BIOXEL_MT_RENDER"
bl_label = "Render Setting Presets"
def draw(self, context):
layout = self.layout
for k, v in RenderSettingPreset.PRESETS.items():
op = layout.operator(RenderSettingPreset.bl_idname,
text=v)
op = layout.operator(RenderSettingPreset.bl_idname, text=v)
op.preset = k
class DangerZoneMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_DANGER"
bl_idname = "BIOXEL_MT_DANGER"
bl_label = "Danger Zone"
def draw(self, context):
@@ -190,118 +43,44 @@ class DangerZoneMenu(bpy.types.Menu):
class BioxelNodesTopbarMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_TOPBAR"
bl_idname = "BIOXEL_MT_TOPBAR"
bl_label = "Bioxel Nodes"
def draw(self, context):
layout = self.layout
layout.menu(ImportLayerMenu.bl_idname,
icon=ImportLayerMenu.bl_icon)
layout.operator(LoadContainer.bl_idname)
layout.separator()
layout.operator(SaveAllLayersCache.bl_idname)
layout.operator(RemoveAllMissingLayers.bl_idname)
layout.separator()
layout.menu(ReLinkNodeLibMenu.bl_idname)
layout.operator(SaveNodeLib.bl_idname)
layout.separator()
layout.menu(DangerZoneMenu.bl_idname)
layout.separator()
layout.operator(AddEeveeEnv.bl_idname)
layout.menu(RenderSettingMenu.bl_idname)
layout.separator()
layout.operator(Help.bl_idname,
icon=Help.bl_icon)
def TOPBAR(self, context):
layout = self.layout
if is_incompatible():
layout.menu(IncompatibleMenu.bl_idname)
else:
layout.menu(BioxelNodesTopbarMenu.bl_idname)
class NodeHeadMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_HEAD"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.menu(AddLayerMenu.bl_idname,
icon=AddLayerMenu.bl_icon)
layout.operator(SaveContainer.bl_idname)
layout.separator()
layout.operator(ContainerProps.bl_idname)
layout.menu(ExtractFromContainerMenu.bl_idname)
layout.separator()
layout.operator(SaveContainerLayersCache.bl_idname,
icon=SaveContainerLayersCache.bl_icon)
layout.operator(RemoveContainerMissingLayers.bl_idname)
layout.separator()
layout.operator(AddLocator.bl_idname,
icon=AddLocator.bl_icon)
layout.operator(AddSlicer.bl_idname,
icon=AddSlicer.bl_icon)
layout.menu(AddCutterMenu.bl_idname)
layout.separator()
layout.menu(FetchLayerMenu.bl_idname)
layout.operator(Help.bl_idname, icon=Help.bl_icon)
class NodeContextMenu(bpy.types.Menu):
bl_idname = "BIOXELNODES_MT_NODE_CONTEXT"
bl_idname = "BIOXEL_MT_NODE_CONTEXT"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
def draw(self, context):
layout = self.layout
layout.operator(ExtractNodeMesh.bl_idname)
layout.operator(ExtractNodeShapeWire.bl_idname)
layout.operator(ExtractNodeBboxWire.bl_idname)
layout.operator(ExtractMesh.bl_idname)
layout.separator()
layout.operator(SaveSelectedLayersCache.bl_idname,
icon=SaveSelectedLayersCache.bl_icon)
layout.operator(RemoveSelectedLayers.bl_idname)
layout.separator()
layout.operator(RenameLayer.bl_idname,
icon=RenameLayer.bl_icon)
layout.operator(RetimeLayer.bl_idname)
layout.operator(RelocateLayer.bl_idname)
class ObjectContextMenu(bpy.types.Menu):
bl_idname = "BIOXEL_MT_OBJECT_CONTEXT"
bl_label = "Bioxel Nodes"
bl_icon = "FILE_VOLUME"
layout.separator()
layout.operator(ResampleLayer.bl_idname,
icon=ResampleLayer.bl_icon)
layout.operator(SignScalar.bl_idname)
layout.operator(FillByThreshold.bl_idname)
layout.operator(FillByRange.bl_idname)
layout.operator(FillByLabel.bl_idname)
layout.operator(CombineLabels.bl_idname)
def draw(self, context):
layout = self.layout
layout.operator(TogglePhantom.bl_idname)
def NODE_CONTEXT(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
if not is_geo_nodes or not container_obj:
return
layout = self.layout
@@ -309,127 +88,18 @@ def NODE_CONTEXT(self, context):
layout.menu(NodeContextMenu.bl_idname)
def NODE_HEAD(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
if not is_geo_nodes or not is_container:
return
# 定义添加到右键菜单的函数,放在首位
def OBJECT_CONTEXT(self, context):
layout = self.layout
layout.separator()
layout.menu(NodeHeadMenu.bl_idname)
def NODE_PROP(self, context):
if is_incompatible():
return
container_obj = context.object
is_geo_nodes = context.area.ui_type == "GeometryNodeTree"
is_container = get_container_obj(container_obj)
self.bl_label = "Group"
if not is_geo_nodes or not is_container:
return
if container_obj.modifiers[0].node_group != context.space_data.edit_tree:
return
self.bl_label = "Bioxel Nodes"
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list.clear()
for layer_obj in get_container_layer_objs(container_obj):
layer_item = layer_list.add()
layer_item.label = get_layer_label(layer_obj)
layer_item.obj_name = layer_obj.name
layer_item.info_text = "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
layout = self.layout
layout.label(text="Layer List")
split = layout.row()
split.template_list(listtype_name="BIOXELNODES_UL_layer_list",
list_id="layer_list",
dataptr=layer_list_UL,
propname="layer_list",
active_dataptr=layer_list_UL,
active_propname="layer_list_active",
item_dyntip_propname="info_text",
rows=20)
# sidebar = split.column(align=True)
# sidebar.menu(AddLayerMenu.bl_idname,
# icon=AddLayerMenu.bl_icon, text="")
# sidebar.operator(SaveContainerLayersCache.bl_idname,
# icon=SaveContainerLayersCache.bl_icon, text="")
# sidebar.operator(RemoveContainerMissingLayers.bl_idname,
# icon=RemoveContainerMissingLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SaveSelectedLayersCache.bl_idname,
# icon=SaveSelectedLayersCache.bl_icon, text="")
# sidebar.operator(RemoveSelectedLayers.bl_idname,
# icon=RemoveSelectedLayers.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(RenameLayer.bl_idname,
# icon=RenameLayer.bl_icon, text="")
# sidebar.operator(ResampleLayer.bl_idname,
# icon=ResampleLayer.bl_icon, text="")
# sidebar.operator(RetimeLayer.bl_idname,
# icon=RetimeLayer.bl_icon, text="")
# sidebar.separator()
# sidebar.operator(SignScalar.bl_idname,
# icon=SignScalar.bl_icon, text="")
# sidebar.operator(FillByThreshold.bl_idname,
# icon=FillByThreshold.bl_icon, text="")
# sidebar.operator(FillByRange.bl_idname,
# icon=FillByRange.bl_icon, text="")
# sidebar.operator(FillByLabel.bl_idname,
# icon=FillByLabel.bl_icon, text="")
# sidebar.separator()
# layout.separator()
def VIEW3D_TOPBAR(self, context):
layout = self.layout
layout.operator(SliceViewer.bl_idname)
node_menu = NodeMenu(
menu_items=MENU_ITEMS
)
layout.menu(ObjectContextMenu.bl_idname)
def add():
node_menu.register()
# bpy.types.VIEW3D_HT_header.append(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.append(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.prepend(NODE_PROP)
bpy.types.NODE_MT_editor_menus.append(NODE_HEAD)
bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT)
bpy.types.VIEW3D_MT_object_context_menu.append(OBJECT_CONTEXT)
# bpy.types.NODE_MT_context_menu.append(NODE_CONTEXT)
def remove():
# bpy.types.VIEW3D_HT_header.remove(VIEW3D_TOPBAR)
bpy.types.TOPBAR_MT_editor_menus.remove(TOPBAR)
bpy.types.NODE_PT_node_tree_properties.remove(NODE_PROP)
bpy.types.NODE_MT_editor_menus.remove(NODE_HEAD)
bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT)
node_menu.unregister()
bpy.types.VIEW3D_MT_object_context_menu.remove(OBJECT_CONTEXT)
# bpy.types.NODE_MT_context_menu.remove(NODE_CONTEXT)

149
src/bioxelnodes/node.py Normal file
View File

@@ -0,0 +1,149 @@
from pathlib import Path
import bpy
# LookupError replaced with built-in LookupError
from .constants import LATEST_NODE_LIB_PATH, NODE_LIB_FILENAME
def move_node_to_node(node, target_node, offset=(0, 0)):
node.location.x = target_node.location.x + offset[0]
node.location.y = target_node.location.y + offset[1]
def move_node_between_nodes(node, target_nodes, offset=(0, 0)):
xs = []
ys = []
for target_node in target_nodes:
xs.append(target_node.location.x)
ys.append(target_node.location.y)
node.location.x = sum(xs) / len(xs) + offset[0]
node.location.y = sum(ys) / len(ys) + offset[1]
def get_node_type(node):
node_type = type(node).__name__
if node_type == "GeometryNodeGroup":
node_type = node.node_tree.name
return node_type
def get_nodes_by_type(node_group, type_name: str):
return [node for node in node_group.nodes if get_node_type(node) == type_name]
def get_node_tree(node_type: str, use_link=True):
try:
return bpy.data.node_groups[node_type]
except KeyError:
lib_path_str = str(LATEST_NODE_LIB_PATH)
with bpy.data.libraries.load(lib_path_str, link=use_link, relative=True) as (
data_from,
data_to,
):
data_to.node_groups = [
n for n in data_from.node_groups if n == node_type]
node_tree = data_to.node_groups[0]
if node_tree is None:
raise LookupError("No custom node found")
return node_tree
def assign_node_tree(node, node_tree):
node.node_tree = node_tree
node.width = 200.0
node.name = node_tree.name
return node
def add_node_to_graph(node_name: str, node_group, node_label=None, use_link=True):
node_type = f"O {node_name}"
node_label = node_label or node_name
# Deselect all nodes first
for node in node_group.nodes:
if node.select:
node.select = False
node_tree = get_node_tree(node_type, use_link)
node = node_group.nodes.new("GeometryNodeGroup")
assign_node_tree(node, node_tree)
node.label = node_label
node.show_options = False
bpy.ops.node.view_selected()
return node
def get_output_node(node_group):
try:
output_node = get_nodes_by_type(node_group, "NodeGroupOutput")[0]
except:
output_node = node_group.nodes.new("NodeGroupOutput")
return output_node
def add_bioxel_node(name: str):
# bpy.ops.node.add_group_asset(
# asset_library_type="CUSTOM",
# asset_library_identifier = "O Bioxel",
# relative_asset_identifier = "NodeTreO Crop Layer"
# )
# bpy.ops.node.add_group_asset(
# asset_library_type="CUSTOM",
# asset_library_identifier="O Bioxel",
# relative_asset_identifier="Nodes.blend\\NodeTree\\O Render Structure"
# )
try:
node_tree = bpy.data.node_groups[name]
except KeyError:
bpy.ops.object.modifier_add_node_group(
asset_library_type="CUSTOM",
asset_library_identifier="O Bioxel",
relative_asset_identifier=f"{NODE_LIB_FILENAME}\\NodeTree\\{name}",
)
bpy.ops.object.modifier_remove(modifier=name)
try:
node_tree = bpy.data.node_groups[name]
except Exception as e:
raise e
bpy.ops.node.add_node(
"INVOKE_DEFAULT", type="GeometryNodeGroup", use_transform=True
)
node = bpy.context.active_node
node.node_tree = node_tree
node.show_options = False
return bpy.context.active_node
def get_layer_nodes(node_group):
"""Return all O Layer nodes in the given node_tree."""
return [
n
for n in node_group.nodes
if n.bl_idname == "GeometryNodeGroup"
and getattr(n, "node_tree", None)
and n.node_tree.name.startswith("O Layer")
]
def get_main_node_group(context):
space = getattr(context, "space_data", None) or getattr(
context, "space", None)
if not space:
return None
node_tree = getattr(space, "node_tree", None)
if not node_tree:
return None
# accept GeometryNodeTree by bl_idname or class name for compatibility
if getattr(node_tree, "bl_idname", "") == "GeometryNodeTree" or node_tree.__class__.__name__ == "GeometryNodeTree":
return node_tree
return None

View File

@@ -1,87 +0,0 @@
import bpy
from .operators.node import AddNode
class NodeMenu():
def __init__(
self,
menu_items,
) -> None:
self.menu_items = menu_items
self.class_prefix = f"BIOXELNODES_MT"
root_label = "Bioxel Nodes"
menu_classes = []
self._create_menu_class(
items=menu_items,
label=root_label,
menu_classes=menu_classes
)
self.menu_classes = menu_classes
idname = f"{self.class_prefix}_{root_label.replace(' ', '').upper()}"
icon = "FILE_VOLUME"
def drew_menu(self, context):
if (bpy.context.area.spaces[0].tree_type == 'GeometryNodeTree'):
layout = self.layout
layout.separator()
layout.menu(idname,
icon=icon)
self.drew_menu = drew_menu
def _create_menu_class(self, menu_classes, items, label='CustomNodes', icon=0, idname_namespace=None):
idname_namespace = idname_namespace or self.class_prefix
idname = f"{idname_namespace}_{label.replace(' ', '').upper()}"
# create submenu class if item is menu.
for item in items:
item_items = item.get('items') if item != 'separator' else None
if item_items:
menu_class = self._create_menu_class(
menu_classes=menu_classes,
items=item_items,
label=item.get('label') or 'CustomNodes',
icon=item.get('icon') or 0,
idname_namespace=idname
)
item['menu_class'] = menu_class
# create menu class
class Menu(bpy.types.Menu):
bl_idname = idname
bl_label = label
def draw(self, context):
layout = self.layout
for item in items:
if item == "separator":
layout.separator()
elif item.get('menu_class'):
layout.menu(
item.get('menu_class').bl_idname,
icon=item.get('icon') or 'NONE'
)
else:
op = layout.operator(
AddNode.bl_idname,
text=item.get('label'),
icon=item.get('icon') or 'NONE'
)
op.node_name = item['name']
op.node_label = item.get('label') or ""
op.node_description = item.get(
'node_description') or ""
menu_classes.append(Menu)
return Menu
def register(self):
for cls in self.menu_classes:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(self.drew_menu)
def unregister(self):
bpy.types.NODE_MT_add.remove(self.drew_menu)
for cls in reversed(self.menu_classes):
bpy.utils.unregister_class(cls)

View File

@@ -1,649 +0,0 @@
import bpy
import bmesh
from ..constants import COMPONENT_OUTPUT_NODES
from ..utils import get_cache_dir, get_use_link, select_object
from ..bioxel.io import load_container, save_container
from ..bioxelutils.container import container_to_obj, obj_to_container
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_layer_objs, get_container_obj, get_node_type,
get_nodes_by_type, get_output_node, is_missing_layer,
move_node_between_nodes,
move_node_to_node,
get_all_layer_objs)
from .layer import RemoveLayers, SaveLayersCache
class SaveContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.save_container"
bl_label = "Save Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container = obj_to_container(container_obj)
save_path = f"{self.filepath.split('.')[0]}.bioxel"
save_container(container, save_path, overwrite=True)
self.report({"INFO"}, f"Successfully save to {save_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class LoadContainer(bpy.types.Operator):
bl_idname = "bioxelnodes.load_container"
bl_label = "Load Container (.bioxel) (BETA)"
bl_description = "Clean all caches saved in temp"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH"
) # type: ignore
filename_ext = ".bioxel"
def execute(self, context):
load_path = self.filepath
container = load_container(self.filepath)
is_first_import = len(get_all_layer_objs()) == 0
container_obj = container_to_obj(container,
scene_scale=0.01,
cache_dir=get_cache_dir())
select_object(container_obj)
if is_first_import:
bpy.ops.bioxelnodes.render_setting_preset('EXEC_DEFAULT',
preset="preview_c")
self.report({"INFO"}, f"Successfully load {load_path}")
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
class ExtractNodeObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# nodes = [node for node in context.selected_nodes
# if get_node_type(node).removeprefix("BioxelNodes_") in COMPONENT_OUTPUT_NODES]
if len(context.selected_nodes) == 0:
self.report({"WARNING"}, "No node selected.")
return {'FINISHED'}
selected_node = context.selected_nodes[0]
container_node_group = container_obj.modifiers[0].node_group
container_output_node = get_output_node(container_node_group)
if len(container_output_node.inputs[0].links) == 0:
pre_socket = None
else:
pre_socket = container_output_node.inputs[0].links[0].from_socket
container_node_group.links.new(selected_node.outputs[0],
container_output_node.inputs[0])
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{container_obj.name}_{self.object_type}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_nodes_by_type(node_group, 'NodeGroupOutput')[0]
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
node_group.links.new(fetch_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
if pre_socket:
container_node_group.links.new(pre_socket,
container_output_node.inputs[0])
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractNodeMesh(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractNodeShapeWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractNodeBboxWire(bpy.types.Operator, ExtractNodeObject):
bl_idname = "bioxelnodes.extract_node_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class ExtractObject():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
obj = bpy.context.active_object
obj.name = f"{container_obj.name}_{self.object_type}"
bpy.ops.node.new_geometry_nodes_modifier()
modifier = obj.modifiers[0]
node_group = modifier.node_group
output_node = get_output_node(node_group)
fetch_mesh_node = add_node_to_graph(f"Fetch{self.object_type}",
node_group,
node_label=f"Fetch {self.object_type}",
use_link=get_use_link())
fetch_mesh_node.inputs[0].default_value = container_obj
if self.object_type == "Mesh":
node_group.links.new(
fetch_mesh_node.outputs[0], output_node.inputs[0])
else:
curve_to_mesh_node = node_group.nodes.new(
"GeometryNodeCurveToMesh")
node_group.links.new(
fetch_mesh_node.outputs[0], curve_to_mesh_node.inputs[0])
node_group.links.new(
curve_to_mesh_node.outputs[0], output_node.inputs[0])
for modifier in obj.modifiers:
bpy.ops.object.modifier_apply(modifier=modifier.name)
obj.data.materials.clear()
select_object(obj)
self.report({"INFO"}, f"Successfully picked")
return {'FINISHED'}
class ExtractMesh(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
object_type = "Mesh"
class ExtractShapeWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_shape_wire"
bl_label = "Extract Shape Wire"
bl_description = "Extract Shape Wire"
bl_icon = "FILE_VOLUME"
object_type = "ShapeWire"
class ExtractBboxWire(bpy.types.Operator, ExtractObject):
bl_idname = "bioxelnodes.extract_bbox_wire"
bl_label = "Extract Bbox Wire"
bl_description = "Extract Bbox Wire"
bl_icon = "MESH_CUBE"
object_type = "BboxWire"
class AddCutter():
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
# TODO: do not use operator to create obj
if self.cutter_type == "plane":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "cylinder":
bpy.ops.mesh.primitive_cylinder_add(
radius=1, depth=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
bpy.context.object.rotation_euler[0] = container_obj.rotation_euler[0]
elif self.cutter_type == "cube":
bpy.ops.mesh.primitive_cube_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "sphere":
bpy.ops.mesh.primitive_ico_sphere_add(
radius=1, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
elif self.cutter_type == "pie":
bpy.ops.mesh.primitive_plane_add(
size=2, enter_editmode=False, align='WORLD', location=(0, 0, 0), scale=(1, 1, 1))
pie_obj = bpy.context.active_object
orig_data = pie_obj.data
# Create mesh
pie_mesh = bpy.data.meshes.new('Pie')
pie_obj.data = pie_mesh
bpy.data.meshes.remove(orig_data)
# # Create object
# pie = bpy.data.objects.new('Pie', pie_mesh)
# # Link object to scene
# bpy.context.scene.collection.objects.link(pie)
# Get a BMesh representation
bm = bmesh.new() # create an empty BMesh
bm.from_mesh(pie_mesh) # fill it in from a Mesh
# Hot to create vertices
v_0 = bm.verts.new((0.0, -1.0, 0.0))
v_1 = bm.verts.new((-1.0, -1.0, 1.0))
v_2 = bm.verts.new((0.0, 1.0, 0.0))
v_3 = bm.verts.new((-1.0, 1.0, 1.0))
v_4 = bm.verts.new((1.0, -1.0, 1.0))
v_5 = bm.verts.new((1.0, 1.0, 1.0))
# Initialize the index values of this sequence.
bm.verts.index_update()
# How to create a face
# it's not necessary to create the edges before, I made it only to show how create
# edges too
bm.faces.new((v_0, v_1, v_3, v_2))
bm.faces.new((v_0, v_2, v_5, v_4))
# Finish up, write the bmesh back to the mesh
bm.to_mesh(pie_mesh)
# bpy.context.view_layer.objects.active = pie
cutter_obj = bpy.context.active_object
# vcos = [container_obj.matrix_world @
# v.co for v in container_obj.data.vertices]
# def find_center(l): return (max(l) + min(l)) / 2
# x, y, z = [[v[i] for v in vcos] for i in range(3)]
# center = [find_center(axis) for axis in [x, y, z]]
# cutter_obj.location = center
name = f"{self.cutter_type.capitalize()}_Cutter"
cutter_obj.name = name
cutter_obj.data.name = name
cutter_obj.visible_camera = False
cutter_obj.visible_diffuse = False
cutter_obj.visible_glossy = False
cutter_obj.visible_transmission = False
cutter_obj.visible_volume_scatter = False
cutter_obj.visible_shadow = False
cutter_obj.hide_render = True
cutter_obj.display_type = 'WIRE'
cutter_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
cut_nodes = get_nodes_by_type(node_group,
'BioxelNodes_Cut')
if len(cut_nodes) == 0:
cutter_node = add_node_to_graph("ObjectCutter",
node_group,
node_label=f"{self.cutter_type.capitalize()} Cutter",
use_link=get_use_link())
cutter_node.inputs[0].default_value = self.cutter_type.capitalize()
cutter_node.inputs[1].default_value = cutter_obj
cut_node = add_node_to_graph("Cut",
node_group,
use_link=get_use_link())
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_to_node(cut_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
cut_node.inputs[0])
node_group.links.new(cut_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(cut_node,
[pre_output_node, output_node])
node_group.links.new(cutter_node.outputs[0],
cut_node.inputs[1])
move_node_to_node(cutter_node, cut_node, (-300, -300))
else:
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="ObjectCutter",
node_label=name)
node = bpy.context.active_node
node.inputs[0].default_value = self.cutter_type.capitalize()
node.inputs[1].default_value = cutter_obj
return {'FINISHED'}
class AddPlaneCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_plane_cutter"
bl_label = "Add a Plane Cutter"
bl_description = "Add a plane cutter to current container"
bl_icon = "MESH_PLANE"
cutter_type = "plane"
class AddCylinderCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cylinder_cutter"
bl_label = "Add a Cylinder Cutter"
bl_description = "Add a cylinder cutter to current container"
bl_icon = "MESH_CYLINDER"
cutter_type = "cylinder"
class AddCubeCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_cube_cutter"
bl_label = "Add a Cube Cutter"
bl_description = "Add a cube cutter to current container"
bl_icon = "MESH_CUBE"
cutter_type = "cube"
class AddSphereCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_sphere_cutter"
bl_label = "Add a Sphere Cutter"
bl_description = "Add a sphere cutter to current container"
bl_icon = "MESH_UVSPHERE"
cutter_type = "sphere"
class AddPieCutter(bpy.types.Operator, AddCutter):
bl_idname = "bioxelnodes.add_pie_cutter"
bl_label = "Add a Pie Cutter"
bl_description = "Add a pie cutter to current container"
bl_icon = "MESH_CONE"
cutter_type = "pie"
class AddSlicer(bpy.types.Operator):
bl_idname = "bioxelnodes.add_slicer"
bl_label = "Add a Slicer"
bl_description = "Add a slicer to current container"
bl_icon = "TEXTURE"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.mesh.primitive_plane_add(size=2,
enter_editmode=False,
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
slicer_obj = bpy.context.active_object
slicer_obj.name = "Slicer"
slicer_obj.data.name = "Slicer"
slicer_obj.visible_camera = False
slicer_obj.visible_diffuse = False
slicer_obj.visible_glossy = False
slicer_obj.visible_transmission = False
slicer_obj.visible_volume_scatter = False
slicer_obj.visible_shadow = False
slicer_obj.hide_render = True
slicer_obj.display_type = 'WIRE'
slicer_obj.lineart.usage = 'EXCLUDE'
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
slice_node = add_node_to_graph("Slice",
node_group,
use_link=get_use_link())
slice_node.inputs[1].default_value = slicer_obj
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_to_node(slice_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
slice_node.inputs[0])
node_group.links.new(slice_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(slice_node,
[pre_output_node, output_node])
return {'FINISHED'}
class AddLocator(bpy.types.Operator):
bl_idname = "bioxelnodes.add_locator"
bl_label = "Add a Locator"
bl_description = "Add a locator to current container"
bl_icon = "EMPTY_AXIS"
bl_options = {'UNDO'}
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
bpy.ops.object.empty_add(type='ARROWS',
align='WORLD',
location=(0, 0, 0),
scale=(1, 1, 1))
locator_obj = bpy.context.active_object
locator_obj.name = "Locator"
select_object(container_obj)
modifier = container_obj.modifiers[0]
node_group = modifier.node_group
try:
selected_node = context.selected_nodes[0]
except:
selected_node = None
parent_node = add_node_to_graph("TransformParent",
node_group,
node_label="Transform Parent",
use_link=get_use_link())
parent_node.inputs[1].default_value = locator_obj
if selected_node is None or len(selected_node.outputs) == 0:
output_node = get_output_node(node_group)
if len(output_node.inputs[0].links) == 0:
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_to_node(parent_node, output_node, (-300, 0))
else:
pre_output_node = output_node.inputs[0].links[0].from_node
node_group.links.new(pre_output_node.outputs[0],
parent_node.inputs[0])
node_group.links.new(parent_node.outputs[0],
output_node.inputs[0])
move_node_between_nodes(parent_node,
[pre_output_node, output_node])
else:
try:
to_node = selected_node.outputs[0].links[0].to_node
except:
to_node = None
if to_node is None:
node_group.links.new(selected_node.outputs[0],
parent_node.inputs[0])
move_node_to_node(parent_node, selected_node, (300, 0))
else:
node_group.links.new(selected_node.outputs[0],
parent_node.inputs[0])
node_group.links.new(parent_node.outputs[0],
to_node.inputs[0])
move_node_between_nodes(parent_node,
[selected_node, to_node])
return {'FINISHED'}
class ContainerProps(bpy.types.Operator):
bl_idname = "bioxelnodes.container_props"
bl_label = "Change Container Properties"
bl_description = "Change current ontainer properties"
bl_icon = "FILE_TICK"
bl_options = {'UNDO'}
scene_scale: bpy.props.FloatProperty(name="Scene Scale",
soft_min=0.0001, soft_max=10.0,
min=1e-6, max=1e6,
default=0.01) # type: ignore
step_size: bpy.props.FloatProperty(name="Step Size",
soft_min=0.1, soft_max=100.0,
min=0.1, max=1e2,
default=1) # type: ignore
def execute(self, context):
container_obj = get_container_obj(context.object)
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {'FINISHED'}
container_obj.scale[0] = self.scene_scale
container_obj.scale[1] = self.scene_scale
container_obj.scale[2] = self.scene_scale
container_obj["scene_scale"] = self.scene_scale
container_obj["step_size"] = self.step_size
for layer_obj in get_container_layer_objs(container_obj):
layer_obj.data.render.space = 'WORLD'
layer_obj.data.render.step_size = self.scene_scale * self.step_size
return {'FINISHED'}
def invoke(self, context, event):
container_obj = get_container_obj(context.object)
if container_obj is None:
return self.execute(context)
else:
self.scene_scale = container_obj.get("scene_scale") or 0.01
self.step_size = container_obj.get("step_size") or 1
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
def get_container_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
layer_filter = layer_filter or _layer_filter
container_obj = context.object
layer_objs = get_container_layer_objs(container_obj)
return [obj for obj in layer_objs if layer_filter(obj, context)]
class SaveContainerLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_container_layers_cache"
bl_label = "Save Container Layers' Cache"
bl_description = "Save all current container layers' cache to directory."
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all container layers."
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_container_layers(context, is_not_missing)
class RemoveContainerMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_container_missing_layers"
bl_label = "Remove Container Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all container missing layers."
def get_layers(self, context):
def is_missing(layer_obj, context):
return is_missing_layer(layer_obj)
return get_container_layers(context, is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}

File diff suppressed because it is too large Load Diff

View File

@@ -1,619 +1,314 @@
from pathlib import Path
import shutil
import bpy
import numpy as np
from ..node import add_bioxel_node, get_layer_nodes, get_main_node_group
from ..utils import refresh_bioxel_panels
from ..layer import get_layer_caches, set_layer_caches
from ..exceptions import NoContent
from ..bioxel.layer import Layer
from ..bioxelutils.node import add_node_to_graph
from ..bioxelutils.common import (get_container_obj, get_layer_kind, get_layer_label, get_layer_name,
get_layer_prop_value,
get_container_layer_objs,
get_node_type, is_missing_layer, move_node_to_node, set_layer_prop_value)
from ..bioxelutils.layer import layer_to_obj, obj_to_layer
from ..utils import get_cache_dir, copy_to_dir, get_use_link
class RenameLayer(bpy.types.Operator):
"""Rename a cached Bioxel layer"""
bl_idname = "bioxel.rename_layer"
bl_label = "Rename Layer"
bl_options = {"REGISTER", "UNDO"}
def get_label_layer_selection(self, context):
items = [("None", "None", "")]
container_obj = get_container_obj(bpy.context.active_object)
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
new_name: bpy.props.StringProperty(name="New name", default="") # type: ignore
for layer_obj in get_container_layer_objs(container_obj):
kind = get_layer_prop_value(layer_obj, "kind")
name = get_layer_prop_value(layer_obj, "name")
if kind == "label":
items.append((layer_obj.name,
name,
""))
return items
class FetchLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.fetch_layer"
bl_label = "Fetch Layer"
bl_description = "Fetch layer from current container"
bl_icon = "NODE"
bl_options = {'UNDO'}
layer_obj_name: bpy.props.StringProperty(
options={"HIDDEN"}) # type: ignore
@classmethod
def description(cls, context, properties):
layer_obj_name = properties.layer_obj_name
layer_obj = bpy.data.objects.get(layer_obj_name)
return "\n".join([f"{prop}: {get_layer_prop_value(layer_obj, prop)}"
for prop in ["kind",
"bioxel_size",
"shape",
"frame_count",
"channel_count",
"min", "max"]])
@property
def layer_obj(self):
return bpy.data.objects.get(self.layer_obj_name)
def invoke(self, context, event):
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if entry:
self.new_name = str(entry.get("name", ""))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
if self.layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if not self.new_name:
self.report({"ERROR"}, "Name cannot be empty")
return {"CANCELLED"}
if is_missing_layer(self.layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
caches = get_layer_caches()
for c in caches:
if str(c.get("id", "")) == self.cache_id:
c["name"] = self.new_name
break
bpy.ops.bioxelnodes.add_node('EXEC_DEFAULT',
node_name="FetchLayer",
node_label="Fetch Layer")
node = bpy.context.active_node
try:
set_layer_caches(caches)
refresh_bioxel_panels(context)
self.report({"INFO"}, "Layer renamed")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to save changes: {e}")
return {"CANCELLED"}
node.inputs[0].default_value = self.layer_obj
node.label = get_layer_label(self.layer_obj)
class DeleteLayer(bpy.types.Operator):
"""Delete a saved Bioxel layer"""
bl_idname = "bioxel.delete_layer"
bl_label = "Delete Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty() # type: ignore
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
caches = get_layer_caches()
new_list = [c for c in caches if str(c.get("id", "")) != self.cache_id]
removed = next(
(c for c in caches if str(c.get("id", "")) == self.cache_id), None
)
try:
set_layer_caches(new_list)
refresh_bioxel_panels(context)
except Exception as e:
self.report({"ERROR"}, f"Failed to update layer list: {e}")
return {"CANCELLED"}
self.report({"INFO"}, "Layer removed")
return {"FINISHED"}
def get_selected_layers(context, layer_filter=None):
def _layer_filter(layer_obj, context):
return True
# 添加图层到节点图的操作器
class AddLayerNode(bpy.types.Operator):
"""将选中的图层添加到几何节点图"""
layer_filter = layer_filter or _layer_filter
select_objs = []
# node_group = context.space_data.edit_tree
for node in context.selected_nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
layer_obj = node.inputs[0].default_value
if layer_obj is not None:
if layer_filter(layer_obj, context):
select_objs.append(layer_obj)
bl_idname = "bioxel.add_layer_node"
bl_label = "添加图层节点"
bl_options = {"REGISTER", "UNDO"}
return list(set(select_objs))
def get_selected_layer(context, layer_filter=None):
layer_objs = get_selected_layers(context, layer_filter)
return layer_objs[0] if len(layer_objs) > 0 else None
class OutputLayerOperator():
new_layer_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, orig_layer: Layer, context):
"""do the operation"""
return orig_layer
def add_layer_node(self, context, layer):
orig_node = context.selected_nodes[0]
layer_obj = layer_to_obj(layer,
container_obj=context.object,
cache_dir=get_cache_dir())
node_group = context.space_data.edit_tree
fetch_node = add_node_to_graph("FetchLayer",
node_group,
use_link=get_use_link())
fetch_node.label = get_layer_prop_value(layer_obj, "name")
fetch_node.inputs[0].default_value = layer_obj
move_node_to_node(fetch_node, orig_node, (0, -100))
cache_id: bpy.props.StringProperty() # type: ignore # 图层ID
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
# 获取图层数据
caches = get_layer_caches()
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
# 查找目标图层
entry = next(
(c for c in caches if str(c.get("id", "")) == str(self.cache_id)), None
)
orig_layer = obj_to_layer(layer_obj)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
# 创建并设置节点属性
try:
new_layer = self.operate(orig_layer, context)
except NoContent as e:
self.report({"WARNING"}, e.message)
return {'FINISHED'}
layer_node = add_bioxel_node("O Layer")
except:
self.report({"ERROR"}, "Fail to add layer node")
return {"CANCELLED"}
self.add_layer_node(context, new_layer)
return {'FINISHED'}
layer_node.node_tree.make_local()
layer_node.inputs["Path"].default_value = entry["path"]
layer_node.inputs["Name"].default_value = entry["name"]
layer_node.inputs["Shape"].default_value = entry["shape"]
layer_node.inputs["Min"].default_value = entry["min"]
layer_node.inputs["Max"].default_value = entry["max"]
layer_node.inputs["ID"].default_value = entry["id"]
# 将特定属性隐藏到hidden面板
hidden_sockets = ["Path", "Shape", "Min", "Max", "ID", "Animation"]
class LayerOperator():
def operate(self, layer_obj: bpy.types.Object, context):
"""do the operation"""
...
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
if is_missing_layer(layer_obj):
self.report({"WARNING"}, "Selected layer is lost.")
return {'FINISHED'}
self.operate(layer_obj, context)
return {'FINISHED'}
class RetimeLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.retime_layer"
bl_label = "Retime Sequence"
bl_description = "Retime layer time sequence"
bl_icon = "TIME"
frame_duration: bpy.props.IntProperty(name="Frames") # type: ignore
frame_start: bpy.props.IntProperty(name="Start") # type: ignore
frame_offset: bpy.props.IntProperty(name="Offset") # type: ignore
sequence_mode: bpy.props.EnumProperty(name="Mode",
default="REPEAT",
items=[("CLIP", "Clip", ""),
("EXTEND", "Extend", ""),
("REPEAT", "Repeat", ""),
("PING_PONG", "Ping-Pong", "")]) # type: ignore
def operate(self, layer_obj, context):
layer_obj.data.frame_duration = self.frame_duration
layer_obj.data.frame_start = self.frame_start
layer_obj.data.frame_offset = self.frame_offset
layer_obj.data.sequence_mode = self.sequence_mode
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.frame_duration = layer_obj.data.frame_duration
self.frame_start = layer_obj.data.frame_start
self.frame_offset = layer_obj.data.frame_offset
self.sequence_mode = layer_obj.data.sequence_mode
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Retime {name}")
return {'RUNNING_MODAL'}
if entry["frame_count"] > 1:
layer_node.inputs["Frame Count"].default_value = entry["frame_count"]
layer_node.inputs["Animation"].default_value = True
else:
return self.execute(context)
hidden_sockets = hidden_sockets + ["Frame Count", "Frame Offset", "Cycle"]
# 将特定属性隐藏到hidden面板
for socket_name in hidden_sockets:
if socket_name in layer_node.inputs:
layer_node.inputs[socket_name].hide = True
for socket_name in layer_node.outputs.keys():
if socket_name != entry["kind"].capitalize():
layer_node.outputs[socket_name].hide = True
self.report({"INFO"}, f"Successfully created node: {entry['name']}")
return {"FINISHED"}
class RelocateLayer(bpy.types.Operator):
bl_idname = "bioxelnodes.relocate_layer"
bl_label = "Relocate Layer Cache"
bl_description = "Relocate layer cache"
bl_icon = "FILE"
class RelocatePath(bpy.types.Operator):
"""Locate and update the missing layer folder, and update all related nodes"""
filepath: bpy.props.StringProperty(subtype="FILE_PATH") # type: ignore
bl_idname = "bioxel.find_layer_folder"
bl_label = "Relocate"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
layer_obj = get_selected_layer(context)
if layer_obj == None:
self.report({"WARNING"}, "Get no layer.")
return {'FINISHED'}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
self.operate(layer_obj, context)
return {'FINISHED'}
def operate(self, layer_obj, context):
layer_obj.data.filepath = self.filepath
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class RenameLayer(bpy.types.Operator, LayerOperator):
bl_idname = "bioxelnodes.rename_layer"
bl_label = "Rename Layer"
bl_description = "Rename layer"
bl_icon = "FILE_FONT"
new_name: bpy.props.StringProperty(name="New Name",
options={"SKIP_SAVE"}) # type: ignore
def operate(self, layer_obj, context):
name = f"{layer_obj.parent.name}_{self.new_name}"
layer_obj.name = name
layer_obj.data.name = name
set_layer_prop_value(layer_obj, "name", self.new_name)
node_group = context.space_data.edit_tree
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.label = self.new_name
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
self.new_name = get_layer_name(layer_obj)
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Rename {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class ResampleLayer(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.resample_layer"
bl_label = "Resample Value"
bl_description = "Resample value"
bl_icon = "ALIASED"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
bioxel_size: bpy.props.FloatProperty(
name="Bioxel Size",
soft_min=0.1, soft_max=10.0,
default=1,
) # type: ignore
@staticmethod
def get_new_shape(orig_shape, orig_size, new_size):
return (int(orig_shape[0]*orig_size/new_size),
int(orig_shape[1]*orig_size/new_size),
int(orig_shape[2]*orig_size/new_size))
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_shape = self.get_new_shape(new_layer.shape,
new_layer.bioxel_size[0],
self.bioxel_size)
new_layer.resize(new_shape, self.smooth)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_R-{self.bioxel_size:.2f}"
return new_layer
def draw(self, context):
layer_obj = get_selected_layer(context)
orig_shape = get_layer_prop_value(layer_obj, "shape")
orig_size = get_layer_prop_value(layer_obj, "bioxel_size")
new_shape = self.get_new_shape(orig_shape,
orig_size,
self.bioxel_size)
orig_shape = tuple(orig_shape)
bioxel_count = new_shape[0] * new_shape[1] * new_shape[2]
layer_shape_text = f"Shape from {str(orig_shape)} to {str(new_shape)}"
if bioxel_count > 100000000:
layer_shape_text += "**TOO LARGE!**"
layout = self.layout
layout.prop(self, "new_layer_name")
layout.prop(self, "bioxel_size")
layout.prop(self, "smooth")
layout.label(text=layer_shape_text)
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
self.bioxel_size = get_layer_prop_value(layer_obj,
"bioxel_size")
context.window_manager.invoke_props_dialog(self,
title=f"Resample {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class SignScalar(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.sign_scalar"
bl_label = "Sign Value"
bl_description = "Sign value"
bl_icon = "REMOVE"
def operate(self, orig_layer: Layer, context):
new_layer = orig_layer.copy()
new_layer.data = -orig_layer.data
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_Sign"
return new_layer
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Sign {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillOperator(OutputLayerOperator):
fill_value: bpy.props.FloatProperty(
name="Fill Value",
soft_min=0, soft_max=1024.0,
default=0,
) # type: ignore
invert: bpy.props.BoolProperty(
name="Invert Aera",
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
def invoke(self, context, event):
layer_obj = get_selected_layer(context)
if layer_obj:
name = get_layer_label(layer_obj)
context.window_manager.invoke_props_dialog(self,
title=f"Fill {name}")
return {'RUNNING_MODAL'}
else:
return self.execute(context)
class FillByThreshold(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_threshold"
bl_label = "Fill Value by Threshold"
bl_description = "Fill value by threshold"
bl_icon = "EMPTY_SINGLE_ARROW"
threshold: bpy.props.FloatProperty(
name="Threshold",
soft_min=0, soft_max=1024,
default=128,
directory: bpy.props.StringProperty(
name="Layer Folder",
description="Select the correct folder for this layer",
subtype="DIR_PATH",
) # type: ignore
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = data <= self.threshold \
if self.invert else data > self.threshold
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.threshold:.2f}"
return new_layer
class FillByRange(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_range"
bl_label = "Fill Value by Range"
bl_description = "Fill value by range"
bl_icon = "IPO_CONSTANT"
from_min: bpy.props.FloatProperty(
name="From Min",
soft_min=0, soft_max=1024,
default=128,
) # type: ignore
from_max: bpy.props.FloatProperty(
name="From Max",
soft_min=0, soft_max=1024,
default=256,
) # type: ignore
def operate(self, orig_layer: Layer, context):
data = np.amax(orig_layer.data, -1)
mask = (data <= self.from_min) | (data >= self.from_max) if self.invert else \
(data > self.from_min) & (data < self.from_max)
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{self.from_min:.2f}-{self.from_max:.2f}"
return new_layer
class FillByLabel(bpy.types.Operator, FillOperator):
bl_idname = "bioxelnodes.fill_by_label"
bl_label = "Fill Value by Label"
bl_description = "Fill value by label"
bl_icon = "MESH_CAPSULE"
smooth: bpy.props.IntProperty(name="Smooth Size",
default=0,
soft_min=0, soft_max=5,
options={"SKIP_SAVE"}) # type: ignore
label_obj_name: bpy.props.EnumProperty(name="Label Layer",
items=get_label_layer_selection) # type: ignore
def operate(self, orig_layer: Layer, context):
label_obj = bpy.data.objects.get(self.label_obj_name)
if label_obj is None:
raise NoContent("Cannot find any label layer.")
label_layer = obj_to_layer(label_obj)
label_layer.resize(orig_layer.shape, self.smooth)
mask = np.amax(label_layer.data, -1)
if self.invert:
mask = 1 - mask
new_layer = orig_layer.copy()
new_layer.fill(self.fill_value, mask, 0)
new_layer.name = self.new_layer_name \
or f"{orig_layer.name}_F-{label_layer.name}"
return new_layer
class CombineLabels(bpy.types.Operator, OutputLayerOperator):
bl_idname = "bioxelnodes.combine_labels"
bl_label = "Combine Labels"
bl_description = "Combine all selected labels"
bl_icon = "MOD_BUILD"
def execute(self, context):
def layer_filter(layer_obj, context):
return get_layer_kind(layer_obj) == "label"
label_objs = get_selected_layers(context, layer_filter)
if len(label_objs) < 2:
self.report({"WARNING"}, "Not enough layers.")
return {'FINISHED'}
base_obj = label_objs[0]
label_objs = label_objs[1:]
base_layer = obj_to_layer(base_obj)
new_layer = base_layer.copy()
label_names = [base_layer.name]
for label_obj in label_objs:
label_layer = obj_to_layer(label_obj)
label_layer.resize(base_layer.shape)
new_layer.data = np.maximum(
new_layer.data, label_layer.data)
label_names.append(label_layer.name)
new_layer.name = self.new_layer_name \
or f"C-{'-'.join(label_names)}"
self.add_layer_node(context, new_layer)
return {'FINISHED'}
class BatchLayerOperator():
success_msg = "Successfully done selected layers."
fail_msg = "fails."
def execute(self, context):
layer_objs = self.get_layers(context)
fails = []
for layer_obj in layer_objs:
try:
self.operate(layer_obj, context)
except:
fails.append(layer_obj)
if len(fails) == 0:
self.report({"INFO"}, self.success_msg)
else:
self.report(
{"WARNING"}, f"{','.join([layer_obj.name for layer_obj in fails])} {self.fail_msg}")
return {'FINISHED'}
class SaveLayersCache(BatchLayerOperator):
fail_msg = "fail to save."
cache_dir: bpy.props.StringProperty(
name="Cache Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def operate(self, layer_obj, context):
output_dir = bpy.path.abspath(self.cache_dir)
source_dir = bpy.path.abspath(layer_obj.data.filepath)
source_path: Path = Path(source_dir).resolve()
is_sequence = layer_obj.data.is_sequence
name = layer_obj.name if is_sequence else f"{layer_obj.name}.vdb"
output_path: Path = Path(output_dir, name, source_path.name).resolve() \
if is_sequence else Path(output_dir, name).resolve()
if output_path != source_path:
copy_to_dir(source_path.parent if is_sequence else source_path,
output_path.parent.parent if is_sequence else output_path.parent,
new_name=name)
blend_path = Path(bpy.path.abspath("//")).resolve()
layer_obj.data.filepath = bpy.path.relpath(str(output_path),
start=str(blend_path))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
context.window_manager.fileselect_add(self)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a folder")
return {"CANCELLED"}
class RemoveLayers(BatchLayerOperator):
fail_msg = "fail to remove."
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id", "")) == self.cache_id), None)
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
def operate(self, layer_obj, context):
old_path = entry["path"]
new_path = (
bpy.path.relpath(self.directory)
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(self.directory)
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 更新所有 node group 中 O Layer 节点的 Path
for node_group in bpy.data.node_groups:
for node in node_group.nodes:
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node_group.nodes.remove(node)
for node in get_layer_nodes(node_group):
node_path = getattr(node.inputs.get("Path"), "default_value")
if node_path and node_path == old_path:
node.inputs["Path"].default_value = new_path
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
self.report({"INFO"}, f"Cache path updated: {new_path}")
return {"FINISHED"}
if cache_filepath.is_file():
if layer_obj.data.is_sequence:
for f in cache_filepath.parent.iterdir():
f.unlink(missing_ok=True)
else:
cache_filepath.unlink(missing_ok=True)
# also remove layer object
bpy.data.volumes.remove(layer_obj.data)
class SaveLayer(bpy.types.Operator):
"""Copy the entire layer folder (named by id) to a target folder and update its path in the cache"""
return {'FINISHED'}
bl_idname = "bioxel.cache_layer_to_folder"
bl_label = "Save Layer"
bl_options = {"REGISTER", "UNDO"}
cache_id: bpy.props.StringProperty(options={"HIDDEN"}) # type: ignore
use_relative: bpy.props.BoolProperty(
name="Relative Path",
description="Store the path relative to the current Blender file",
default=True,
) # type: ignore
directory: bpy.props.StringProperty(
name="Target Folder",
description="Select a folder to cache the layer files",
subtype="DIR_PATH",
) # type: ignore
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove them?")
return {'RUNNING_MODAL'}
# Default to Blender file directory if possible
blend_dir = Path(bpy.data.filepath).parent if bpy.data.filepath else Path.cwd()
context.window_manager.fileselect_add(self)
if not self.directory:
self.directory = str(blend_dir)
return {"RUNNING_MODAL"}
def execute(self, context):
if not self.cache_id:
self.report({"ERROR"}, "Missing cache id")
return {"CANCELLED"}
if not self.directory:
self.report({"ERROR"}, "Please select a target folder")
return {"CANCELLED"}
caches = get_layer_caches()
entry = next((c for c in caches if str(c.get("id")) == self.cache_id), None)
cache_id = entry.get("id")
if not entry:
self.report({"ERROR"}, "Layer not found")
return {"CANCELLED"}
old_path = entry["path"]
src_path = bpy.path.abspath(old_path)
if not src_path or not Path(src_path).is_dir():
self.report({"ERROR"}, "Source folder not found")
return {"CANCELLED"}
src_dir = Path(src_path).resolve()
dst_root = Path(self.directory)
# copy as a subfolder named by id
dst_dir = (dst_root / src_dir.name).resolve()
try:
if src_dir != dst_dir:
if dst_dir.exists():
shutil.rmtree(dst_dir)
shutil.copytree(src_dir, dst_dir)
# Save as relative path if requested and possible
new_path = (
bpy.path.relpath(str(dst_dir))
if self.use_relative and bpy.data.filepath
else bpy.path.abspath(str(dst_dir))
)
entry["path"] = new_path
set_layer_caches(caches)
refresh_bioxel_panels(context)
# 遍历所有 node group
for node_group in bpy.data.node_groups:
for node in get_layer_nodes(node_group):
if cache_id == getattr(node.inputs.get("ID"), "default_value"):
node.inputs["Path"].default_value = new_path
self.report({"INFO"}, f"Layer cached to {dst_dir}")
return {"FINISHED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to cache layer: {e}")
return {"CANCELLED"}
class SaveSelectedLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_selected_layers_cache"
bl_label = "Save Selected Layers Cache"
bl_description = "Save selected layers' Cache"
bl_icon = "FILE_TICK"
class SelectAndFocusNode(bpy.types.Operator):
"""Select and focus the specified node in the current node tree"""
success_msg = "Successfully saved all selected layers."
bl_idname = "bioxel.select_and_focus_node"
bl_label = "Select and Focus Node"
node_name: bpy.props.StringProperty() # type: ignore
def get_layers(self, context):
def is_not_missing(layer_obj, context):
return not is_missing_layer(layer_obj)
return get_selected_layers(context, is_not_missing)
class RemoveSelectedLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_selected_layers"
bl_label = "Remove Selected Layers"
bl_description = "Remove selected layers"
bl_icon = "TRASH"
success_msg = "Successfully removed all selected layers."
def get_layers(self, context):
return get_selected_layers(context)
def execute(self, context):
node_group = get_main_node_group(context)
if not node_group:
self.report({"ERROR"}, "No node group found.")
return {"CANCELLED"}
node = node_group.nodes.get(self.node_name)
if not node:
self.report({"ERROR"}, f"Node '{self.node_name}' not found.")
return {"CANCELLED"}
# Deselect all, select and focus this node
for n in node_group.nodes:
n.select = False
node.select = True
node_group.nodes.active = node
bpy.ops.node.view_selected()
return {"FINISHED"}

View File

@@ -1,110 +1,21 @@
import json
import uuid
import webbrowser
import bpy
from pathlib import Path
import shutil
from ..bioxelutils.common import get_all_layer_objs, get_node_lib_path, get_node_version, is_missing_layer, set_file_prop
from .layer import RemoveLayers, SaveLayersCache
from ..constants import LATEST_NODE_LIB_PATH, VERSIONS
from ..utils import get_cache_dir
class ReLinkNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.relink_node_lib"
bl_label = "Relink Node Library"
bl_description = "Relink all nodes to addon library source"
bl_options = {'UNDO'}
index: bpy.props.IntProperty() # type: ignore
def execute(self, context):
node_version = VERSIONS[self.index]['node_version']
lib_path = get_node_lib_path(node_version) \
if self.index != 0 else LATEST_NODE_LIB_PATH
node_libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
node_lib = node_group.library
if node_lib:
node_libs.append(node_lib)
node_libs = list(set(node_libs))
for node_lib in node_libs:
node_lib.filepath = str(lib_path)
# FIXME: may cause crash
node_lib.reload()
set_file_prop("node_version", node_version)
self.report({"INFO"}, f"Successfully relinked.")
return {'FINISHED'}
class SaveNodeLib(bpy.types.Operator):
bl_idname = "bioxelnodes.save_node_lib"
bl_label = "Save Node Library"
bl_description = "Save node library file to local"
bl_options = {'UNDO'}
lib_dir: bpy.props.StringProperty(
name="Library Directory",
subtype='DIR_PATH',
default="//"
) # type: ignore
def execute(self, context):
node_version = get_node_version()
if node_version is None:
node_version = VERSIONS[0]["node_version"]
else:
if node_version not in [v["node_version"] for v in VERSIONS]:
node_version = VERSIONS[0]["node_version"]
if node_version == VERSIONS[0]["node_version"]:
lib_path = LATEST_NODE_LIB_PATH
else:
lib_path = get_node_lib_path(node_version)
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_dir = bpy.path.abspath(self.lib_dir)
local_lib_path: Path = Path(lib_dir,
f"BioxelNodes_{version_str}.blend").resolve()
node_lib_path: Path = lib_path
blend_path = Path(bpy.path.abspath("//")).resolve()
if local_lib_path != node_lib_path:
shutil.copy(node_lib_path, local_lib_path)
libs = []
for node_group in bpy.data.node_groups:
if node_group.name.startswith("BioxelNodes"):
if node_group.library:
libs.append(node_group.library)
libs = list(set(libs))
for lib in libs:
lib.filepath = bpy.path.relpath(str(local_lib_path),
start=str(blend_path))
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.invoke_props_dialog(self)
return {'RUNNING_MODAL'}
@classmethod
def poll(cls, context):
return bpy.data.is_saved
from ..constants import LATEST_NODE_LIB_PATH
from ..utils import (
get_all_layer_objs,
get_cache_dir,
get_node_version,
is_missing_layer,
)
class CleanTemp(bpy.types.Operator):
bl_idname = "bioxelnodes.clear_temp"
bl_idname = "bioxel.clear_temp"
bl_label = "Clean Temp"
bl_description = "Clean all cache in temp (include other project cache)"
@@ -113,24 +24,26 @@ class CleanTemp(bpy.types.Operator):
try:
shutil.rmtree(cache_dir)
self.report({"INFO"}, f"Successfully cleaned temp.")
return {'FINISHED'}
return {"FINISHED"}
except:
self.report({"WARNING"},
"Fail to clean temp, you may do it manually.")
return {'CANCELLED'}
self.report(
{"WARNING"}, "Fail to clean temp, you may do it manually.")
return {"CANCELLED"}
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message="All temp files will be removed, include other project cache, do you still want to clean?")
return {'RUNNING_MODAL'}
context.window_manager.invoke_confirm(
self,
event,
message="All temp files will be removed, include other project cache, do you still want to clean?",
)
return {"RUNNING_MODAL"}
class RenderSettingPreset(bpy.types.Operator):
bl_idname = "bioxelnodes.render_setting_preset"
bl_idname = "bioxel.render_setting_preset"
bl_label = "Render Setting Presets"
bl_description = "Render setting presets for bioxel"
bl_options = {'UNDO'}
bl_options = {"UNDO"}
PRESETS = {
"performance": "Performance",
@@ -138,166 +51,201 @@ class RenderSettingPreset(bpy.types.Operator):
"quality": "Quality",
}
preset: bpy.props.EnumProperty(name="Preset",
default="balance",
items=[(k, v, "")
for k, v in PRESETS.items()]) # type: ignore
preset: bpy.props.EnumProperty(
name="Preset", default="balance", items=[(k, v, "") for k, v in PRESETS.items()]
) # type: ignore
def execute(self, context):
if self.preset == "performance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "4"
bpy.context.scene.eevee.volumetric_shadow_samples = 32
bpy.context.scene.eevee.volumetric_samples = 64
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 0
bpy.context.scene.cycles.volume_bounces = 4
bpy.context.scene.cycles.transparent_max_bounces = 4
bpy.context.scene.cycles.volume_preview_step_rate = 4
bpy.context.scene.cycles.volume_step_rate = 4
bpy.context.scene.cycles.volume_biased = True
bpy.context.scene.cycles.volume_preview_step_rate = 100
bpy.context.scene.cycles.volume_step_rate = 100
elif self.preset == "balance":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "2"
bpy.context.scene.eevee.volumetric_shadow_samples = 64
bpy.context.scene.eevee.volumetric_samples = 128
bpy.context.scene.eevee.volumetric_ray_depth = 8
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 4
bpy.context.scene.cycles.transparent_max_bounces = 8
bpy.context.scene.cycles.volume_preview_step_rate = 1
bpy.context.scene.cycles.volume_step_rate = 1
bpy.context.scene.cycles.volume_bounces = 8
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_biased = True
bpy.context.scene.cycles.volume_preview_step_rate = 10
bpy.context.scene.cycles.volume_step_rate = 10
elif self.preset == "quality":
# EEVEE
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.use_taa_reprojection = False
# bpy.context.scene.eevee.use_shadow_jitter_viewport = True
bpy.context.scene.eevee.volumetric_tile_size = "1"
bpy.context.scene.eevee.volumetric_shadow_samples = 128
bpy.context.scene.eevee.volumetric_samples = 256
bpy.context.scene.eevee.volumetric_ray_depth = 16
bpy.context.scene.eevee.use_volumetric_shadows = True
# Cycles
bpy.context.scene.cycles.volume_bounces = 8
bpy.context.scene.cycles.transparent_max_bounces = 16
bpy.context.scene.cycles.volume_preview_step_rate = 0.5
bpy.context.scene.cycles.volume_step_rate = 0.5
bpy.context.scene.cycles.volume_bounces = 16
bpy.context.scene.cycles.transparent_max_bounces = 32
bpy.context.scene.cycles.volume_biased = False
return {'FINISHED'}
class SliceViewer(bpy.types.Operator):
bl_idname = "bioxelnodes.slice_viewer"
bl_label = "Slice Viewer"
bl_description = "A preview scene setting for viewing slicers"
bl_icon = "FILE_VOLUME"
def execute(self, context):
# bpy.context.scene.eevee.use_taa_reprojection = False
bpy.context.scene.eevee.volumetric_tile_size = '2'
bpy.context.scene.eevee.volumetric_shadow_samples = 128
bpy.context.scene.eevee.volumetric_samples = 128
bpy.context.scene.eevee.volumetric_ray_depth = 1
bpy.context.scene.eevee.use_shadows = False
bpy.context.scene.eevee.use_volumetric_shadows = False
view_3d = None
if context.area.type == 'VIEW_3D':
view_3d = context.area
else:
for area in context.screen.areas:
if area.type == 'VIEW_3D':
view_3d = area
break
if view_3d:
view_3d.spaces[0].shading.type = 'MATERIAL'
view_3d.spaces[0].shading.studio_light = 'studio.exr'
view_3d.spaces[0].shading.studiolight_intensity = 1.5
view_3d.spaces[0].shading.use_scene_lights = False
view_3d.spaces[0].shading.use_scene_world = False
return {'FINISHED'}
def get_all_layers(layer_filter=None):
def _layer_filter(layer_obj):
return True
layer_filter = layer_filter or _layer_filter
layer_objs = get_all_layer_objs()
return [obj for obj in layer_objs if layer_filter(obj)]
class SaveAllLayersCache(bpy.types.Operator, SaveLayersCache):
bl_idname = "bioxelnodes.save_all_layers_cache"
bl_label = "Save All Layers Cache"
bl_description = "Save all cache of this file"
bl_icon = "FILE_TICK"
success_msg = "Successfully saved all layers."
def get_layers(self, context):
def is_not_missing(layer_obj):
return not is_missing_layer(layer_obj)
return get_all_layers(is_not_missing)
class RemoveAllMissingLayers(bpy.types.Operator, RemoveLayers):
bl_idname = "bioxelnodes.remove_all_missing_layers"
bl_label = "Remove All Missing Layers"
bl_description = "Remove all current container missing layers"
bl_icon = "BRUSH_DATA"
success_msg = "Successfully removed all missing layers."
def get_layers(self, context):
def is_missing(layer_obj):
return is_missing_layer(layer_obj)
return get_all_layers(is_missing)
def invoke(self, context, event):
context.window_manager.invoke_confirm(self,
event,
message=f"Are you sure to remove all **Missing** layers?")
return {'RUNNING_MODAL'}
return {"FINISHED"}
class Help(bpy.types.Operator):
bl_idname = "bioxelnodes.help"
bl_idname = "bioxel.help"
bl_label = "Need Help?"
bl_description = "Online Manual for Beginner"
bl_icon = "HELP"
def execute(self, context):
webbrowser.open(
'https://omoolab.github.io/BioxelNodes/latest/', new=2)
webbrowser.open("https://docs.omoolab.xyz/bioxelnodes/latest/", new=2)
return {'FINISHED'}
return {"FINISHED"}
class AddEeveeEnv(bpy.types.Operator):
bl_idname = "bioxelnodes.add_eevee_env"
bl_label = "Add EEVEE Env Light"
bl_description = "To make volume shadow less dark"
# 定义虚无化切换操作
class TogglePhantom(bpy.types.Operator):
"""Toggle object phantom state (ray invisible, no shadows, semi-transparent wireframe)"""
bl_idname = "bioxel.toggle_phantom"
bl_label = "Toggle Phantom"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
bpy.ops.wm.append(
directory=f"{str(LATEST_NODE_LIB_PATH)}/Object",
filename="EeveeEnv"
obj = context.active_object
if not obj:
self.report({"WARNING"}, "No object selected")
return {"CANCELLED"}
# 获取3D视图空间
view_space = None
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
for space in area.spaces:
if space.type == "VIEW_3D":
view_space = space
break
if view_space:
break
# 确保存储状态的文本数据块存在
text_name = "phantom_orig_states"
if text_name not in bpy.data.texts:
bpy.data.texts.new(text_name)
# 从文本数据块加载状态
states_text = bpy.data.texts[text_name]
try:
states = json.loads(states_text.as_string())
except:
states = {}
def get_obj_uuid(obj):
if "bioxel_uuid" not in obj:
obj["bioxel_uuid"] = uuid.uuid4().hex
return obj["bioxel_uuid"]
# 使用物体的唯一标识符来追踪
obj_uuid = get_obj_uuid(obj)
# 检查物体是否已经处于虚无化状态
is_phantom = (
obj.display_type == "SOLID"
and obj.show_wire
and obj.show_in_front
and round(obj.color[3], 2) == 0.10
and not obj.visible_camera
and not obj.visible_diffuse
and not obj.visible_glossy
and not obj.visible_transmission
and not obj.visible_volume_scatter
and not obj.visible_shadow
)
bpy.context.scene.eevee.use_shadows = True
bpy.context.scene.eevee.use_volumetric_shadows = True
if is_phantom:
# 恢复原始状态
if obj_uuid in states:
state = states[obj_uuid]
obj.display_type = state["display_type"]
obj.show_wire = state["show_wire"]
obj.show_in_front = state["show_in_front"]
obj.color = state["color"]
obj.visible_camera = state["visible_camera"]
obj.visible_diffuse = state["visible_diffuse"]
obj.visible_glossy = state["visible_glossy"]
obj.visible_transmission = state["visible_transmission"]
obj.visible_volume_scatter = state["visible_volume_scatter"]
obj.visible_shadow = state["visible_shadow"]
return {'FINISHED'}
# 恢复视图着色设置(如果之前有保存)
if view_space and "viewport_color_type" in state:
view_space.shading.color_type = state["viewport_color_type"]
del states[obj_uuid]
self.report({"INFO"}, f"Object restored")
else:
# 保存原始状态
state_data = {
"display_type": obj.display_type,
"show_wire": obj.show_wire,
"show_in_front": obj.show_in_front,
"color": list(obj.color), # 保存RGBA颜色值
"visible_camera": obj.visible_camera,
"visible_diffuse": obj.visible_diffuse,
"visible_glossy": obj.visible_glossy,
"visible_transmission": obj.visible_transmission,
"visible_volume_scatter": obj.visible_volume_scatter,
"visible_shadow": obj.visible_shadow,
}
# 检查并处理视图着色设置
if view_space:
# 保存当前视图着色设置
state_data["viewport_color_type"] = view_space.shading.color_type
# 检查当前是否为object, random或attribute
valid_types = {"OBJECT", "RANDOM", "ATTRIBUTE"}
if view_space.shading.color_type not in valid_types:
# 改为object模式
view_space.shading.color_type = "OBJECT"
states[obj_uuid] = state_data
# 设置虚无化状态
obj.display_type = "SOLID" # 以实体方式显示
obj.show_wire = True # 显示线框
obj.show_in_front = True # 显示在其他物体前面
# 保持RGB不变,仅修改alpha透明度为0.1
obj.color[3] = 0.1
# 关闭所有射线可见性和投影
obj.visible_camera = False
obj.visible_diffuse = False
obj.visible_glossy = False
obj.visible_transmission = False
obj.visible_volume_scatter = False
obj.visible_shadow = False
self.report({"INFO"}, f"Object phantomed")
# 保存状态到文本数据块
states_text.clear()
states_text.write(json.dumps(states, indent=2))
return {"FINISHED"}

View File

@@ -1,12 +1,12 @@
import bpy
from ..bioxelutils.common import is_incompatible, local_lib_not_updated
from ..bioxelutils.node import assign_node_tree, get_node_tree
from ..utils import is_incompatible, local_lib_not_updated
from ..node import assign_node_tree, get_node_tree
from ..utils import get_use_link
class AddNode(bpy.types.Operator):
bl_idname = "bioxelnodes.add_node"
bl_idname = "bioxel.add_node"
bl_label = "Add Node"
bl_options = {"REGISTER", "UNDO"}
@@ -24,7 +24,7 @@ class AddNode(bpy.types.Operator):
@property
def node_type(self):
return f"BioxelNodes_{self.node_name}"
return f"O {self.node_name}"
def execute(self, context):
space = context.space_data

View File

@@ -0,0 +1,107 @@
import bpy
from ..utils import get_use_link
from ..node import add_node_to_graph, get_output_node, get_main_node_group
class ExtractMesh(bpy.types.Operator):
bl_idname = "bioxel.extract_mesh"
bl_label = "Extract Mesh"
bl_description = "Extract Mesh"
bl_icon = "OUTLINER_OB_MESH"
bl_options = {"UNDO"}
@classmethod
def poll(cls, context):
# Check if there is a selected node in the node group
node_group = get_main_node_group(context)
selected_nodes = [n for n in node_group.nodes if n.select]
if len(selected_nodes) == 0:
return False
else:
source_node = selected_nodes[0]
return "Structure" in source_node.outputs
def execute(self, context):
container_obj = context.object
if container_obj is None:
self.report({"WARNING"}, "Cannot find any bioxel container.")
return {"FINISHED"}
node_group = container_obj.modifiers[0].node_group
selected_nodes = [n for n in node_group.nodes if n.select]
source_node = selected_nodes[0]
# Check if the selected node has a "Structure" output
if "Structure" not in source_node.outputs:
self.report({"ERROR"}, "Selected node has no 'Structure' output.")
return {"CANCELLED"}
# 记录原始输出连接(用于后续复原)
output_node = get_output_node(node_group)
original_links = list(output_node.inputs[0].links)
# 添加Component to Mesh节点
to_mesh_node = add_node_to_graph(
"Structure to Mesh", node_group, use_link=get_use_link()
)
# 连接节点:源节点 -> Component to Mesh -> 输出节点
node_group.links.new(
source_node.outputs["Structure"], to_mesh_node.inputs[0])
node_group.links.new(to_mesh_node.outputs[0], output_node.inputs[0])
new_obj = None
try:
deps = context.evaluated_depsgraph_get()
eval_obj = container_obj.evaluated_get(deps)
# obtain evaluated mesh (from modifiers including Geometry Nodes)
mesh_eval = eval_obj.to_mesh()
if mesh_eval is None:
raise RuntimeError("Failed to produce evaluated mesh")
mesh_name = f"{container_obj.name}_Mesh"
mesh_copy = mesh_eval.copy()
mesh_copy.name = mesh_name
# clear the temporary evaluated mesh
try:
eval_obj.to_mesh_clear()
except Exception:
pass
# create the new object with same transform as source
new_obj = bpy.data.objects.new(mesh_name, mesh_copy)
# link to same collection as the container (fallback to current collection)
col = (
container_obj.users_collection[0]
if getattr(container_obj, "users_collection", None)
else context.collection
)
if col is None:
col = context.collection
col.objects.link(new_obj)
# copy world transform exactly
new_obj.matrix_world = container_obj.matrix_world.copy()
except Exception as e:
self.report({"ERROR"}, f"Failed to extract mesh: {e}")
# 恢复原始连接
for link in original_links:
node_group.links.new(link.from_socket, output_node.inputs[0])
# 删除临时添加的Component to Mesh节点
node_group.nodes.remove(to_mesh_node)
if new_obj:
# deselect everything, select and activate new_obj
for o in list(context.selected_objects):
o.select_set(False)
new_obj.select_set(True)
context.view_layer.objects.active = new_obj
self.report({"INFO"}, f"Mesh extracted: {new_obj.name}")
return {"FINISHED"}
else:
return {"CANCELLED"}

180
src/bioxelnodes/panels.py Normal file
View File

@@ -0,0 +1,180 @@
from pathlib import Path
import bpy
from .node import get_layer_nodes, get_main_node_group
from .utils import load_icon
from .layer import get_layer_caches
from .operators.io import ImportAsColor, ImportAsLabel, ImportAsScalar, ImportData
from .operators.misc import Help, RenderSettingPreset
from .operators.layer import (
AddLayerNode,
DeleteLayer,
RelocatePath,
RenameLayer,
SaveLayer,
)
class ImportMenu(bpy.types.Menu):
bl_label = "Bioxel Import"
bl_idname = "BIOXEL_MT_import"
def draw(self, context):
layout = self.layout
layout.operator(ImportAsScalar.bl_idname,
text="Import As Scalar").filepath = ""
layout.operator(ImportAsLabel.bl_idname,
text="Import As Label").filepath = ""
layout.operator(ImportAsColor.bl_idname,
text="Import As Color").filepath = ""
class RenderSettingMenu(bpy.types.Menu):
bl_label = "Render Setting Presets"
bl_idname = "BIOXEL_MT_render_setting"
def draw(self, context):
layout = self.layout
for k, v in RenderSettingPreset.PRESETS.items():
op = layout.operator(RenderSettingPreset.bl_idname, text=v)
op.preset = k
class BioxelPanelBase:
"""
Abstract base panel for all Bioxel UI panels.
- Ensures the panel appears only when the active area is the Node Editor
and the Node Editor is editing a Geometry Nodes tree.
- Places the panel under the "Bioxel" sidebar tab.
- Provides an optional bl_order for panel ordering inside the category.
"""
bl_space_type = "NODE_EDITOR"
bl_region_type = "UI"
bl_context = "geometry_node"
bl_category = "Bioxel Nodes"
# try to make Bioxel panels order early (may be respected by Blender)
@classmethod
def poll(cls, context):
node_group = get_main_node_group(context)
return node_group is not None
class HeaderPanel(bpy.types.Panel, BioxelPanelBase):
bl_label = "Bioxel Nodes"
bl_idname = "BIOXEL_PT_header_panel"
bl_order = 0
def draw(self, context):
layout = self.layout
layout.operator(
Help.bl_idname, text="Help", icon=Help.bl_icon # 复用原操作的图标定义
)
layout.menu(
RenderSettingMenu.bl_idname, text="Render Preset", icon="RENDER_STILL"
)
class LayerNodePanel(BioxelPanelBase, bpy.types.Panel):
bl_label = "Layer Nodes"
bl_idname = "BIOXEL_PT_o_layer_nodes"
bl_order = 1
def draw(self, context):
layout = self.layout
node_group = get_main_node_group(context)
layout.template_list(
"BIOXEL_UL_layer_nodes",
"",
node_group,
"nodes",
context.window_manager,
"bioxel_layer_list_index",
rows=3,
)
class LibraryPanel(BioxelPanelBase, bpy.types.Panel):
bl_label = "Layer Library"
bl_idname = "BIOXEL_PT_library_panel"
bl_order = 2
def draw(self, context):
layout = self.layout
wm = context.window_manager
caches = get_layer_caches()
row = layout.row()
row.scale_y = 2.0
row.operator(ImportData.bl_idname, icon="IMPORT")
# row.menu(ImportMenu.bl_idname, icon="IMPORT", text="Import")
layout.separator()
layout.template_icon_view(
bpy.context.window_manager,
"bioxel_layer_library",
show_labels=False,
scale=10.0,
scale_popup=6.0,
)
selected_id = getattr(wm, "bioxel_layer_library", None)
selected_id = None if selected_id == "nothing_found" else selected_id
entry = next((c for c in caches if c.get("id") == selected_id), None)
if entry:
cache_id = entry["id"]
path = entry["path"]
name = entry.get("name", "?")
kind = entry.get("kind", "?")
path_exists = Path(bpy.path.abspath(path)).exists()
layout.prop(wm, "bioxel_snapshot_z", text="Z Slice", slider=True)
layout.separator()
# operations and meta_box as before
ops_row = layout.row(align=True)
if path_exists:
ops_row.operator(
AddLayerNode.bl_idname, text="Add", icon="PLUS"
).cache_id = selected_id
ops_row.operator(
SaveLayer.bl_idname, text="Save", icon="FILE_TICK"
).cache_id = selected_id
ops_row.operator(
RenameLayer.bl_idname, text="Name", icon="FONT_DATA"
).cache_id = selected_id
ops_row.operator(
DeleteLayer.bl_idname, text="", icon="TRASH"
).cache_id = selected_id
else:
ops_row.operator(
RelocatePath.bl_idname, text="Relocate", icon="FILE_FOLDER"
).cache_id = selected_id
ops_row.operator(
DeleteLayer.bl_idname, text="", icon="TRASH"
).cache_id = selected_id
meta_box = layout.box()
if hasattr(meta_box, "alert"):
meta_box.alert = True
meta_box.label(text=f"Cache is missing...")
meta_box.label(text=f"Please relocate the cache path!")
return
meta_box = layout.box()
meta_box.label(text=f"Name: {name}")
meta_box.label(text=f"Path: {path}")
meta_box.label(text=f"Kind: {kind}")
meta_box.label(
text=f"Dims: [{entry.get('frame_count',1)},{tuple(entry.get('shape',''))},{entry.get('channel_count',1)}]"
)
if kind in ["scalar", "color", "vector"]:
path = Path(bpy.path.abspath(entry["path"]))
histogram = load_icon(
path / "histogram.png", f"{cache_id}_histogram")
meta_box.template_icon(histogram, scale=10.0)
else:
layout.label(text="No layer selected", icon="QUESTION")

View File

@@ -8,22 +8,10 @@ class BioxelNodesPreferences(bpy.types.AddonPreferences):
cache_dir: bpy.props.StringProperty(
name="Set Cache Directory",
subtype='DIR_PATH',
default=str(Path(Path.home(), '.bioxelnodes'))
default=str(Path(Path.home(), '.bioxel'))
) # type: ignore
change_render_setting: bpy.props.BoolProperty(
name="Change Render Setting on First Import",
default=True,
) # type: ignore
node_import_method: bpy.props.EnumProperty(name="Node Import Method",
default="LINK",
items=[("LINK", "Link", ""),
("APPEND", "Append (Reuse Data)", "")]) # type: ignore
def draw(self, context):
layout = self.layout
layout.label(text="Configuration")
layout.prop(self, 'cache_dir')
layout.prop(self, "change_render_setting")
layout.prop(self, "node_import_method")

View File

@@ -1,44 +1,134 @@
from pathlib import Path
import bpy
from .bioxelutils.common import get_node_type
from .operators.layer import SelectAndFocusNode
from .utils import load_icon
from .layer import get_layer_caches, set_layer_caches
class BIOXELNODES_UL_layer_list(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_propname):
layout.label(text=item.label, translate=False, icon_value=icon)
def get_snapshot_icon(cache, z: float):
cache_id = str(cache["id"])
shape = (64, 64, 32)
zidx = int(z * (shape[2] - 1))
path = Path(bpy.path.abspath(cache["path"]))
return load_icon(path / f"snapshot_{zidx}.png", f"{cache_id}_{zidx}")
class BIOXELNODES_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
def _bioxel_layer_items(self, context):
"""
EnumProperty items callback: 从 get_layer_caches 构造枚举项。
使用 PREVIEWS 加载每个 layer 的 thumbnail_png(如果存在)并返回 icon_id。
"""
items = []
caches = get_layer_caches()
for idx, cache in enumerate(caches):
cache_id = str(cache["id"])
name = cache.get("name", f"Layer ?")
description = cache.get("kind", "?")
z = cache.get("snapshot_z", 0.5)
icon = get_snapshot_icon(cache, z)
# 在这个context下,只能加载无法生成。
items.append((cache_id, name, description, icon, idx))
if not items:
items = [("nothing_found", "No Layers",
"No layers found", "QUESTION", 0)]
return items
def select_layer(self, context):
layer_list_UL = bpy.context.window_manager.bioxelnodes_layer_list_UL
layer_list = layer_list_UL.layer_list
layer_list_active = layer_list_UL.layer_list_active
if len(layer_list) > 0 and layer_list_active != -1 and layer_list_active < len(layer_list):
layer_obj = bpy.data.objects[layer_list[layer_list_active].obj_name]
node_group = context.space_data.edit_tree
for node in node_group.nodes:
node.select = False
if get_node_type(node) == "BioxelNodes_FetchLayer":
if node.inputs[0].default_value == layer_obj:
node.select = True
layer_list_UL.layer_list_active = -1
def _update_layer_gallery(self, context):
wm = context.window_manager
selected_id = getattr(wm, "bioxel_layer_library", None)
cache = next(
(c for c in get_layer_caches() if str(c.get("id", "")) == str(selected_id)),
None,
)
z = cache.get("snapshot_z", 0.5) if cache else 0.5
setattr(wm, "bioxel_snapshot_z", z)
class BIOXELNODES_Layer(bpy.types.PropertyGroup):
obj_name: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
info_text: bpy.props.StringProperty() # type: ignore
def _update_snapshot_z(self, context):
"""
update callback for WindowManager.bioxel_thumb_z
- write a slice PNG from the saved .npy
- update PREVIEWS entry for the selected layer
- force template_icon_view to refresh by briefly cycling the EnumProperty value
- force UI redraw
"""
wm = context.window_manager
selected_id = getattr(wm, "bioxel_layer_library", None)
if not selected_id or selected_id == "nothing_found":
return
try:
selected_id = int(selected_id)
except Exception:
return
caches = get_layer_caches()
z = getattr(wm, "bioxel_snapshot_z", 0.5)
for cache in caches:
if int(cache.get("id", -1)) == selected_id:
cache["snapshot_z"] = z
break
set_layer_caches(caches)
class BIOXELNODES_LayerListUL(bpy.types.PropertyGroup):
layer_list: bpy.props.CollectionProperty(
type=BIOXELNODES_Layer) # type: ignore
layer_list_active: bpy.props.IntProperty(default=-1,
update=select_layer,
options=set()) # type: ignore
class BIOXEL_Series(bpy.types.PropertyGroup):
id: bpy.props.StringProperty() # type: ignore
label: bpy.props.StringProperty() # type: ignore
class BIOXEL_UL_layer_nodes(bpy.types.UIList):
"""UIList for O Layer nodes only"""
bl_idname = "BIOXEL_UL_layer_nodes"
def filter_items(self, context, data, propname):
nodes = getattr(data, propname)
flt_flags = []
flt_neworder = []
for node in nodes:
# 只保留 O Layer 节点
if (
hasattr(node, "bl_idname")
and node.bl_idname.startswith("GeometryNodeGroup")
and getattr(getattr(node, "node_tree", None), "name", "").startswith(
"O Layer"
)
):
flt_flags.append(self.bitflag_filter_item)
else:
flt_flags.append(0)
return flt_flags, flt_neworder
def draw_item(
self, context, layout, data, item, icon, active_data, active_propname, index
):
node = item
# 这里只画 O Layer 节点,其他已被 filter_items 过滤
split = layout.split(factor=0.3, align=True)
col1 = split.row(align=True)
col2 = split.row(align=True)
name_socket = node.inputs["Name"]
resample_socket = node.inputs["Resample"]
scale_socket = node.inputs["Bioxel Scale"]
name = str(node.inputs["Name"].default_value)
# row.prop(name_socket, "default_value", text="")
col1.prop(resample_socket, "default_value",
icon="UV_DATA", text="", toggle=1)
col1.prop(scale_socket, "default_value", text="")
col2.label(text=name)
op = col2.operator(
SelectAndFocusNode.bl_idname, text="", icon="RESTRICT_SELECT_OFF", emboss=True
)
op.node_name = node.name

View File

@@ -1,6 +1,12 @@
from pathlib import Path
from ast import literal_eval
import shutil
import bpy
import numpy as np
from .node import get_nodes_by_type
from .constants import LATEST_NODE_LIB_PATH, NODE_LIB_DIRPATH, PREVIEW_COLLECTIONS
def copy_to_dir(source_path, dir_path, new_name=None, exist_ok=True):
@@ -44,16 +50,16 @@ def select_object(target_obj):
def progress_bar(self, context):
row = self.layout.row()
row.progress(
factor=context.window_manager.bioxelnodes_progress_factor,
factor=context.window_manager.bioxel_progress_factor,
type="BAR",
text=context.window_manager.bioxelnodes_progress_text
text=context.window_manager.bioxel_progress_text
)
row.scale_x = 2
def progress_update(context, factor, text=""):
context.window_manager.bioxelnodes_progress_factor = factor
context.window_manager.bioxelnodes_progress_text = text
bpy.context.window_manager.bioxel_progress_factor = factor
bpy.context.window_manager.bioxel_progress_text = text
def get_preferences():
@@ -62,11 +68,335 @@ def get_preferences():
def get_cache_dir():
preferences = get_preferences()
cache_path = Path(preferences.cache_dir, 'VDBs')
cache_path = Path(preferences.cache_dir)
cache_path.mkdir(parents=True, exist_ok=True)
return str(cache_path)
return cache_path
def get_use_link():
preferences = get_preferences()
return preferences.node_import_method == "LINK"
def get_container_objs_from_selection():
container_objs = []
for obj in bpy.context.selected_objects:
if get_container_obj(obj):
container_objs.append(obj)
return list(set(container_objs))
def get_container_obj(current_obj):
if current_obj:
if current_obj.get('bioxel_container'):
return current_obj
elif current_obj.get('bioxel_layer'):
parent = current_obj.parent
return parent if parent.get('bioxel_container') else None
return None
def get_layer_prop_value(layer_obj: bpy.types.Object, prop_name: str):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
prop = layer_node.inputs.get(prop_name)
if prop is None:
return None
value = prop.default_value
if type(value).__name__ == "bpy_prop_array":
value = tuple(value)
return tuple([int(v) for v in value]) \
if prop in ["shape"] else value
elif type(value).__name__ == "str":
return str(value)
elif type(value).__name__ == "float":
value = float(value)
return round(value, 2) \
if prop in ["bioxel_size"] else value
elif type(value).__name__ == "int":
value = int(value)
return value
else:
return value
def get_layer_name(layer_obj):
return get_layer_prop_value(layer_obj, "name")
def get_layer_kind(layer_obj):
return get_layer_prop_value(layer_obj, "kind")
def get_layer_label(layer_obj):
name = get_layer_name(layer_obj)
# kind = get_layer_kind(layer_obj)
label = f"{name}"
if is_missing_layer(layer_obj):
return "**MISSING**" + label
elif is_temp_layer(layer_obj):
return "* " + label
else:
return label
def is_missing_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
return not cache_filepath.is_file()
def is_temp_layer(layer_obj):
cache_filepath = Path(bpy.path.abspath(
layer_obj.data.filepath)).resolve()
cache_dirpath = Path(get_cache_dir())
return cache_dirpath in cache_filepath.parents
def set_layer_prop_value(layer_obj: bpy.types.Object, prop: str, value):
node_group = layer_obj.modifiers[0].node_group
layer_node = get_nodes_by_type(node_group, "BioxelNodes__Layer")[0]
layer_node.inputs[prop].default_value = value
def get_layer_obj(current_obj: bpy.types.Object):
if current_obj:
if current_obj.get('bioxel_layer') and current_obj.parent:
if current_obj.parent.get('bioxel_container'):
return current_obj
return None
def get_container_layer_objs(container_obj: bpy.types.Object):
layer_objs = []
for obj in bpy.data.objects:
if obj.parent == container_obj and get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def get_all_layer_objs():
layer_objs = []
for obj in bpy.data.objects:
if get_layer_obj(obj):
layer_objs.append(obj)
return layer_objs
def add_driver(target, target_prop, var_sources, expression):
driver = target.driver_add(target_prop)
is_vector = isinstance(driver, list)
drivers = driver if is_vector else [driver]
for i, driver in enumerate(drivers):
for j, var_source in enumerate(var_sources):
source = var_source['source']
prop = var_source['prop']
var = driver.driver.variables.new()
var.name = f"var{j}"
var.targets[0].id_type = source.id_type
var.targets[0].id = source
var.targets[0].data_path = f'["{prop}"][{i}]'\
if is_vector else f'["{prop}"]'
driver.driver.expression = expression
def add_direct_driver(target, target_prop, source, source_prop):
drivers = [
{
"source": source,
"prop": source_prop
}
]
expression = "var0"
add_driver(target, target_prop, drivers, expression)
def read_file_prop(content: str):
props = {}
for line in content.split("\n"):
line = line.replace(" ", "")
p = line.split("=")[0]
if p != "":
v = line.split("=")[-1]
props[p] = v
return props
def write_file_prop(props: dict):
lines = []
for p, v in props.items():
lines.append(f"{p} = {v}")
return "\n".join(lines)
def set_file_prop(prop, value):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
props[prop] = value
bpy.data.texts["BioxelNodes"].clear()
bpy.data.texts["BioxelNodes"].write(write_file_prop(props))
def get_file_prop(prop):
if bpy.data.texts.get("BioxelNodes") is None:
bpy.data.texts.new("BioxelNodes")
props = read_file_prop(bpy.data.texts["BioxelNodes"].as_string())
return props.get(prop)
def get_node_version():
node_version = get_file_prop("node_version")
return literal_eval(node_version) if node_version else None
def is_incompatible():
return False
def get_node_lib_path(node_version):
version_str = "v"+".".join([str(i) for i in list(node_version)])
lib_filename = f"BioxelNodes_{version_str}.blend"
return Path(NODE_LIB_DIRPATH,
lib_filename).resolve()
def local_lib_not_updated():
return False
def ndarray_to_png(array: np.ndarray, save_path: str):
"""
Create a PNG from a 2D or 3-channel slice array using Blender API.
slice_arr expected shape: (W, H) or (W, H, 1) or (W,H,3). Values assumed numeric.
The function:
- Converts single-channel arrays to RGB by repeating channel.
- Flips the image vertically to match Blender's origin.
- Writes the image file to save_path using a temporary Blender image datablock.
Parameters:
- array: numpy ndarray representing the slice (W,H,C) or (W,H,1) or (W,H)
- save_path: destination PNG filepath
"""
save_path = Path(save_path)
h, w = array.shape[0], array.shape[1]
if array.shape[2] == 1:
img = np.concatenate([array, array, array], axis=2)
else:
img = array[..., :3]
# Blender expects pixels as sequence of floats in RGBA, length w*h*4
# NOTE: Blender's image origin is bottom-left; we'll flip vertically for visual consistency.
img = np.flipud(img)
pixels = np.empty((h * w * 4,), dtype=np.float32)
# fill
pixels[0::4] = img[..., 0].ravel()
pixels[1::4] = img[..., 1].ravel()
pixels[2::4] = img[..., 2].ravel()
pixels[3::4] = 1.0
# create a temporary image datablock, save then remove
name = "tmp"
image = bpy.data.images.new(
name, width=w, height=h, alpha=True, float_buffer=False)
image.pixels = pixels.tolist()
# ensure format and filepath then save
image.filepath_raw = str(save_path)
image.file_format = "PNG"
image.save()
# remove from data to avoid clutter
try:
bpy.data.images.remove(image)
except Exception:
pass
def split_text_to_lines(text: str, max_chars: int = 60) -> list:
"""
Split `text` into multiple lines not exceeding `max_chars` where possible.
Breaks on whitespace; long words are chunked.
Returns list of line strings.
"""
if not text:
return []
words = text.split()
lines = []
cur = ""
for w in words:
if cur:
candidate = cur + " " + w
else:
candidate = w
if len(candidate) <= max_chars:
cur = candidate
else:
if cur:
lines.append(cur)
# if single word longer than max_chars, chunk it
if len(w) > max_chars:
for i in range(0, len(w), max_chars):
lines.append(w[i: i + max_chars])
cur = ""
else:
cur = w
if cur:
lines.append(cur)
return lines
def wrapped_label(layout, text: str, max_chars: int = 42):
"""
Helper to add multiple layout.label calls for a long text.
"""
for line in split_text_to_lines(text, max_chars=max_chars):
layout.label(text=line)
def refresh_bioxel_panels(context):
"""
Force Bioxel UI to refresh.
- Tags relevant areas for redraw.
- Nudges the bioxel_layer_gallery EnumProperty to force items() re-evaluation
when possible (helps template_icon_view pick up updated icons/paths).
Use this from operators after they change b i o x e l _ l a y e r s or previews.
"""
# tag redraw for all windows/areas
for window in context.window_manager.windows:
for area in window.screen.areas:
# target node editor and other UI areas that may show previews
if area.type in {"NODE_EDITOR", "VIEW_3D", "IMAGE_EDITOR", "OUTLINER"}:
area.tag_redraw()
def load_icon(filepath: Path, key: str):
pcoll = PREVIEW_COLLECTIONS["layers"]
try:
icon = pcoll[key].icon_id
except KeyError:
if filepath.exists():
pcoll.load(key, str(filepath), "IMAGE")
try:
icon = pcoll[key].icon_id
except KeyError:
icon = "TEXTURE"
return icon

View File

691
uv.lock generated

File diff suppressed because it is too large Load Diff