diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..cfeebc0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# All files — auto-request review from the maintainer on every PR. +* @Clonephaze diff --git a/.github/DISCUSSION_TEMPLATE/announcements.yml b/.github/DISCUSSION_TEMPLATE/announcements.yml new file mode 100644 index 0000000..9acba80 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/announcements.yml @@ -0,0 +1,25 @@ +title: "[Announcement]" +labels: ["announcement"] +body: + - type: markdown + attributes: + value: | + ## 📢 New Announcement + Use this category for release notes, important project updates, and milestone callouts. + Only maintainers should post here. + + - type: input + id: version + attributes: + label: Related version (if applicable) + placeholder: "e.g. v2.1.0" + validations: + required: false + + - type: textarea + id: content + attributes: + label: Announcement + description: Write the full announcement here. Markdown is supported. + validations: + required: true diff --git a/.github/DISCUSSION_TEMPLATE/bug-reports.yml b/.github/DISCUSSION_TEMPLATE/bug-reports.yml new file mode 100644 index 0000000..eabd67a --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/bug-reports.yml @@ -0,0 +1,84 @@ +title: "[Bug] " +labels: ["bug", "needs triage"] +body: + - type: markdown + attributes: + value: | + Found something broken? Fill this out with as much detail as possible. + If you're not sure it's a bug, post in [Support / Troubleshooting](../../discussions/new?category=support) first. + + - type: input + id: addon-version + attributes: + label: Addon Version + description: Found in Edit → Preferences → Add-ons → 3MF Format + placeholder: "e.g. 2.0.0" + validations: + required: true + + - type: input + id: blender-version + attributes: + label: Blender Version + placeholder: "e.g. 5.0" + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + placeholder: "e.g. Windows 11, macOS 14, Ubuntu 24.04" + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + options: + - Import + - Export — Standard + - Export — Orca / BambuStudio + - Export — PrusaSlicer + - Multi-Material Paint + - UI / Preferences + - Performance + - Other + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe what went wrong. Paste any error output from Window → Toggle System Console. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open a .3mf file with '...' + 2. Export with settings '...' + 3. Open the result in OrcaSlicer + 4. See '...' + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Additional context + description: Screenshots, your .3mf file, or anything else that might help. + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/feature-ideas.yml b/.github/DISCUSSION_TEMPLATE/feature-ideas.yml new file mode 100644 index 0000000..4fa82c9 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/feature-ideas.yml @@ -0,0 +1,54 @@ +title: "[Idea] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Got an idea to make the addon better? Share it here for community discussion before it becomes a formal feature request. + + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + options: + - Import + - Export — Standard + - Export — Orca / BambuStudio + - Export — PrusaSlicer + - Multi-Material Paint + - UI / Preferences + - Performance + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the limitation or gap you've hit. + placeholder: "When I try to... I can't... because..." + validations: + required: true + + - type: textarea + id: idea + attributes: + label: Describe your idea + description: What would the ideal solution look like? Rough sketches, mockups, and examples from other tools are all welcome. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds you've considered + validations: + required: false + + - type: textarea + id: extra + attributes: + label: Additional context + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/showcase-gallery.yml b/.github/DISCUSSION_TEMPLATE/showcase-gallery.yml new file mode 100644 index 0000000..2963224 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/showcase-gallery.yml @@ -0,0 +1,53 @@ +title: "[Showcase]" +labels: ["showcase"] +body: + - type: markdown + attributes: + value: | + Show off something you made with the 3MF addon! Prints, sliced models, multi-material setups, creative workflows — all welcome. + + - type: input + id: title + attributes: + label: What did you make? + placeholder: "e.g. 7-colour dragon with Orca paint export, functional hinge assembly..." + validations: + required: true + + - type: dropdown + id: workflow + attributes: + label: Which workflow did you use? + options: + - Standard export + - Orca / BambuStudio multi-colour + - PrusaSlicer MMU + - Import + re-export roundtrip + - Multi-Material Paint Suite + - Other / combination + validations: + required: true + + - type: textarea + id: description + attributes: + label: Tell us about it + description: What was the process? Any tips or tricks you discovered? + validations: + required: true + + - type: textarea + id: media + attributes: + label: Photos / screenshots / files + description: Drag images in here, or share a link. A photo of the finished print is always a highlight! + validations: + required: false + + - type: input + id: addon-version + attributes: + label: Addon version used + placeholder: "e.g. 2.0.0" + validations: + required: false diff --git a/.github/DISCUSSION_TEMPLATE/support-troubleshooting.yml b/.github/DISCUSSION_TEMPLATE/support-troubleshooting.yml new file mode 100644 index 0000000..e90e07a --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/support-troubleshooting.yml @@ -0,0 +1,72 @@ +title: "[Support] " +labels: ["support"] +body: + - type: markdown + attributes: + value: | + Having trouble? Fill this out and the community can help. + For confirmed bugs, please open a [Bug Report issue](../../issues/new/choose) instead. + + - type: input + id: addon-version + attributes: + label: Addon Version + description: Found in Edit → Preferences → Add-ons → 3MF Format + placeholder: "e.g. 2.0.0" + validations: + required: true + + - type: input + id: blender-version + attributes: + label: Blender Version + placeholder: "e.g. 5.0" + validations: + required: true + + - type: input + id: slicer + attributes: + label: Target Slicer (if relevant) + placeholder: "e.g. OrcaSlicer 2.3, PrusaSlicer 2.9, BambuStudio" + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + options: + - Import + - Export — Standard + - Export — Orca / BambuStudio + - Export — PrusaSlicer + - Multi-Material Paint + - UI / Preferences + - Other + validations: + required: true + + - type: textarea + id: question + attributes: + label: What are you trying to do? + description: Describe what you're attempting and where you're getting stuck. + validations: + required: true + + - type: textarea + id: tried + attributes: + label: What have you tried? + description: Steps you've already taken, workarounds attempted, etc. + validations: + required: false + + - type: textarea + id: extra + attributes: + label: Additional context + description: Console output, screenshots, or a sample .3mf file are very helpful. + validations: + required: false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1b71518 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Clonephaze +buy_me_a_coffee: clonephaze diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7c6cdbd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,87 @@ +name: Bug Report +description: Something isn't working right +labels: ["bug", "needs triage"] +assignees: ["Clonephaze"] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! The more detail you provide, the faster it can be fixed. + + - type: input + id: addon-version + attributes: + label: Addon Version + description: Found in Edit → Preferences → Add-ons → 3MF Format + placeholder: "e.g. 2.0.0" + validations: + required: true + + - type: input + id: blender-version + attributes: + label: Blender Version + placeholder: "e.g. 5.0" + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + placeholder: "e.g. Windows 11, macOS 14, Ubuntu 24.04" + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + options: + - Import + - Export — Standard + - Export — Orca / BambuStudio + - Export — PrusaSlicer + - Multi-Material Paint + - UI / Preferences + - Performance + - Other + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Describe what went wrong. Include any error messages from the Blender console (Window → Toggle System Console). + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Walk through exactly what you did before hitting the bug. + placeholder: | + 1. Opened a .3mf file with '...' + 2. Clicked Export with settings '...' + 3. Opened the result in OrcaSlicer + 4. Saw '...' + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Additional context + description: Attach screenshots, your .3mf file, or anything else that might help. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..beaa4a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false + +contact_links: + - name: Usage Guide + url: https://www.clonecore.net/docs/3mf-guide/getting-started + about: Import/export walkthrough and slicer-specific setup. + - name: API Guide + url: https://www.clonecore.net/docs/3mf/guide + about: Headless and scripting usage via the public API. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a9f9975 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,58 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] + +body: + - type: markdown + attributes: + value: | + Got an idea to make the 3MF addon better? Fill this out and it'll be considered for a future release. + + - type: dropdown + id: area + attributes: + label: Which area does this relate to? + options: + - Import + - Export — Standard + - Export — Orca / BambuStudio + - Export — PrusaSlicer + - Multi-Material Paint + - UI / Preferences + - Performance + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: Describe the limitation or gap in the current addon. + placeholder: "When I try to... I can't... because..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Describe the feature you'd like + description: What would the ideal solution look like? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + description: Any workarounds you currently use, or other approaches that might work. + validations: + required: false + + - type: textarea + id: extra + attributes: + label: Additional context + description: Screenshots, references, examples from other tools — anything helpful. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..476b541 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## What does this PR do? + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / code cleanup +- [ ] Documentation update +- [ ] Other: + +## Related issue + +Closes # + +## How was it tested? + + + +## Checklist + +- [ ] I tested this in Blender 4.2 or newer +- [ ] I ran the test suite (`python tests/run_all_tests.py`) and it passes +- [ ] I haven't broken any existing import/export behaviour I'm aware of +- [ ] New or changed behaviour is reflected in the README (if relevant) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..a71935a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,367 @@ +# Copilot Instructions for Blender 3MF Format + +## Project Overview + +Blender addon (extension) for importing/exporting **3MF Core Spec v1.4.0** files with multi-material support for Orca Slicer, BambuStudio, PrusaSlicer, and SuperSlicer. Targets **Blender 4.2+** minimum; primary development on **Blender 5.0**. + +- **Version:** 2.0.0 +- **Extension ID:** `ThreeMF_io` +- **License:** GPL-3.0-or-later +- **Manifest:** `io_mesh_3mf/blender_manifest.toml` + +--- + +## Architecture + +``` +io_mesh_3mf/ +├── __init__.py # Addon registration, FileHandler, preferences, reload logic +├── api.py # Public API: import_3mf(), export_3mf(), inspect_3mf(), batch ops +├── paint_panel.py # MMU Paint Suite panel (~1050 lines) - texture painting UI +├── orca_project_template.json # Template JSON for Orca Slicer metadata export +│ +├── common/ # Shared across import & export +│ ├── __init__.py # Re-exports key symbols (debug, warn, error, hex_to_rgb, etc.) +│ ├── types.py # All dataclasses (ResourceObject, ResourceMaterial, etc.) +│ ├── constants.py # XML namespaces, file paths, MIME types, spec version +│ ├── extensions.py # ExtensionManager, Extension registry +│ ├── metadata.py # Metadata / MetadataEntry classes +│ ├── annotations.py # Annotations class, ContentType / Relationship namedtuples (OPC packaging) +│ ├── units.py # Unit conversion dicts + scale functions +│ ├── colors.py # hex↔RGB, sRGB↔linear conversions +│ ├── logging.py # DEBUG_MODE, debug(), warn(), error(), safe_report() +│ ├── xml.py # parse_transformation, format_transformation, resolve_extension_prefixes +│ └── segmentation.py # SegmentationDecoder / Encoder / TriangleSubdivider +│ +├── import_3mf/ # Import package +│ ├── __init__.py # Re-exports Import3MF operator +│ ├── operator.py # Import3MF: UI properties, draw, invoke, execute shell +│ ├── context.py # ImportContext / ImportOptions dataclasses +│ ├── archive.py # read_archive, read_content_types, assign_content_types, must_preserve, load_external_model +│ ├── geometry.py # read_objects, read_vertices, read_triangles, read_components +│ ├── builder.py # build_items, build_object orchestration +│ ├── scene.py # Mesh creation, material assignment, UV setup, origin, grid layout +│ ├── segmentation.py # Hash segmentation → UV texture rendering (numpy) +│ ├── triangle_sets.py # Triangle Sets Extension import +│ ├── slicer/ # Slicer-specific detection and data +│ │ ├── __init__.py +│ │ ├── detection.py # detect_vendor +│ │ ├── colors.py # read_orca/prusa/blender/prusa_slic3r filament colors, read_prusa_object_extruders +│ │ └── paint.py # ORCA_PAINT_TO_INDEX, parse_paint_color_to_index, get_or_create_paint_material, subdivide_prusa_segmentation +│ └── materials/ # Materials Extension import +│ ├── __init__.py +│ ├── base.py # basematerials, colorgroups, parse_hex_color, srgb_to_linear +│ ├── textures.py # texture2d / texture2dgroup parsing + extraction +│ ├── pbr.py # PBR display properties (metallic, specular, translucent) +│ └── passthrough.py # composites, multiproperties, store_passthrough +│ +└── export_3mf/ # Export package + ├── __init__.py # Re-exports Export3MF operator + ├── operator.py # Export3MF: UI properties, draw, invoke, execute dispatch + ├── context.py # ExportContext / ExportOptions dataclasses + ├── archive.py # create_archive, must_preserve, write_core_properties + ├── geometry.py # write_vertices, write_triangles, write_passthrough_triangles, write_metadata, check_non_manifold_geometry + ├── standard.py # BaseExporter (shared base class), StandardExporter + ├── orca.py # OrcaExporter + ├── prusa.py # PrusaExporter + ├── components.py # detect_linked_duplicates, should_use_components + ├── thumbnail.py # Viewport render → PNG thumbnail + ├── segmentation.py # UV textures → segmentation hash strings (numpy) + ├── triangle_sets.py # Triangle Sets Extension export + └── materials/ # Materials Extension export + ├── __init__.py + ├── base.py # ORCA_FILAMENT_CODES, face colors, basematerials/colorgroups + ├── textures.py # Texture detection, archive writing, texture resources + ├── pbr.py # PBR property extraction + display property writing + └── passthrough.py # Round-trip passthrough material writing (ID remapping) +``` + +### Key architectural patterns + +- **Context dataclasses** — `ImportContext` and `ExportContext` replace mutable `self.*` state on operators. Every helper takes `ctx` as its first argument. +- **Import/Export operators** inherit from `bpy.types.Operator` + `ImportHelper`/`ExportHelper`. They are thin shells that create a context and delegate work to submodules. +- **3MF files** are ZIP archives containing XML model files + OPC structure +- **XML parsing** uses `xml.etree.ElementTree` exclusively (never lxml) +- **Export dispatch:** `Export3MF.execute()` → `StandardExporter` / `OrcaExporter` / `PrusaExporter` (all inherit from `BaseExporter` in `standard.py`) +- **Materials sub-packages** mirror each other: `import_3mf/materials/` and `export_3mf/materials/` with matching module names +- **Public API** (`api.py`) provides `import_3mf()`, `export_3mf()`, `inspect_3mf()`, `batch_import()`, `batch_export()` for headless/programmatic use without `bpy.ops` + +--- + +## Coding Practices + +### Logging — NO `logging` module + +**Blender addons have no logging infrastructure.** Python's `logging` module does nothing in Blender because there are no handlers configured. **Never use `import logging` or `logging.getLogger()`.** + +All logging goes through `common/logging.py`: + +```python +from ..common import debug, warn, error +# or +from ..common.logging import debug, warn, error + +# Informational / progress messages — silent by default +debug(f"Loaded {count} objects") + +# Warnings about malformed data — ALWAYS prints with "WARNING:" prefix +warn(f"Missing vertex coordinate in triangle {idx}") + +# Errors — ALWAYS prints with "ERROR:" prefix +error(f"Failed to write archive: {e}") +``` + +- `debug()` is gated by `DEBUG_MODE = False` in `common/logging.py` — set to `True` during development only +- `warn()` and `error()` always print, so real problems are visible to users + +### Color conversions — use `common/colors.py` helpers + +```python +from ..common.colors import hex_to_rgb, rgb_to_hex + +r, g, b = hex_to_rgb("#CC3319") # → (0.8, 0.2, 0.098...) +hex_str = rgb_to_hex(0.8, 0.2, 0.1) # → "#CC3319" +``` + +**Exception:** `import_3mf/materials/base.py` has its own `parse_hex_color()` that handles RGBA + sRGB-to-linear conversion. That serves a different purpose and should NOT be replaced. + +### Unicode safety + +Always cache Blender strings to a local variable before passing them to XML/ElementTree operations. Python can garbage-collect the underlying C string otherwise: + +```python +object_name = str(blender_object.name) # Cache before use in XML +``` + +### Blender property naming + +Blender custom properties **cannot start with an underscore**. Use `3mf_` prefix instead. + +### Blender 5.0 API differences + +Check version before using changed APIs: + +```python +if bpy.app.version >= (5, 0, 0): + # Blender 5.0: image_paint.brush is read-only, use paint_settings API + # unified_paint_settings accessed via ts.image_paint.unified_paint_settings +else: + # Blender 4.x: direct brush assignment works +``` + +### Error reporting in operators and contexts + +Use `safe_report()` for messages that should appear in Blender's status bar: + +```python +# On ImportContext / ExportContext: +ctx.safe_report({'ERROR'}, "No mesh objects selected") +ctx.safe_report({'WARNING'}, "Non-manifold geometry detected") +ctx.safe_report({'INFO'}, f"Exported {count} objects") + +# Standalone function (from common/logging.py): +from ..common.logging import safe_report +safe_report(operator, {'WARNING'}, "Some message") +``` + +`safe_report()` gracefully falls back when running without a real Blender operator (e.g., API calls or tests). + +--- + +## Custom Mesh Properties + +These are stored on `mesh.data` (the Mesh datablock, not the Object): + +| Property | Type | Description | +|----------|------|-------------| +| `3mf_is_paint_texture` | `bool` | Mesh has an MMU paint texture | +| `3mf_paint_extruder_colors` | `str` | Stringified dict of `{extruder_index: "#RRGGBB"}` | +| `3mf_paint_default_extruder` | `int` | Default extruder (1-based) for unpainted regions | +| `3mf_triangle_set` | int attribute | Per-face set index (0 = no set) | +| `3mf_triangle_set_names` | `list` | Ordered list of triangle set names | + +--- + +## Export Modes + +### Standard Export (`StandardExporter`) + +Spec-compliant single `3D/3dmodel.model` file. The `use_orca_format` setting controls dispatch: + +- **AUTO** — detects materials and paint data, chooses the best exporter +- **STANDARD** — geometry with basematerials/colorgroups/textures when present +- **PAINT** — UV-painted regions exported as hash segmentation strings + +### Orca Export (`OrcaExporter`) + +Production Extension multi-file structure for Orca Slicer / BambuStudio: + +- Individual objects in `3D/Objects/*.model` with `paint_color` attributes for per-triangle colors +- Main model with `p:path` component references +- `Metadata/project_settings.config` JSON with filament colors +- Filament color mapping via `blender_filament_colors.xml` fallback metadata + +### Prusa Export (`PrusaExporter`) + +PrusaSlicer-compatible format: + +- Single model file with `slic3rpe:mmu_segmentation` attributes for hash segmentation +- `Slic3r_PE.config` with printer/filament settings + +### Paint color encoding (Orca format) + +```python +# export: filament index → paint code +ORCA_FILAMENT_CODES = ["", "4", "8", "0C", "1C", ...] # index 0=none, 1="4", 2="8" + +# import: paint code → filament index (1-based) +ORCA_PAINT_TO_INDEX = {"": 0, "4": 1, "8": 2, "0C": 3, ...} +``` + +--- + +## MMU Paint Suite (`paint_panel.py`) + +Sidebar panel (`VIEW3D_PT_mmu_paint`) for multi-filament texture painting. Two UI states: + +1. **Init Setup** — editable filament list, color pickers, "Initialize Painting" button +2. **Active Painting** — read-only swatch palette, click to switch brush color, add/remove/reassign filaments + +Key classes: +- **PropertyGroups:** `MMUFilamentItem` (display), `MMUInitFilamentItem` (editable), `MMUPaintSettings` (scene-level) +- **UILists:** `MMU_UL_init_filaments`, `MMU_UL_filaments` +- **Operators:** `MMU_OT_initialize`, `MMU_OT_select_filament`, `MMU_OT_reassign_filament_color`, `MMU_OT_switch_to_paint`, `MMU_OT_import_paint_popup`, etc. + +Uses `numpy` for bulk pixel operations (color reassignment, texture scanning). + +--- + +## Hash Segmentation System + +Three-module pipeline for slicer-agnostic multi-material data: + +1. **`common/segmentation.py`** — Core codec: `SegmentationDecoder`, `SegmentationEncoder`, `SegmentationNode` tree, `TriangleSubdivider`. Hex strings encode recursive subdivision trees where each nibble = `xxyy` (state/split info). + +2. **`import_3mf/segmentation.py`** — Renders segmentation trees as colored UV textures: subdivide triangles in UV space → fill pixels with extruder colors → gap filling. Uses numpy vectorized ops. + +3. **`export_3mf/segmentation.py`** — Reverses the process: pre-compute state map from texture pixels (numpy) → sample at triangle corners/interior → recursively build segmentation tree → encode to hex string. Performance-critical. + +--- + +## Extension System + +### Adding namespace support + +1. Add constant in `common/constants.py`: `NEW_NAMESPACE = "http://..."` +2. Add to `SUPPORTED_EXTENSIONS` set +3. Register in `common/extensions.py` with `Extension` dataclass +4. Add to `MODEL_NAMESPACES` dict for XML parsing + +### Extension prefix resolution + +`requiredextensions="p"` uses prefixes, not URIs. Use `resolve_extension_prefixes()` from `common/xml.py`: + +```python +known_prefix_mappings = { + "p": PRODUCTION_NAMESPACE, + "m": MATERIAL_NAMESPACE, +} +``` + +--- + +## Public API (`api.py`) + +For headless/programmatic use without `bpy.ops`: + +```python +from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + +# Inspect without creating Blender objects +info = inspect_3mf("model.3mf") +print(info.unit, info.num_objects, info.num_triangles_total) + +# Import +result = import_3mf("model.3mf", import_materials="PAINT") +print(result.status, result.num_loaded, result.objects) + +# Export +result = export_3mf("output.3mf", use_orca_format="AUTO") +print(result.status, result.num_written) + +# Batch operations +from io_mesh_3mf.api import batch_import, batch_export +results = batch_import(["a.3mf", "b.3mf"]) + +# Building blocks for custom workflows +from io_mesh_3mf.api import colors, types, segmentation, units +``` + +--- + +## Testing + +Tests require **Blender's Python** (not system Python). **No mocking** — all tests run inside real Blender headless mode. Three runners: + +```powershell +# All tests (unit + integration, spawns separate Blender processes) +python tests/run_all_tests.py + +# Unit tests only (real Blender Python, no mocks — tests/unit/) +blender --background --factory-startup --python-exit-code 1 -noaudio -q --python tests/run_unit_tests.py + +# Integration tests only (real Blender objects — tests/integration/) +blender --background --factory-startup --python-exit-code 1 -noaudio -q --python tests/run_tests.py +``` + +- **Unit tests** (`tests/unit/`) test individual functions with real Blender Python (colors, types, constants, segmentation, xml, units, metadata) +- **Integration tests** (`tests/integration/`) create real Blender objects, import/export real `.3mf` files +- **Test resources** in `tests/resources/` and `tests/resources/3mf_consortium/` +- **Blender CLI flags:** `--factory-startup` (deterministic), `--python-exit-code 1` (CI-friendly), `-noaudio` (faster), `-q` (quiet) + +--- + +## Build & Install + +```powershell +cd io_mesh_3mf +blender --command extension build # → ThreeMF_io-2.0.0.zip +``` + +Drag the resulting `.zip` into Blender → Preferences → Add-ons to install. + +--- + +## Key Files Quick Reference + +| File | Purpose | +|------|---------| +| `common/logging.py` | `debug()`, `warn()`, `error()`, `safe_report()`, `DEBUG_MODE` | +| `common/colors.py` | `hex_to_rgb()`, `rgb_to_hex()`, `srgb_to_linear()`, `linear_to_srgb()` | +| `common/constants.py` | All XML namespaces, file paths, MIME types, spec version | +| `common/types.py` | All dataclasses (ResourceObject, ResourceMaterial, etc.) | +| `common/extensions.py` | Extension registry, `ExtensionManager`, `Extension` dataclass | +| `common/segmentation.py` | Core segmentation tree codec (decode/encode hex strings) | +| `import_3mf/context.py` | `ImportContext` / `ImportOptions` dataclasses | +| `export_3mf/context.py` | `ExportContext` / `ExportOptions` dataclasses | +| `export_3mf/standard.py` | `StandardExporter` | +| `export_3mf/orca.py` | `OrcaExporter` | +| `export_3mf/prusa.py` | `PrusaExporter` | +| `api.py` | Public API: `import_3mf()`, `export_3mf()`, `inspect_3mf()` | +| `paint_panel.py` | MMU Paint Suite sidebar panel | +| `orca_project_template.json` | Template JSON for Orca metadata export | + +--- + +## Caveats & Gotchas + +1. **No `logging` module** — use `common.logging` `debug`/`warn`/`error` exclusively +2. **No `print()` calls** — use `debug()` for dev output, `warn()`/`error()` for real issues +3. **Blender properties can't start with `_`** — use `3mf_` prefix for custom properties +4. **Cache strings before XML ops** — Blender may GC the C string behind `blender_object.name` +5. **numpy is available** in Blender's Python — used extensively for pixel operations +6. **Blender 5.0 broke brush APIs** — `image_paint.brush` is read-only; version-check before use +7. **Context dataclasses** — `ImportContext` / `ExportContext` are the state bags. Operators create them in `execute()` and pass to all helpers. +8. **Sub-package imports** — use `from ..common import ...` for common utilities +9. **`safe_report()`** — use on contexts (`ctx.safe_report()`) or standalone from `common.logging` — never bare `self.report()` so tests don't crash +10. **sRGB vs linear** — `import_3mf/materials/base.py` has `srgb_to_linear()` for material colors; `common/colors.py` `hex_to_rgb()` returns raw values (no gamma conversion) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1a5ebc4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,49 @@ +# Build Sphinx docs and deploy to GitHub Pages on pushes to main. +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: # Allow manual trigger from Actions tab + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Sphinx + run: pip install sphinx furo numpy + + - name: Build HTML + run: sphinx-build -b html -d docs/_build/doctrees docs docs/_build/html -W --keep-going + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_build/html + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..7c5d797 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,30 @@ +name: Stale Issues + +on: + schedule: + - cron: "0 6 * * *" # Daily at 06:00 UTC + workflow_dispatch: + +permissions: + issues: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-message: > + This issue has had no activity for 60 days. It will be closed in 14 days + unless there is further activity. If this is still relevant, please leave a + comment or a 👍 to keep it open. + close-issue-message: > + Closing due to inactivity. Feel free to reopen if the issue is still present + in the latest version. + stale-issue-label: "stale" + exempt-issue-labels: "pinned,bug,in progress" + days-before-stale: 60 + days-before-close: 14 + # Don't touch PRs — let those be reviewed manually. + days-before-pr-stale: -1 + days-before-pr-close: -1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6dac72..6d9aa28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,30 +1,36 @@ # Blender add-on to import and export 3MF files. -# Copyright (C) 2020 Ghostkeeper -# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public -# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later -# version. -# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied -# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free -# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# Copyright (C) 2020 Ghostkeeper, 2025 Clonephaze +# This add-on is free software: you can redistribute it and/or modify it under the terms of the GNU General +# Public License as published by the Free Software Foundation, either version 2 of the License, or (at your +# option) any later version. +# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the +# implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# You should have received a copy of the GNU General Public License along with this program. If not, see +# . -# Github action that performs the automated tests on every change and pull request using Python's Unittest module. -name: Automated Tests -on: [push, pull_request] +# GitHub workflow that performs code style checks on every change and pull request. +# Note: Unit and integration tests require Blender and must be run locally before commits. +# Run all tests locally with: python tests/run_all_tests.py +name: Code Style + +on: + push: + pull_request: jobs: - test: # Performs the Unittest tests. + lint: # Performs code style checks. runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install dependencies - run: python3 -m pip install mathutils pycodestyle - - name: Test - run: python3 -m unittest test + run: python3 -m pip install pycodestyle - name: Code style - run: python3 -m pycodestyle --ignore=E402 --max-line-length=120 . \ No newline at end of file + # E402: module-level import not at top (common in Blender addons) + # W503: line break before binary operator (conflicts with PEP8 recommendation) + run: python3 -m pycodestyle --ignore=E402,W503 --max-line-length=120 io_mesh_3mf/ tests/ diff --git a/.gitignore b/.gitignore index 8d2929c..7daba6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ # I like to store old releases in my project folder for this project. releases +/.github/chatmodes +/.github/prompts +*.pyc +*.zip +docs/_build/ +/ReferenceFiles diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..9a987d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,97 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + }, + { + "name": "Run All Tests (Unit + Integration)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/tests/run_all_tests.py", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "justMyCode": false + }, + { + "name": "Blender: Run Unit Tests", + "type": "debugpy", + "request": "launch", + "program": "blender", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "args": [ + "--background", + "--factory-startup", + "--python-exit-code", "1", + "-noaudio", + "-q", + "--python", + "tests/run_unit_tests.py" + ] + }, + { + "name": "Blender: Run Integration Tests", + "type": "debugpy", + "request": "launch", + "program": "blender", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "args": [ + "--background", + "--factory-startup", + "--python-exit-code", "1", + "-noaudio", + "-q", + "--python", + "tests/run_tests.py" + ] + }, + { + "name": "Blender: Run Specific Test File", + "type": "debugpy", + "request": "launch", + "program": "blender", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "args": [ + "--background", + "--factory-startup", + "--python-exit-code", "1", + "-noaudio", + "-q", + "--python", + "${file}" + ] + }, + { + "name": "Blender: Debug Current Script", + "type": "debugpy", + "request": "launch", + "program": "blender", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "args": [ + "--background", + "--factory-startup", + "--python", + "${file}" + ] + }, + { + "name": "Attach to Running Process", + "type": "debugpy", + "request": "attach", + "processId": "${command:pickProcess}", + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/3mfImportExport.png b/3mfImportExport.png new file mode 100644 index 0000000..b8062e5 Binary files /dev/null and b/3mfImportExport.png differ diff --git a/3mfImportExportScreenshot.png b/3mfImportExportScreenshot.png new file mode 100644 index 0000000..be73571 Binary files /dev/null and b/3mfImportExportScreenshot.png differ diff --git a/3mfImportExportThumbnail.png b/3mfImportExportThumbnail.png new file mode 100644 index 0000000..8bebca0 Binary files /dev/null and b/3mfImportExportThumbnail.png differ diff --git a/API.md b/API.md new file mode 100644 index 0000000..f972b2c --- /dev/null +++ b/API.md @@ -0,0 +1,27 @@ +# Public API Documentation + +Full documentation lives in [docs/site/](docs/site/index.html) — open `docs/site/index.html` in a browser. + +If you change the API or docstrings, rebuild with: + +```powershell +docs/build.ps1 # or: docs/build.ps1 -Clean +``` + +The docs include: + +- **Getting Started** — overview, quick start, export format reference, modifier parts, callbacks, CLI usage +- **Recipes** — ready-to-use patterns (import, export, round-trip, batch, inspect) +- **Core API Reference** — auto-generated parameter docs for `import_3mf`, `export_3mf`, `inspect_3mf`, and batch helpers +- **API Discovery** — version checking, capability detection, standalone helper module +- **Building Blocks** — colors, units, types, segmentation, extensions, metadata + +### Quick example + +```python +from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + +result = import_3mf("model.3mf") +result = export_3mf("output.3mf", use_selection=True) +info = inspect_3mf("model.3mf") +``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d5db8a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,880 @@ +2.6.0 — FullSpectrum Round-Trip & MMU Mix Colors +==== + +Features +---- +* **FullSpectrum 3MF import** — Full import support for OrcaSlicer-FullSpectrum files, with part hierarchy reconstruction, per-part extruder inheritance, and virtual mixed filament slots in the MMU Paint panel. +* **FullSpectrum export** — Correct parts-mode export: per-part `extruder=N` metadata, `mixed_filament_definitions` retained, only physical colors in `filament_colour`. +* **Mix Colors inline form** — The `+` button in the Mix Colors panel now opens an inline form instead of a popup menu. Choose a mode — **Target Color** (auto-finds the closest blend), **Gradient** (set components and mix ratio), or **Pattern** (set a repeating layer sequence) — then click **Add**. +* **Bake panel mixed list** — The Mixed Filaments section in the Shader Editor bake panel is now hidden by default in a collapsible section. +* **Bake confirmation shows mixed filaments** — The bake confirmation popup now shows your mixed filament colors alongside the physical palette so you can confirm they will be included. + +Bug Fixes +---- +* **PrusaSlicer "Multi-part object detected" dialog (Issue #24)** — Blender exports with multiple objects no longer trigger the dialog in PrusaSlicer. All mesh objects are now combined into a single mesh (with world transforms baked into vertex coordinates) and written as one ``. The `Slic3r_PE_model.config` is updated to match, with one `` per original object, each carrying the correct extruder assignment. +* **Diffuse BSDF and other shader types not recognized** — `material_to_hex_color()` previously only read color from Principled BSDF nodes, so materials using Diffuse BSDF, Glossy, Emission, Glass, Toon, Velvet, and others were all treated as the same default gray. Colors are now read from any recognized shader node type, with the node closest to the Material Output taking priority. +* **Mixed filaments included in bake quantization** — Baking and re-quantizing now use the full palette including mixed virtual slots, so painted mixed-color regions are no longer snapped back to the nearest physical color. +* **Filament add/remove in paint panel** — Adding a filament now immediately appears in the list; removing one no longer corrupts the virtual slot order. + +Performance +---- +* **Faster multi-component import** — External `.model` references are cached so assemblies that share component sources only parse them once. +* **Faster MMU paint decode** — Single-color segmentation strings use an O(1) fast path. +* **Seam/Support UV reuse** — UV layout is computed once for the Color layer and reused for Seam and Support layers. + +---- + +2.5.1 — Rating Nudge +==== + +Features +---- +* **Rating nudge popup** — After every 5 successful exports, a popup asks if you'd like to rate the addon on the Blender Extensions marketplace. Options: **Rate it ★** (opens the Extensions page and dismisses permanently), **Remind me after 5 more exports** (snoozes), or **Don't ask again** (dismisses permanently). The prompt count persists across Blender sessions and addon updates. These reminders help support continued development and are much appreciated if you find the addon useful! It also helps others discover the addon when browsing the marketplace. + +---- + +2.5.0 — Modifier Parts & Slicer Settings Passthrough +==== + +Features +---- +* **Modifier parts support (Issue #27)** — Import and export Orca Slicer / BambuStudio modifier meshes. All five part subtypes are supported: `normal_part`, `modifier_part`, `support_enforcer`, `support_blocker`, and `negative_part`. Part subtypes are stored as the `3mf_part_subtype` custom property on Blender Objects and round-trip through `model_settings.config`. +* **Part Type dropdown** — New inline dropdown in the 3MF Metadata sidebar panel to assign part subtypes in Object mode. Non-normal parts get viewport materials matching Orca Slicer's color scheme. +* **Per-object slicer settings passthrough** — Custom slicer setting overrides on `` and `` elements in `model_settings.config` (e.g. `wall_loops`, `sparse_infill_density`) are now preserved through import/export round-trips. Settings are stored as JSON in `3mf_orca_settings` and `3mf_orca_wrapper_settings` custom properties. +* **Parent-child hierarchy preservation** — Multi-part assemblies (modifier groups) create an Empty parent on import, preserving the grouped structure for round-trip export. + +Bug Fixes +---- +* **Object names preserved on import** — Imported objects were all named "3MF Object" instead of using the actual name from the file. Names are now resolved from `model_settings.config` part names (Orca/BambuStudio) or the 3MF `` `name` attribute, fixing round-trip name loss and dictionary collisions during grouped export. + +API +---- +* **API version bumped to 1.2.0** — new `"modifier_parts"` capability. +* `inspect_3mf()` now returns `part_subtypes` — a list of dicts with `part_id`, `subtype`, and `name` for each non-normal part. + +---- + +2.4.4 — Disabled Object Export & Thumbnail Fixes +==== + +Bug Fixes +---- +* **Disabled objects lost colors during export** — `collect_face_colors()` hardcoded `export_hidden=True` but never passed `include_disabled`, so render-disabled objects were silently excluded from color collection even when "Include Disabled" was enabled. This caused "No face colors detected" warnings and missing/randomly-swapped material colors. All internal `collect_mesh_objects()` wrappers (`collect_face_colors`, `detect_linked_duplicates`) now accept and forward `export_hidden`/`include_disabled` from the export context. +* **API `objects=` parameter ignored visibility settings** — `export_3mf(objects=[...])` accepted an explicit object list but downstream exporters still filtered by viewport visibility and render-disable state, silently dropping valid objects. The API now forces `export_hidden=True` and `include_disabled=True` when an explicit object list is provided. +* **API non-manifold check skipped disabled objects** — The pre-export geometry validation in `api.py` hardcoded `export_hidden=True` without `include_disabled`, so disabled objects bypassed non-manifold warnings. Now uses the resolved export options consistently. +* **Component detection missed disabled objects** — `detect_linked_duplicates()` hardcoded `export_hidden=True` without `include_disabled`, so disabled linked duplicates were excluded from component instancing in the StandardExporter. Now forwards the export context visibility settings. +* **Windows Explorer thumbnails not showing** — The OPC `[Content_Types].xml` mapped all `.png` files to the 3MF texture content type (`application/vnd.ms-package.3dmanufacturing-3dmodeltexture`), including `Metadata/thumbnail.png`. Windows Explorer's OPC thumbnail handler requires `image/png`. An explicit `` for the thumbnail part is now written when the `.png` default isn't `image/png`. + +API +---- +* **API version bumped to 1.1.1** (patch — no surface changes). + +---- + +2.4.3 — Flatten Hierarchy & Spec Compliance +==== + +Features +---- +* **Flatten Hierarchy export option** — New `flatten_hierarchy` option (disabled by default) writes child meshes parented to an Empty directly as top-level build items instead of wrapping them in `` containers. Some printing services (e.g. JLC3DP) reject files using 3MF component references; enable this to maximize compatibility. Available in the operator UI, export presets, and the public API. + +Bug Fixes +---- +* **Skip faceless mesh objects** — Mesh objects with vertices but no faces (e.g. armature bone widget shapes like `WGT-rig_*`) were exported as empty `` elements with no `` child, violating the 3MF Core Spec which requires every object to have either a mesh or components. Printing services rejected these files. Such objects are now filtered out during export with a warning. +* **Invalid object cleanup** — Objects that produce no triangles after modifier evaluation (e.g. edge-only meshes, failed `to_mesh()`) no longer leave orphaned `` elements in the 3MF XML. The exporter removes them and skips the corresponding build item or component reference. +* **Namespace prefix cleanup** — Materials Extension namespace was serialised as `ns1:` instead of the standard `m:` prefix. Now correctly registered before XML output. +* **Model root attributes** — Added `unit="millimeter"` and `xml:lang="en-US"` to the model root element for spec compliance. +* **Core properties duplicate xmlns** — `core.xml` had duplicate `xmlns:dc` and `xmlns:dcterms` declarations. Removed duplicates. + +---- + +2.4.2 — Texture Export Spec Compliance +==== + +Bug Fixes +---- +* **OPC content type for textures** — `[Content_Types].xml` was hardcoding `.png` as `image/png`, overriding the 3MF-spec-required `application/vnd.ms-package.3dmanufacturing-3dmodeltexture`. Services like JLC3DP that validate OPC content types rejected these files. The fallback is now only applied when no more specific content type was recorded during import. +* **Texture path preservation** — Exported textures were always written to `3D/Texture/` regardless of the original archive path. Round-trip exports now preserve the original texture path (e.g. `3D/Textures/`) when available, keeping model XML references, relationships, and content types consistent. +* **Relationship file overwriting** — `write_texture_relationships()` was called separately for PBR, standard, and passthrough texture pipelines, each overwriting the previous `3D/_rels/3dmodel.model.rels`. All texture relationships are now accumulated and written once. +* **Rels namespace prefix** — Texture relationship XML was serialised with `ns0:` prefixed elements instead of a clean `xmlns=` default namespace declaration, which some strict OPC parsers rejected. + +Tests +---- +* **New `test_texture_rels.py`** — 11 unit tests covering `write_texture_relationships()`: namespace correctness, no `ns0:` prefix, relationship count/type/target/id, empty dict edge case, and global namespace registry safety. +* **New `TestWriteContentTypes`** — 4 unit tests in `test_annotations.py` verifying `write_content_types()` preserves 3MF texture OPC types and falls back correctly. +* **New `test_sphere_logo_opc_texture_structure`** — Integration test verifying full round-trip OPC packaging of `sphere_logo.3mf`: content types, texture files, rels structure, and target validity. + +2.4.1 — Minor Export UX Improvement +==== +Changes +---- +* **Skip Disabled Renamed** — The "Skip Disabled Objects" export option has been renamed to "Include Disabled" to match "Include Hidden" and align the options together. Now both options are "Opt in to export disabled objects". The underlying logic is unchanged — disabled objects are still excluded by default, and enabling this option includes them in the export. The api remains unchanged to avoid breaking existing scripts. + +Bug Fixes +---- +* **Prusa paint segmentation export fixed** — Prusa-flavour PAINT export had regressed, producing files where shared materials were not mapped to the correct extruder. Fixed so each object with shared materials is properly assigned to a shared extruder when opened in PrusaSlicer. + +---- + +2.4.0 — Adaptive Export Subdivision, OKLab Color Detection & Bake Improvements +==== + +Features +---- +* **Adaptive pre-subdivision for PAINT exports** — New `subdivide_mesh_for_segmentation()` detects mesh faces whose UV footprint exceeds the segmentation resolution budget (`4^depth × 4` pixels) and iteratively splits them via bmesh before encoding. Operates on the temporary `to_mesh()` copy so original scene geometry is untouched. Integrated into both Orca and Standard export paths. +* **OKLab k-means color detection** — Image texture color extraction completely rewritten. Uses k-means++ clustering in OKLab perceptual color space with spatially balanced sampling (8³ sRGB grid, 200 max per cell) for diverse, accurate palette extraction from photographic textures. +* **Skip Dissolve option** — New `skip_dissolve` checkbox in the bake panel and MMU initialization. When enabled, Limited Dissolve is skipped to preserve original mesh topology (useful for models where the N-gon merging is undesirable). +* **Quantize settings in panels** — `quantize_method`, `region_similarity`, and `min_region_size` now visible in the shader editor panel and 3D viewport panel. +* **Improved Bake to MMU UI** — Popup dialog and sidebar panel redesigned with grouped boxes (Source Material, Filament Palette, Bake Settings, Quantization, Options), section icons, vertex color fast-path callout, and alert-styled warning. Sidebar quantize state also reorganised into distinct header and tool sections. + +Bug Fixes +---- +* **UV active_render fix during bake** — `_ensure_uv_unwrap()` previously set MMU_Paint as `active_render` before baking, causing source textures to sample from the wrong UV layer. Now keeps the original UV as `active_render` during bake and switches after. +* **Quantization brightness weight** — `_hue_aware_distance` had `W_V=0.05` (near-zero brightness weight), causing dark navy to match the same filament as bright sky blue. Raised to `W_V=2.0`. + +Performance +---- +* **Vectorized region merge** — `_merge_small_regions()` replaced Python for-loop over border pairs with numpy filter/sort/unique operations. +* **LUT-based palette application** — Steps 4+5 of `_quantize_by_regions()` replaced per-region Python loops (19K iterations × 67M pixels at 8192²) with two numpy LUT lookups. +* **`_rebuild_region_palette`** — Returns numpy LUT array instead of dict for O(1) vectorized indexing. + +Technical +---- +* **`bake.py` split into focused modules** — Monolithic 2770-line `bake.py` refactored into `paint/quantize.py` (pixel/region quantization pipeline), `paint/vertex_colors.py` (vertex colour detection, rasterisation, face assignment), and a slimmer `bake.py` (operators, panels, UV/texture helpers, registration). All public symbols re-exported for backward compatibility. +* `BaseExporter._find_paint_texture()` static helper for quick paint texture lookup. +* `texture_to_segmentation()` accepts optional `mesh` parameter for pre-subdivided meshes. +* `_extract_auxiliary_segmentation()` accepts `subdivided_mesh` kwarg so seam/support segmentation uses the same subdivided mesh. +* OKLab conversion helpers: `_srgb_to_linear_array()`, `_srgb_to_oklab()`, `_oklab_to_srgb()` with M1/M2 matrix transforms. +* `_spatially_balanced_sample()` and `_kmeans_pp_init()` / `_kmeans()` for robust clustering. +* `_select_diverse_from_centers()` with fourth-root frequency weighting for palette diversity. + +--- + +2.3.0 — Region-Aware Quantization, Vertex Color Fast Path & API Discovery +==== + +Features +---- +* **Region-based quantization** — New quantization method segments the baked texture into edge-aware regions before snapping to filament colours. Handles shadows and gradients far more accurately than per-pixel matching. +* **Vertex color fast path** — Materials driven by Color Attribute / Vertex Color nodes skip Cycles entirely. Vectorised per-face filament assignment handles 350K+ faces in milliseconds. +* **UV island awareness** — Region segmentation respects UV island boundaries, preventing colour bleed across unrelated mesh parts. +* **UV bleed (dilation)** — 4-pixel bleed border around every UV island prevents visible seams on the 3D model. +* **Adaptive bake dialog** — Bake dialog detects vertex colour sources and shows a streamlined panel, hiding irrelevant quantization settings. +* **Multi-group assembly export** — Parent Empties with mesh children export as separate plate items in Orca Slicer with per-group extruder tracking. +* **API discovery & registry** — Layered addon discovery with `driver_namespace` preference, `addon_utils` auto-resolve, and direct-import fallback. Expanded capability flags and standalone `threemf_discovery.py` helper with caching. + +Bug Fixes +---- +* **Limited Dissolve skipped for vertex colours** — Fast path no longer runs Limited Dissolve before UV unwrap, which was slow on high-poly meshes and destructive to vertex colour data. +* **Depsgraph handler cleanup** — Safely checks handler existence before removal during unregistration. + +Technical +---- +* New region pipeline: `_flood_fill_segmentation`, `_build_palette_regions`, `_merge_small_regions`, `_rebuild_region_palette`, `_compute_gradient_magnitude`. +* Vertex colour utilities: `_detect_vertex_color_source`, `_compute_face_filaments`, `_rasterize_face_colors`. +* Neighbourhood brightness context in `_hue_aware_distance` for achromatic pixel matching. +* Quantization settings moved from sidebar N-panel to the bake operator's popup dialog. + +--- + +2.2.2 — Export Dispatch Fix, Skip Disabled Objects & Bake Multi-Slot +==== + +Bug Fixes +---- +* **AUTO mode paint export fixed** — Exporting with Auto mode correctly detected MMU paint textures and showed "MMU paint data detected" in the dialog, but the output file contained no paint data. The internal mode flag wasn't promoted to PAINT before handing off to the slicer exporter, so paint color collection and segmentation were both skipped. +* **Bake-to-MMU multi-slot fix** — Bake-to-MMU only prepared the first material slot for baking. Objects with 2+ material slots would get correct colors for slot 0 but incorrect or missing colors for the rest. +* **API dispatch mirrored** — `api.export_3mf()` in AUTO mode had the same missing paint-detection logic as the operator. Now matches the operator behavior. + +Features +---- +* **Multi-group assembly export** — Multiple parent Empties now export as separate plate items in Orca Slicer. Each Empty with mesh children becomes an independent assembly that can be positioned separately in the slicer. Colors/extruders are tracked per-group, with each group using its dominant part color. +* **Three-way export mode** — `Material Export Mode` replaces the old two-state toggle with Auto (detect and dispatch), Standard 3MF (always spec-compliant with basematerials/textures/components), and Paint Segmentation (explicit hash segmentation for multi-material printing). +* **Skip disabled objects** — New export option (default on). Objects with the render-disable camera icon are excluded from export, alongside the existing viewport/collection visibility check. +* **API versioning & discovery** — Other addons can discover and feature-check the 3MF API at runtime via `bpy.app.driver_namespace["io_mesh_3mf"]`. Includes version tuple, capability flags, and a standalone `threemf_discovery.py` helper module. + +Technical +---- +* `_select_exporter()` extracted as a standalone function for testable dispatch logic. +* `ExportOptions` gains `skip_disabled: bool` and `use_orca_format` default changed to `"AUTO"`. +* `collect_mesh_objects()` gains `skip_disabled` parameter. +* `api.py` registers `API_VERSION = (1, 0, 0)` and 12 capability flags in `driver_namespace`. + +--- + +2.2.1 — Seam & Support Paint Layers, Grouped Assembly Export +==== + +Features +---- +* **Seam & support paint round-trip** — Full import/export of `paint_seam` and `paint_supports` per-triangle attributes (Orca Slicer / BambuStudio). Uses the same hex segmentation codec as color paint with a 2-state palette: enforce (state 1) and block (state 2). Each layer gets a dedicated UV texture and image. Auxiliary data preserved through import → edit → export cycles. +* **MMU Paint layer switcher** — Layer selector (Color / Seam / Support) with per-layer Initialize buttons and Enforce/Block brush mode toggles. +* **Grouped assembly export** — Orca exporter detects parent EMPTYs and writes grouped assemblies with a single wrapper/build item. Bed-offset logic adjusted for grouped exports. +* **Single-material Orca export** — Material detection changed from "multi-material only" to "any material present," so single-color objects now get per-triangle `pid`/`p1` attributes that slicers can read. + +Bug Fixes +---- +* **Dominant color per part** — Fixed incorrect extruder assignments in multi-object Orca exports by picking the correct per-part dominant color from the vertex color mapping. + +Technical +---- +* Segmentation rendering extended to accept custom UV layer names, image names, and default color overrides. +* `ResourceObject` stores `paint_seam` / `paint_supports` for round-trip preservation. +* Cura MMU data research added to ROADMAP (low priority). + +--- + +2.2.0 — Metadata Panel, Slicer Profiles, Triangle Sets & More +==== + +Features +---- +* **Triangle Sets ↔ Sculpt Face Sets** — 3MF Triangle Sets now map bidirectionally to Blender's native sculpt face sets. New `VIEW3D > 3MF > Triangle Sets` sidebar panel (Sculpt mode) allows assigning human-readable names to face set IDs, which round-trip through 3MF export/import. Imported sets populate `.sculpt_face_set` for immediate Sculpt mode visibility. +* **3MF Metadata Panel** — New `VIEW3D > 3MF > Metadata` sidebar panel to view and edit 3MF metadata (Title, Designer, Description, Copyright, LicenseTerms, etc.). Shows per-object metadata, triangle set names and face counts, and stashed slicer config indicators. +* **Slicer profile management** — Load slicer settings from any 3MF file and save as named profiles. Profiles persist across sessions/updates. Export dialog includes a profile picker filtered by slicer format (Orca/Bambu vs PrusaSlicer/SuperSlicer) to embed printer/filament configurations in the exported 3MF, with automatic fallback through stashed config → selected profile → built-in template. Slicer configs are preserved and round-tripped on re-export. +* **Export presets & compression** — Save and load named export configurations via the preset dropdown. Adjustable compression level (0–9) in the export dialog, preferences, and public API (default 3). Preferences reorganized into three tabs: Export, Import, and Advanced (slicer profile management). +* **Smooth by Angle on import** — New import option to apply Blender's *Smooth by Angle* modifier with an adjustable angle threshold (default 30°). Configurable in the import dialog and addon preferences. +* **Seam & Support paint layers** — Full round-trip import/export of `paint_seam` and `paint_supports` 3MF attributes (Orca Slicer / BambuStudio). Uses the same hex segmentation codec as color paint with a 2-state palette: enforce (state 1) and block (state 2). Each layer gets a dedicated UV texture and image. The MMU Paint panel gains a layer switcher (Color / Seam / Support) with Initialize buttons for new layers and Enforce/Block brush mode toggles. + +Technical +---- +* New `slicer_profiles/` package with file-based CRUD, config extraction, and machine name parsing. +* Slicer configs stored in Blender text blocks (Base85 encoded) and stashed on import for round-trip preservation. +* `ExportOptions` gains `slicer_profile` field; Orca and Prusa exporters check profile as Priority 3 fallback. +* `paint/panel.py` split into focused submodules; new `panels/` package for non-paint sidebar panels. + +--- + +2.1.0 — Bake to MMU & Auto-Detect Colors +==== + +Features +---- +* **Auto-detect colors from textures & vertex colors** — "Detect from Materials" now analyzes Image Textures and Color Attributes, not just shader node trees. Prompts for how many colors to extract (2-16). +* **HSV-based color detection** — Bins pixels by hue x saturation (ignoring brightness) so shadow/highlight variants merge into one palette entry. Picks natural-looking representatives using 75th-percentile brightness. +* **Hue-aware quantization** — Bake-to-MMU uses weighted HSV distance so shadowed regions match the correct hue rather than snapping to black. +* **Custom thumbnail generation** — Export dialog now has a Thumbnail section with three modes: Automatic (renders a clean elevated 3/4 view with grid/gizmos/overlays hidden), Custom Image (embed any image file), or None (skip thumbnail). Resolution is configurable (64-1024 px). Camera automatically frames all exported objects. + +--- + +2.0.2 — Multi-Material Auto-Detection & Bed Center Offset +==== + +Bug Fixes +---- +* **Multi-material export regression** — Objects with multiple materials now correctly export per-face color data using the Orca format (`paint_color` attributes). A previous simplification from 3 export modes to 2 broke the export, causing multi-material objects to silently export as standard basematerials that slicers ignored. +* **Geometry Nodes material slot detection** — Material slots created by Geometry Nodes `Set Material` nodes are now detected via depsgraph evaluation. Previously, only the original (unevaluated) object was checked, so GN-assigned materials were invisible to the exporter. +* **Evaluated object material lookups** — `get_triangle_color()` and `collect_face_colors()` now use the evaluated object's material slots when mesh modifiers are applied, matching the evaluated mesh data. + +Features +---- +* **Bed center offset for built-in template** — The Orca exporter auto-offsets object positions to the bed center (128, 128 mm) when using the built-in Bambu Lab A1 template (bottom-left origin). Custom templates passed via `project_template_path` get no offset — the caller needs to handle positioning themselves. +* **Updated built-in project template** — Replaced `orca_project_template.json` with a Bambu Lab A1 profile (256×256 mm bed) that eliminates warning popups on import into Orca Slicer. + +Technical +---- +* Export dispatch now evaluates objects via `context.evaluated_depsgraph_get()` to detect multi-material assignments before choosing StandardExporter vs OrcaExporter. +* `ExportOptions.use_orca_format` default changed from `"BASEMATERIAL"` to `"STANDARD"`. +* Removed all `BASEMATERIAL` references from API and documentation. + +--- + +2.0.1 — Orca Per-Object Settings & Triangle Set Safety +==== + +API +---- +* **Custom Orca project templates** — `export_3mf()` accepts a `project_template` path to replace the built-in `orca_project_template.json` with any printer/filament profile JSON. Falls back to built-in on missing/invalid file. +* **Per-object Orca setting overrides** — New `object_settings` parameter on `export_3mf()` writes per-object `` entries in `model_settings.config`, matching Orca Slicer's native per-object settings format. + +Bug Fixes +---- +* **Triangle set topology guard** — Record original face count on import (`3mf_original_face_count`). Export skips triangle sets with a warning when topology has changed (e.g. faces dissolved), preventing corrupt triangle indices. + +Technical +---- +* Changed compression level for ZIP archives to 3 (from default 9) to reduce export times on large files. This provides a good balance between file size and export speed, especially for complex models with many textures. + +--- + +2.0.0 — Architecture Restructure & Public API +==== + +This is a major restructure of the entire codebase. The monolithic `import_3mf.py` (3055 lines, 56 methods) and `export_3mf.py` have been broken into clean, focused sub-packages. A new public API enables programmatic 3MF workflows without `bpy.ops`. All import/export functionality is unchanged — this is a code organization and robustness release. + +> **Breaking:** Scripts that imported internal symbols directly from the old module layout will need updating. The `bpy.ops.import_mesh.threemf()` and `bpy.ops.export_mesh.threemf()` operators are unchanged. + +Architecture +---- +* **`import_3mf/` package** — Operator, context, archive, geometry, builder, scene, slicer detection, and materials sub-package +* **`export_3mf/` package** — Operator, context, archive, geometry, standard/orca/prusa exporters, components, thumbnail, segmentation, and materials sub-package +* **`common/` package** — Shared types, constants, colors, logging, XML utilities, units, segmentation codec, extensions, metadata, annotations +* **`paint/` package** — MMU Paint Suite panel (`paint/panel.py`) and bake operator (`paint/bake.py`) extracted from monolithic `paint_panel.py` +* **Context dataclasses** — `ImportContext` / `ExportContext` replace mutable operator state. Every helper takes `ctx` as its first argument. +* **Export dispatch** — `StandardExporter`, `OrcaExporter`, `PrusaExporter` classes replace monolithic if/else chains + +Features +---- +* **Lightmap UV option for MMU Paint** — Add `paint_uv_method` ("SMART"|"LIGHTMAP") to import operator, API, and paint panel. Smart UV Project (default) shares edges between faces for hand-painting; Lightmap Pack isolates faces for procedural bakes with higher fidelity. +* **Texture size override** — Add `paint_texture_size` (Auto/1024–16384) to import operator and API. Allows manual control over paint texture resolution instead of auto-sizing by triangle count. +* **Paint subdivision depth control** — Add `subdivision_depth` (4–10, default 7) to export preferences, operator, API, and `ExportOptions`. Tunes segmentation tree depth for balance between detail and export time. +* **Nested EMPTY support** — Export now recursively collects mesh objects from nested EMPTY hierarchies (respecting `export_hidden`). Meshes parented under EMPTYs are exported and validated correctly. +* **Material color detection** — New "Detect from Materials" button in Shader Editor sidebar scans Principled BSDF node trees for colors. Reads Color Ramp stops, RGB nodes, viewport colors, and populates filament palette automatically. +* **Cycles bake optimization** — Bake-to-MMU workflow uses GPU compute (when available), 1 sample, and temporary Emission shader wiring for 5–10× faster flat/procedural color bakes. +* **Merged Standard/BaseMaterial export modes** — The separate "Standard 3MF" (geometry only) and "Base Material" modes are unified into a single "Standard 3MF" mode that automatically includes material colors when present. + +Public API (`api.py`) +---- +* **`import_3mf()`** — Headless import with all operator options as keyword arguments +* **`export_3mf()`** — Headless export with explicit object lists, format selection, callbacks +* **`inspect_3mf()`** — Read-only archive inspection without creating Blender objects (returns object counts, materials, textures, metadata, extensions, vendor format) +* **`batch_import()` / `batch_export()`** — Multi-file operations with per-file error isolation +* **Callbacks** — `on_progress`, `on_warning`, `on_object_created` for monitoring and integration +* **Building blocks** — Re-exports `colors`, `types`, `segmentation`, `units`, `extensions`, `xml_tools`, `metadata`, `components` sub-namespaces +* See **[API.md](API.md)** for full documentation and examples + +Bug Fixes +---- +* **Large colorgroup blocking** — 3MF files with 50,000+ colors in a single colorgroup (e.g. 3D scans with per-vertex coloring) no longer hang Blender. Groups exceeding 1,000 entries are skipped with a user-facing warning. +* **Menu duplicate fix** — Reinstalling the addon via drag-and-drop no longer causes duplicate Import/Export menu entries or `list.remove(x): x not in list` errors. Menu cleanup now matches by function identity rather than object reference. +* **Multiproperties per-vertex UV fix** — Multiproperties with multiple texture2dgroups now correctly resolve per-vertex UV coordinates from `p1`/`p2`/`p3` indices independently, instead of assigning the same UV to all three vertices. +* **PBR texture UV round-trip** — PBR textured materials (roughness, metallic, etc.) now export with proper `texture2dgroup` / `tex2coord` UV coordinate data alongside `pbmetallictexturedisplayproperties`. Previously, PBR materials were excluded from texture2dgroup creation, losing UV data on export. +* **Import texture coordinate source** — Textured materials imported from `texture2dgroup` data now use UV coordinates (not Generated), since a UV layer is guaranteed from the `tex2coord` import. PBR textures on meshes without UV data still fall back to Generated projection. +* **PBR base color deduplication** — When both `setup_textured_material` and `apply_pbr_textures_to_material` run on the same material, the base color texture is no longer wired twice. The PBR function detects and skips already-connected Base Color inputs. +* **Texture archive deduplication** — Texture images already written to the archive by the PBR pass are detected and reused instead of being written a second time. +* **Paint import crash fix** — Fixed `TypeError: render_segmentation_to_texture() got an unexpected keyword argument 'bpy'` caused by a leftover `bpy=bpy` kwarg from the restructure (parameter is named `bpy_module`). +* **Triangle indexing fix** — Segmentation export now correctly uses `mesh.loop_triangles` indices (`tri_idx`) instead of polygon indices, fixing incorrect segmentation mappings for meshes with quads/ngons. +* **Initialize undo fix** — MMU Paint initialization now pushes a single undo step before all operations, so Ctrl+Z restores the complete pre-initialization state instead of fragmenting across mode switches and UV unwraps. + +Technical +---- +* All custom logging uses `common/logging.py` (`debug`, `warn`, `error`, `safe_report`) — no Python `logging` module +* All tests run in real Blender headless mode (no mocking of Blender APIs) +* Test suite: 150 unit tests + 128 integration tests, all passing +* Build: `ThreeMF_io-2.0.0.zip` via `blender --command extension build` +* **Dedicated MMU_Paint UV layer** — Paint initialization and import create/use a dedicated "MMU_Paint" UV layer for segmentation data, keeping painted UVs separate from material texture UVs. +* **Limited Dissolve pre-unwrap** — Bake and initialize paths run `bmesh.ops.dissolve_limit` (~2° tolerance) before UV unwrap to merge coplanar triangles, giving faces more UV space and reducing blurriness in painted regions. +* **Segmentation rasterizer improvements** — Add `expand_px` support for per-edge normalized threshold expansion (eliminates 1-pixel gaps at Lightmap Pack UV island boundaries). UV-method-aware gap closing: 2 dilation passes for Smart UV, 6 passes for Lightmap Pack. +* **Component EMPTY recursion** — Component child handling now recurses into EMPTY hierarchies and only exports MESH/EMPTY children when assembling 3MF component definitions. +* **Mesh collection helper** — New `collect_mesh_objects()` recursively gathers MESH objects from scene collections and nested EMPTY hierarchies. Replaces ad-hoc mesh filtering across operator, materials, and exporter modules. + +--- + +1.4.0 — MMU Paint Import/Export Support w/ 3MF Paint panel +==== + +Features +---- +* **MMU Paint Import/Export:** Full support for importing and exporting multi-material paint data for Orca Slicer and PrusaSlicer, enabling round-trip workflows between Blender and Orca/Prusa for multi-filament prints. +* **MMU Paint Suite:** New panel in the N-panel `3MF`. Allows users to initialize multi-material painting on any mesh, manage filament colors, and paint per-triangle filament assignments using Blender's native texture paint tools. +* **Paint import dialog:** Instructions for finding MMU Paint Suite panel after import, and a button to switch automatically. + +Bug Fixes +---- +* **Fixed critical color space mismatch:** 3MF colors are sRGB, Blender materials are linear — now properly converting between them on import, export, and painting +* **Improved filament operations:** Filament removal and reassignment now provide feedback on pixel counts +* **Fixed PBR and Normal material import issues:** Materials with PBR textures now import correctly without creating duplicate materials or losing texture relationships + +Technical +---- +* Added sRGB ↔ linear color space conversion utilities (`srgb_to_linear()`, `linear_to_srgb()`, `hex_to_linear_rgb()`, `linear_rgb_to_hex()`) +* Applied conversions throughout: import materials (sRGB → linear), export materials (linear → sRGB), paint panel (all color operations) + +--- + +1.3.2 — Component Instances +==== +Features +---- +* Linked duplicates (Alt+D) export as 3MF component references (shared mesh, smaller file size) +* Import restores linked duplicates +* Quick options popup for drag-drop + +Technical +---- +* Component definitions stored in `` with full mesh data +* Instance containers use `` with `` references +* Transforms applied at build item level +* Compatible with all material types (basematerials, textures, PBR, Orca color zones) + +1.3.1 — Drag & Drop Import +==== +Features +---- +* Drag & drop .3mf files directly into Blenders viewport +* Grid Layout: arrange imported objects in a grid +* Origin Placement: choose original, center, or bottom center +* Faster Manifold Check: use bmesh module for faster non-manifold detection + +1.3.0 — Textured PBR Materials +==== +Complete implementation of textured PBR display properties from the 3MF Materials Extension v1.2.1, enabling full round-trip support for base color, roughness, and metallic texture maps. + +Features +---- +* **Base Color Textures:** Import/export `basecolortextureid` from `` with proper Principled BSDF node connections +* **Roughness Textures:** Import/export `roughnesstextureid` with automatic Non-Color space assignment +* **Metallic Textures:** Import/export `metallictextureid` with proper shader node setup +* **Specular Workflow Textures:** Import/export `diffusetextureid`, `speculartextureid`, and `glossinesstextureid` +* **Texture Extraction:** Automatic extraction and packing of texture images from 3MF archives + +Bug Fixes +---- +* Fixed material reuse incorrectly matching default Blender materials when PBR textures were present +* Fixed stale texture relationships in passthrough data causing export issues + +Technical +---- +* Implements textured PBR display properties per 3MF Materials Extension v1.2.1 specification +* Texture images packed into blend file for portability +* Texture metadata preserved as custom properties for perfect round-trips +* Note: Normal maps are exported to archive but cannot be imported (3MF spec has no `normaltextureid` attribute). Will re-visit this in the future. + +Code Organization +---- +* **Import/Export Module Refactoring:** Split large monolithic files into extension-centric packages for better maintainability and easier future additions: + - `export_materials/` package: base.py, textures.py, pbr.py, passthrough.py + - `import_materials/` package: base.py, textures.py, pbr.py, passthrough.py + - Standalone `export_trianglesets.py` and `import_trianglesets.py` modules +* Backward-compatible re-exports preserve existing API + +--- + +1.2.7 — Full PBR Materials Extension Support +==== +Complete implementation of all three PBR workflows from the 3MF Materials and Properties Extension, bringing the add-on 1 step closer to full Material Extension support. + +Features +---- +* **Metallic Workflow:** Import/export `` with metallicness and roughness +* **Specular Workflow:** Import/export `` with specular color and glossiness +* **Translucent Workflow:** Import/export `` with IOR, attenuation color, and roughness +* **Transmission Preservation:** Custom `blender_transmission` attribute preserves Blender's transmission value through translucent workflow round-trips + +Technical +---- +* Implements PBR display properties from 3MF Materials Extension specification +* Workflow selection is automatic based on material properties (metallic → specular → translucent priority) +* Note: 3MF PBR workflows are mutually exclusive per spec — materials using multiple workflows will use the highest-priority workflow that applies + +--- + +1.2.6 — Triangle Sets Extension +==== +Full implementation of the 3MF Triangle Sets extension for grouping triangles. + +Features +---- +* **Triangle Sets Import:** Parse `` elements including `` and `` children +* **Triangle Sets Export:** Export triangle sets with name/identifier attributes and `` optimization +* **Selection Workflows:** Triangle sets enable selection grouping for property assignment + +User Interface +---- +* Added "Export Triangle Sets" checkbox to export operator (disabled when Multi-Material is active) +* Added preference setting for default Triangle Sets export behavior +* Triangle sets stored as integer face attributes with mesh custom property for names + +Bug Fixes +---- +* Fixed "please wait" messages not displaying before UI freeze during import/export + +Technical +---- +* Implements Triangle Sets extension from 3MF Core Spec v1.3+ +* Uses Blender 4.x custom face attributes (face maps removed in Blender 4.0) +* Consecutive triangle indices exported as `` to reduce XML size + +--- + +1.2.5 — Quality of Life Improvements +==== +Export validation, smart material reuse, and flexible import placement options. + +Features +---- +* **Non-Manifold Detection:** Pre-export geometry validation warns about problematic meshes that may cause slicer issues +* **Material Reuse:** Import now matches and reuses existing Blender materials by name and color, preventing duplication on re-import +* **Selection Validation:** "Selection Only" export validates mesh objects are selected before proceeding +* **Import Placement Options:** Control object placement (World Origin / 3D Cursor / Keep Original) and origin calculation on import + +Improvements +---- +* All new features have preference defaults and integrate seamlessly into existing UI +* Import origin-to-geometry calculated before transformation for correct positioning + +--- + +1.2.4 — PrusaSlicer MMU Export & Color Preservation +==== +Full round-trip color support for PrusaSlicer MMU workflows and improved user feedback. + +Features +---- +* **PrusaSlicer MMU Export:** Face colors exported with `slic3rpe:mmu_segmentation` attributes and color metadata for perfect round-trips +* **Progress Messages:** Status feedback during import/export operations +* **Color Fidelity:** Fixed color space handling for accurate material preservation across all formats + +Improvements +---- +* Refactored export code following DRY principles (extracted helper methods, merged duplicate code) + +--- + +1.2.3 — 3MF Core Specification v1.4.0 Compliance +==== +Updated to target the latest 3MF Core Specification v1.4.0 (published February 6, 2025). + +Features +---- +* **3MF Core Spec v1.4.0:** Updated to latest specification version +* **Triangle Sets Extension:** Added namespace support for `t:trianglesets` (defined in Core Spec v1.3+) +* **Framework Ready:** Infrastructure prepared for future Triangle Sets import/export features + +Changes +---- +* Updated `SPEC_VERSION` constant from "1.3.0" to "1.4.0" +* Added `TRIANGLE_SETS_NAMESPACE` constant for grouping triangles +* No breaking changes — v1.4.0 primarily removed deprecated "mirror" functionality (which we never implemented) and added documentation clarifications + +--- + +1.2.2 — PrusaSlicer Compatibility & Thumbnails +==== +This extremely small patch simply adds a tag to the blender manifest.toml + +--- + +1.2.1 — PrusaSlicer Compatibility & Thumbnails +==== +This patch adds PrusaSlicer multi-material import support and automatic thumbnail generation. + +Features +---- +* **PrusaSlicer Support:** Import multi-material color zones from PrusaSlicer files (`slic3rpe:mmu_segmentation` attributes) +* **Thumbnails:** Automatic viewport snapshot embedded in exported 3MF files (256×256 PNG) +* **Cross-Slicer Compatibility:** Color zones now work with both PrusaSlicer and Orca Slicer formats + +Bug Fixes +---- +* Fixed PrusaSlicer imports not showing color zones/materials +* Fixed thumbnails not displaying in file browsers due to missing content type declaration + +--- + +1.2.0 — Production Extension & Orca Slicer Compatibility +==== +This release adds full support for the 3MF Production Extension and comprehensive Orca Slicer/BambuStudio compatibility, enabling multi-color workflows between Blender and modern slicers. + +Features +---- +* **Production Extension Support:** + - Full implementation of the 3MF Production Extension (`http://schemas.microsoft.com/3dmanufacturing/production/2015/06`) + - Multi-file export structure with individual object models in `3D/Objects/` + - `p:path` and `p:UUID` component references for external model files + - Proper OPC relationship files (`3D/_rels/3dmodel.model.rels`) + - Import support for external model file references + +* **Orca Slicer / BambuStudio Compatibility:** + - **Export:** New "Orca Slicer Color Zones" option exports face colors as filament zones + - **Export:** Per-triangle `paint_color` attributes for precise filament assignment + - **Export:** Generates `Metadata/project_settings.config` with filament colors + - **Import:** Reads `paint_color` attributes and creates corresponding Blender materials + - **Import:** Extracts actual filament colors from `project_settings.config` + - **Round-trip:** Full color preservation between Blender and Orca Slicer + +* **3MF Core Specification v1.3.0 Compliance:** + - Added `SPEC_VERSION` constant tracking specification version + - Support for `recommendedextensions` attribute (v1.3.0 addition) + - OPC Core Properties with Dublin Core metadata + - Improved extension validation with prefix-to-namespace resolution + +* **Extension Framework:** + - New `extensions.py` module with `ExtensionManager` class + - Extensible architecture for adding future 3MF extensions + - Support for required vs optional extensions + - Human-readable extension names in warnings + +* **Import Options:** + - New "Import Materials" checkbox to control material/color import + - Configurable default via addon preferences + - Automatic vendor format detection (Orca/Bambu/Prusa) + +Technical Improvements +---- +* **Extension Prefix Resolution:** + - Fixed `requiredextensions="p"` validation (was comparing prefix to namespace URI) + - `resolve_extension_prefixes()` maps XML prefixes to full namespace URIs + - Known prefix mappings for Production, Materials, and Slic3r extensions + +* **Color Indexing:** + - 1-based filament indexing matching Orca Slicer's `paint_color` encoding + - Proper mapping between paint codes ("4", "8", "0C", etc.) and filament array indices + - Case-insensitive paint code parsing + +* **Code Organization:** + - Separated Orca export into dedicated methods (`execute_orca_export`, `write_orca_object_model`, etc.) + - Standard export preserved in `execute_standard_export` + - Clean separation of vendor-specific and standard 3MF handling + +Documentation +---- +* Updated README with comprehensive Extensions section +* Added Orca Slicer compatibility documentation +* Round-trip workflow instructions + +--- + +1.1.3 — Unicode String Caching & Garbage Collection Protection +==== +This release adds comprehensive defensive string caching throughout the add-on to protect Unicode characters from Python's garbage collector. This ensures users with non-ASCII characters (Chinese, Japanese, Korean, Arabic, emoji, etc.) in object names, material names, file paths, and metadata will not experience corruption or data loss. + +Features +---- +* **Defensive String Caching:** + - Cache all object names, material names, metadata (names, values, datatypes), and file names before XML or export operations to protect Unicode from garbage collection + - Explicit `str()` conversion ensures strings persist while Blender's UI or export processes are active + +* **Unicode Support Improvements:** + - Full support for all non-standard Unicode characters in object names, material names, metadata, and file path + +Testing +---- +* **New comprehensive Unicode test suite** (`tests/test_unicode.py`): + - 20+ tests covering Chinese, Japanese, Korean, Arabic, emoji, and mixed Unicode + - Tests for object names, material names, metadata, and roundtrip preservation + - Tests for edge cases: RTL text, combining characters, surrogate pairs, very long names +* **Added to mock test suite** (`test/metadata.py`, `test/export_3mf.py`): + - 6 new tests for Unicode metadata compatibility and conflict detection + - 2 new tests for Unicode object and material name caching +* All existing tests continue to pass, ensuring backward compatibility + +1.1.1 - 1.1.2 — Unit Handling, Precision & Preferences +==== +This release refines the modernized addon with improved unit handling, higher-fidelity coordinate export, better object naming, and configurable defaults via addon preferences. + +Features +---- +* **Unit & Scale Handling:** + - Fix export scaling for combinations of scene scale and units (e.g. scale_length = 0.001 with millimeter units), so 3MF files now match Blender dimensions across common unit setups. + - Improve import scaling by correctly interpreting the 3MF model `unit` attribute and converting to Blender scene units. +* **Object Naming & Visibility:** + - Export the Blender object name into the 3MF model, improving round‑trip fidelity and interoperability with slicers. + - Import object names from 3MF back into Blender objects when available. + - Add an `Export hidden objects` option to the export operator so viewport‑hidden objects can be explicitly included or excluded. + - **User notification** when hidden objects are skipped during export, with a count and hint to enable the option. +* **Coordinate Precision & Formatting:** + - Increase default coordinate precision to 9 decimal places to preserve full 32‑bit float resolution and reduce the risk of non‑manifold issues from rounding. + - Standardize transformation matrix formatting to 9 decimal places for consistent, high‑precision output. + +Addon Preferences +---- +* Add `ThreeMFPreferences` (Edit → Preferences → Add-ons → 3MF) to configure default behavior: + - **Default Coordinate Precision** for exports. + - **Export Hidden Objects by Default** toggle. + - **Apply Modifiers by Default** toggle. + - **Default Global Scale** shared by import and export operators. +* Enhanced preferences UI with grouped settings, icons, and helpful tooltips. +* Export/import operators read these preferences on `invoke`, so dialogs open with user‑chosen defaults while still allowing per‑export overrides. + +Testing & Maintenance +---- +* Extend unit tests to cover: + - New unit/scene scale behavior for export and import. + - Hidden‑object export behavior and notification. + - High‑precision transformation formatting. + - Preferences loading in `invoke()` methods. +* Update tests and documentation to reflect the new defaults and options. + +1.1.0 — Modernization for Blender 4.2+ +==== +**IMPORTANT: This version requires Blender 4.2 or newer. For Blender 2.8-4.1, use version 1.0.2.** + +This release modernizes the addon for Blender 4.2+ and Python 3.11+, ensuring compatibility with current and future Blender versions. + +Breaking Changes +---- +* **Minimum Blender version is now 4.2** (previously supported 2.8-4.0) +* **Minimum Python version is now 3.11** (previously 3.7+) +* Installation now uses Blender Extensions format (drag-and-drop .zip or install via Preferences) + +Features +---- +* **Blender Compatibility:** + - Full compatibility with Blender 4.2, 4.3, 4.5, and 5.0 Alpha + - Verified compatibility with all modern Blender APIs: + - `PrincipledBSDFWrapper` for material handling + - `mesh.loop_triangles` for mesh data + - `evaluated_depsgraph_get()` for modifier evaluation +* **User Experience:** + - Import/export status messages now appear in Blender's UI + - Error and warning messages are user-friendly and actionable + - Warning deduplication prevents UI spam with complex files + - Console logs still available for detailed debugging +* **Developer Experience:** + - Comprehensive test suite (142 unit tests + 16 integration tests) + - Cross-platform integration test runners (Windows PowerShell, macOS/Linux Bash) + - Multi-version testing support (test against all installed Blender versions) + - Automated CI/CD testing via GitHub Actions + - Complete type hints for IDE support and type checking + - Clear public API with `__all__` exports + - Updated documentation and contribution guidelines + +Technical Improvements +---- +* **Code Quality:** + - Added comprehensive type hints to all 7 modules (100% coverage) + - Replaced wildcard imports with explicit imports for better code maintainability + - Converted all string concatenation to modern f-strings + - Added `__all__` exports to all modules for clear public API definition + - Removed outdated Python 3.7 references from code comments +* **Operator Improvements:** + - Removed deprecated `__init__()` methods from operators (Blender 4.2+ requirement) + - Fixed state variable initialization in export/import classes + - Added `self.report()` calls for user-visible error/warning/info messages + - Implemented warning deduplication to prevent UI spam on complex files +* **Error Handling:** + - All errors now display in Blender's UI (not just console logs) + - Warnings are deduplicated - each unique issue reported only once + - Detailed logs still available in console for debugging + - Better error messages for malformed 3MF files +* **Build System:** + - Updated manifest format for modern Blender addon structure (blender_manifest.toml) + - Fixed test mock objects for Python 3.11+ compatibility + - Added warning deduplication tracker initialization in tests + +Bug Fixes +---- +* Fixed operator initialization for Blender 4.2+ compatibility +* Fixed material color handling with modern shader node API +* Fixed mesh triangulation with current API patterns +* Corrected depsgraph evaluation for objects with modifiers +* Fixed mock objects in unit tests to support `report()` method +* Added missing `_reported_warnings` initialization in test setup +* Resolved AttributeError issues in CI/CD test runs + +Testing +---- +* **Unit Tests (142 tests, all passing):** + - All original unit tests updated and passing (Python 3.11) + - Mock objects updated for modern operator interface + - Tests verify type hints don't break runtime behavior + - Code style validation with pycodestyle +* **Integration Tests (16 tests, all passing):** + - New integration tests verify real-world Blender functionality + - Test simple and complex geometry export/import + - Verify material round-trip preservation + - Test modifier evaluation + - Confirm selection-only export + - Validate Blender 4.2+ API compatibility +* **Cross-Platform Test Runners:** + - PowerShell script for Windows + - Bash script for macOS/Linux + - Auto-detection of Blender installations + - Multi-version testing support +* **CI/CD:** + - GitHub Actions automatically run all 142 unit tests + - Python 3.11 validation + - Code style checks +* **Real-World Testing:** + - Tested across Blender 4.2 LTS, 4.3, 4.5, and 5.0 Alpha + - Verified export → import round-trip functionality + - Confirmed material preservation through round-trip operations + - Tested with complex multi-object 3MF files (50+ objects) + - Verified warning deduplication with extension-heavy files + +Contributors +---- +* Modernization work by Clonephaze (Jack Smith) +* Original addon by Ghostkeeper + +1.0.2 - Bug Fixes +==== +* Fix support in newer Blender versions, up to 4.0. +* Run tests using Python 3.10. + +1.0.1 - Bug Fixes +==== +* Fix the resource ID of exported materials to be integer. + +1.0.0 - Big Bang +==== +For the first stable release, the full core 3MF specification is implemented. + +Features +---- +* Support for importing materials, and applying them to triangles of your meshes. +* Support for exporting materials from Blender with a diffuse color. +* Metadata is now retained when editing existing 3MF files. +* Relationships are retained when editing existing 3MF files. +* Content types are retained when editing existing 3MF files. +* Added support for the model types "solidsupport", "support" and "surface". +* Support and solidsupport meshes are hidden from any renders. +* 3MF part numbers are retained when editing existing 3MF files. +* Files marked as MustPreserve are retained when editing existing 3MF files. +* PrintTickets are retained when editing existing 3MF files. +* When metadata, relationships and content types clash when loading multiple 3MF files into one scene, the most common denominator is kept. +* Metadata, relationships, content types and part numbers are retained when the scene is shared through a .blend file. +* The object names are now stored in the 3MF files as metadata. +* Content types are now being read out, allowing for any file type to be anywhere in the archive. +* Automated tests improve stability of the add-on. +* Actions are being logged in Blender's log stream. +* If anything goes wrong, errors and warnings are being logged in Blender's log stream. +* The code is now compliant to Blender's code style requirements. +* Added support for new "Adaptive" units in Blender. +* Transformation matrices are written more compactly. +* Vertex coordinates are written more compactly. +* Warn the user if the 3MF document requires 3MF extensions that are not present. +* When exporting, you can now configure the number of decimals to write. +* Material colors are rendered in Blender with a BSDF node, and converted back to sRGB when exporting. +* The exported 3MF archive is now compressed with the Deflate algorithm. +* Allow installation via .zip file. + +Bug Fixes +---- +* No longer crash if faces are provided with negative vertex indices. +* Importing multiple 3MF files in succession no longer allows resource objects of old files to be used by new files. +* Exporting multiple 3MF files in succession resets the resource ID counter every time. +* No longer crash if there are no access rights to files to read or write. +* Fix writing of transformations for resource objects that have components. +* Fix writing transformations if multiple transformed objects are written. +* Resource objects that have components can no longer have mesh data of their own. +* No longer create meshes when an object has no vertices or faces. +* Transformation matrices and vertex coordinates will no longer use scientific notation for big or tiny numbers. + +0.2.0 - Get Out +==== +This is another pre-release where the goal is to implement exporting 3MF files from Blender. + +Features +---- +* A menu item is added to the export menu to export 3D Manufactoring Format files. +* Saving Open Document formatted archives. +* Support for exporting object resources. +* Support for exporting vertices. +* Support for exporting triangles. +* Support for exporting components. +* Support for exporting build items. +* Support for exporting transformations. +* Support for conversion from Blender's units to millimetres. +* You can now scale the models when importing and exporting. + +Bug Fixes +---- +* The unit is now applied after the 3MF file's own transformations, so that models end up in the correct position. + +0.1.0 - Come On In +==== +This is a minimum viable product release where the goal is to reliably import at least the geometry of a 3MF file into Blender. + +Features +---- +* A menu item is added to the import menu to import 3D Manufactoring Format files. +* Opening 3MF archives. +* Support for importing object resources. +* Support for importing vertices. +* Support for importing triangles. +* Support for importing components. +* Support for importing build items. +* Support for transformations on build items and components. +* Transforming the 3MF file units correctly to Blender's units. diff --git a/CHANGES.md b/CHANGES.md deleted file mode 100644 index ef271d5..0000000 --- a/CHANGES.md +++ /dev/null @@ -1,90 +0,0 @@ -1.0.2 - Bug Fixes -==== -* Fix support in newer Blender versions, up to 4.0. -* Run tests using Python 3.10. - -1.0.1 - Bug Fixes -==== -* Fix the resource ID of exported materials to be integer. - -1.0.0 - Big Bang -==== -For the first stable release, the full core 3MF specification is implemented. - -Features ----- -* Support for importing materials, and applying them to triangles of your meshes. -* Support for exporting materials from Blender with a diffuse color. -* Metadata is now retained when editing existing 3MF files. -* Relationships are retained when editing existing 3MF files. -* Content types are retained when editing existing 3MF files. -* Added support for the model types "solidsupport", "support" and "surface". -* Support and solidsupport meshes are hidden from any renders. -* 3MF part numbers are retained when editing existing 3MF files. -* Files marked as MustPreserve are retained when editing existing 3MF files. -* PrintTickets are retained when editing existing 3MF files. -* When metadata, relationships and content types clash when loading multiple 3MF files into one scene, the most common denominator is kept. -* Metadata, relationships, content types and part numbers are retained when the scene is shared through a .blend file. -* The object names are now stored in the 3MF files as metadata. -* Content types are now being read out, allowing for any file type to be anywhere in the archive. -* Automated tests improve stability of the add-on. -* Actions are being logged in Blender's log stream. -* If anything goes wrong, errors and warnings are being logged in Blender's log stream. -* The code is now compliant to Blender's code style requirements. -* Added support for new "Adaptive" units in Blender. -* Transformation matrices are written more compactly. -* Vertex coordinates are written more compactly. -* Warn the user if the 3MF document requires 3MF extensions that are not present. -* When exporting, you can now configure the number of decimals to write. -* Material colors are rendered in Blender with a BSDF node, and converted back to sRGB when exporting. -* The exported 3MF archive is now compressed with the Deflate algorithm. -* Allow installation via .zip file. - -Bug Fixes ----- -* No longer crash if faces are provided with negative vertex indices. -* Importing multiple 3MF files in succession no longer allows resource objects of old files to be used by new files. -* Exporting multiple 3MF files in succession resets the resource ID counter every time. -* No longer crash if there are no access rights to files to read or write. -* Fix writing of transformations for resource objects that have components. -* Fix writing transformations if multiple transformed objects are written. -* Resource objects that have components can no longer have mesh data of their own. -* No longer create meshes when an object has no vertices or faces. -* Transformation matrices and vertex coordinates will no longer use scientific notation for big or tiny numbers. - -0.2.0 - Get Out -==== -This is another pre-release where the goal is to implement exporting 3MF files from Blender. - -Features ----- -* A menu item is added to the export menu to export 3D Manufactoring Format files. -* Saving Open Document formatted archives. -* Support for exporting object resources. -* Support for exporting vertices. -* Support for exporting triangles. -* Support for exporting components. -* Support for exporting build items. -* Support for exporting transformations. -* Support for conversion from Blender's units to millimetres. -* You can now scale the models when importing and exporting. - -Bug Fixes ----- -* The unit is now applied after the 3MF file's own transformations, so that models end up in the correct position. - -0.1.0 - Come On In -==== -This is a minimum viable product release where the goal is to reliably import at least the geometry of a 3MF file into Blender. - -Features ----- -* A menu item is added to the import menu to import 3D Manufactoring Format files. -* Opening 3MF archives. -* Support for importing object resources. -* Support for importing vertices. -* Support for importing triangles. -* Support for importing components. -* Support for importing build items. -* Support for transformations on build items and components. -* Transforming the 3MF file units correctly to Blender's units. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aaf2a43..f45f932 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,32 +1,150 @@ -Contributing -==== -Contributions to this repository are encouraged. This document describes ways in which you can contribute to the development of the add-on. +# Contributing -Development is currently done through [Github](https://github.com/Ghostkeeper/Blender3mfFormat). +Thanks for your interest in contributing to **Blender 3MF Format**. -Bug reports ----- -No software is free of bugs. Not this one either. The 3MF specifications claim to be human-readable and unambiguous but there are some details in the specification that prove otherwise. (Good example: It's not allowed to have an object resource that contains both a mesh and a component.) Likely there will be many tiny details that are wrong by this specification. If you find one, please search through the [existing issues](https://github.com/Ghostkeeper/Blender3mfFormat/issues) to see if someone else already reported it. If so, you can add to that discussion. If not, you can [create a new issue](https://github.com/Ghostkeeper/Blender3mfFormat/issues/new/choose). +This addon provides comprehensive **3MF Core Spec v1.4.0** import/export for Blender 5.0+ (minimum Blender 4.2+), with multi-material paint support for Orca Slicer, BambuStudio, PrusaSlicer, and SuperSlicer. It's a complete modernization of the original add-on (created by Ghostkeeper) with a focus on **correctness, spec compliance, and predictable import/export behavior**. -In the issue report, please provide full reproduction steps, the expected behaviour and the desired behaviour, and if relevant any 3MF files you are loading or have saved. If the 3MF files contain private intellectual property that you wouldn't like to post online, please say so and we can perhaps transfer it via e-mail. +## Where development happens -Feature requests ----- -Requesting features is easy. Again, please search through the [existing issues](https://github.com/Ghostkeeper/Blender3mfFormat/issues) first. If the feature hasn't been requested before, you can [create a new issue](https://github.com/Ghostkeeper/Blender3mfFormat/issues/new/choose). Please describe clearly what you'd like the add-on to do. +Development and issue tracking happens in this repository. -The scope of this add-on is purely to load and save 3MF files. Adjustments to the data should be kept to an absolute minimum when saving or loading. Any transformations to the data are therefore out of scope. +Historical upstream (no longer maintained): -Pull requests ----- -If you'd like to improve this add-on yourself you are free to do so under the constraints of its [license](https://github.com/Ghostkeeper/Blender3mfFormat/blob/master/LICENSE.md). This license is copyleft, requiring you to publish any changes that you distribute. Good practice is also to submit your changes upstream to this repository in the form of a pull request. The maintainer of the add-on will then review the changes. If they are deemed appropriate, they will be included with a next publication of the add-on. +- https://github.com/Ghostkeeper/Blender3mfFormat -These are a couple of things we'd like you to pay attention to when contributing a pull request: -* There is an extensive testing suite. If you modify or add any code, please ensure that the tests still succeed. You can run the tests locally by running `python3 -m unittest test` from the root directory (or usually `python -m unittest test` on Windows). The tests will also automatically run upon submitting a pull request so you will be alerted there too if the tests fail. -* The aim is to keep the testing suite extensive for the important parts. This doesn't mean that 100% coverage is absolutely required, but it does mean that most features involving the import or export of 3MF data will need to be tested automatically. This keeps the add-on maintainable for the future and enforces better code structures. The tests need to mock the Blender API away, so code that is basically just a concatenation of Blender API calls won't need to be tested. For the rest, please allow everyone to test your changes automatically. -* This add-on maintains [Blender's code style rules](https://wiki.blender.org/wiki/Style_Guide/Python). That means it's PEP-8, and that enum-style string constants need to use single quotes (`'`), while other strings use double quotes. -* Please leave the updating of the change log to the maintainer. -* Please write useful commit messages! Github's web interface allows editing files and by default fills in the useless commit message of "Modified file.py". These commit messages are not useful at all and you might as well not use any version control then. Please describe the changes you made to these files. If the changes are just to some Markdown files it's not that much of an issue but it's important to keep up the good practices. +## What to contribute -Pull requests that add tests or documentation are welcome too. Writing tests is thankless and useful work. Writing documentation is an important learning step for new engineers who like to feel how it is to contribute to an existing repository. +Good contributions include: -Reviewing and testing other people's pull requests is another way in which you could contribute. \ No newline at end of file +- Fixes for Blender 4.2+ / 5.0+ API changes / regressions +- 3MF import/export correctness fixes (especially edge cases) +- Test coverage (unit or integration) +- Documentation improvements (README, API docs, copilot instructions) +- MMU Paint Suite improvements (paint panel, texture handling) +- Small quality-of-life improvements that don't change file semantics +- Triangle Sets / Materials Extension support improvements + +Non-goals (usually): + +- "Opinionated" transformations of scene data on import/export +- Silent data loss or auto-fixing invalid files without clearly reporting it +- Large refactors without a clear benefit or test coverage +- Breaking changes to the public API (`api.py`) without strong justification + +## Bug reports + +Before opening a new issue, please search existing issues first. + +When reporting a bug, include: + +1. **Blender version** (e.g. 4.2.x LTS / 5.0.x - primary dev version is 5.0) +2. **OS** (Windows/macOS/Linux) and relevant hardware notes +3. **Steps to reproduce** (a minimal, reliable recipe) +4. **Expected vs actual behavior** +5. **Logs / traceback** (copy/paste from the system console, accessible via Window → Toggle System Console) +6. If relevant, a **minimal .3mf sample file** +7. For paint/segmentation issues: screenshots of the texture or face colors + +If your file contains sensitive data, don't post it publicly. Instead, try to reproduce the issue with a sanitized/minimal file. + +## Feature requests + +Feature requests are welcome, but please keep the scope aligned with the project: + +- The add-on's purpose is to **load and save 3MF files**. +- Changes that modify geometry/materials beyond what the format requires should be kept minimal. + +When requesting a feature, describe: + +- The user problem being solved +- Why it belongs in the add-on (vs being handled elsewhere) +- Any references to the 3MF spec or examples from other tools + +## Pull requests + +Pull requests are welcome. + +### Basic workflow + +1. Fork the repo +2. Create a feature branch +3. Make your changes +4. Run tests (see below) +5. Open a PR with a clear description and screenshots/files when relevant + +### Testing requirements + +This project has **unit tests** and **integration tests**. All tests run in **real Blender headless mode** — no mocking. They require Blender's Python interpreter. + +#### Run all tests (recommended before submitting PR) + +Windows (PowerShell): + +```powershell +python tests/run_all_tests.py +``` + +This spawns separate Blender processes for unit and integration tests, reporting pass/fail for both suites. + +#### Unit tests only + +Tests for individual functions (colors, segmentation codec, XML parsing, units, etc.) without creating Blender objects: + +```powershell +blender --background --factory-startup --python-exit-code 1 -noaudio -q --python tests/run_unit_tests.py +``` + +Located in `tests/unit/` + +#### Integration tests only + +End-to-end tests that create real Blender objects, import/export `.3mf` files, and validate materials/geometry: + +```powershell +blender --background --factory-startup --python-exit-code 1 -noaudio -q --python tests/run_tests.py +``` + +Located in `tests/integration/` + +Test resources (sample files) are in `tests/resources/` and `tests/resources/3mf_consortium/` + +**Note:** All tests require Blender's Python — don't use system Python or virtualenvs. You need Blender installed and available on your PATH. + +### Code style and architecture + +- Follow [Blender's Python style guide](https://wiki.blender.org/wiki/Style_Guide/Python) +- PEP-8 compatible (`` in headers) +- Keep changes focused and readable +- Prefer adding tests for bug fixes and behavior changes + +**Architecture notes:** + +- **Context dataclasses** — `ImportContext` / `ExportContext` replace mutable operator state. All helpers take `ctx` as first arg. +- **NO `logging` module** — use `common.logging` (`debug()`, `warn()`, `error()`) exclusively. Python's `logging` does nothing in Blender. +- **NO `print()` calls** — use `debug()` for dev output, `warn()`/`error()` for real issues. +- **Cache Blender strings** before XML ops — Python may GC the C string behind `blender_object.name` +- **Blender properties can't start with `_`** — use `3mf_` prefix for custom properties +- **Sub-package imports** — use `from ..common import ...` for common utilities +- See `.github/copilot-instructions.md` for full architecture documentation + +### Commit messages + +Write meaningful commit messages. Avoid generic messages like “Update file” or “Fix stuff”. + +Good examples: + +- `Fix material slot indexing when exporting empty slots` +- `Update import operator for Blender 4.2 depsgraph API` + +### Changelog + +Please don't update `CHANGELOG.md` unless you're asked to. Maintainers will handle release notes. + +## Reviewing PRs is also contributing + +If you're not ready to code, you can still help by: + +- Trying the add-on on your Blender version and reporting results +- Testing PR branches +- Improving docs +- Sharing minimal repro files for tricky import/export bugs diff --git a/README.md b/README.md index 725c0b0..0e5b040 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,227 @@ -Blender 3MF Format -==== -This is a Blender add-on that allows importing and exporting 3MF files. +# Blender 3MF Format +[![Static Badge](https://img.shields.io/badge/Funding-%2460-blue?style=for-the-badge&logo=buymeacoffee)](https://buymeacoffee.com/clonephaze) [![Static Badge](https://img.shields.io/badge/Supporters-2-success?style=for-the-badge&logo=buymeacoffee)](https://buymeacoffee.com/clonephaze) [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Clonephaze/3MF-Blender-Add-on---Maintained?style=for-the-badge&logo=github&color=critical)](https://github.com/Clonephaze/3MF-Blender-Add-on---Maintained/issues) [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed/Clonephaze/3MF-Blender-Add-on---Maintained?style=for-the-badge&logo=github)](https://github.com/Clonephaze/3MF-Blender-Add-on---Maintained/issues) -3D Manufacturing Format files (.3mf) are a file format for triangular meshes intended to serve as exchange format for 3D printing applications. They can communicate not only the model, but also the intent and material of a 3D printing job from the CAD software to the CAM software (slicer). In this scenario, Blender serves as the CAD software. To that end, the aim of this add-on is to make Blender a more viable alternative as CAD software for additive manufacturing. +> [!NOTE] +> This is an actively maintained fork of the [original Blender 3MF add-on](https://github.com/Ghostkeeper/Blender3mfFormat), updated for modern Blender versions (4.2+) and ongoing development. -Installation ----- -This add-on requires Blender 2.80 or newer. It is tested on version 2.80, 2.83, 2.93 3.0 and 3.3. +This is an add-on for Blender for importing and exporting **3MF (3D Manufacturing Format)** files. -To install this add-on, currently you need to tell Blender where to find a .zip archive with the add-on inside. -1. Download the latest release from the [releases page](https://github.com/Ghostkeeper/Blender3mfFormat/releases/latest). This is a .zip archive. -2. In Blender, go to Edit -> Preferences and open the Add-ons tab on the left. -3. Click on the Install... button at the top. Navigate to the .zip you downloaded. -4. Under the "Community" category, find the add-on called "Import-Export: 3MF format". Make sure that it's enabled. (Note: If searching with the search box, exclude the "Import-Export" text since this is the category, not part of the add-on name. Just search for "3MF" instead.) +3MF is a modern format for 3D printing. Unlike STL, it carries more than geometry: units, materials, colors, metadata, and slicer-relevant information. Blender sits upstream of slicers in many workflows, and this add-on helps make that process smooth and predictable. -The add-on is being considered for inclusion in Blender as a community add-on [here](https://developer.blender.org/T84154). This would make it easier to install. +The goal is simple: make **Blender a reliable, spec-compliant tool in real 3MF workflows**, with solid behavior and interoperability with modern slicers. -Usage ----- -When this add-on is installed, a new entry will appear under the File -> Import menu called "3D Manufacturing Format". When you click that, you'll be able to select 3MF files to import into your Blender scene. A new entry will also appear under the File -> Export menu with the same name. This allows you to export your scene to a 3MF file. +--- -![Screenshot](screenshot.png) +## Status -The following options are available when importing 3MF files: -* Scale: A scaling factor to apply to the scene after importing. All of the mesh data loaded from the 3MF files will get scaled by this factor from the origin of the coordinate system. They are not scaled individually from the centre of each mesh, but all from the coordinate origin. +- **Version 2.0.0** — Major architecture restructure with public API +- Compatible with **Blender 4.2+** +- Actively maintained -The following options are available when exporting to 3MF: -* Selection only: Only export the objects that are selected. Other objects will not be included in the 3MF file. -* Scale: A scaling factor to apply to the models in the 3MF file. The models are scaled by this factor from the coordinate origin. -* Apply modifiers: Apply the modifiers to the mesh data before exporting. This embeds these modifiers permanently in the file. If this is disabled, the unmodified meshes will be saved to the 3MF file instead. -* Precision: Number of decimals to use for coordinates in the 3MF file. Greater precision will result in a larger file size. +For Blender versions **2.80–3.6**, see the [original releases](https://github.com/Ghostkeeper/Blender3mfFormat/releases/latest). -Scripting ----- -From a script, you can import a 3MF mesh by executing the following function call: +--- -``` -bpy.ops.import_mesh.threemf(filepath="/path/to/file.3mf") -``` +## Features -This import function has two relevant parameters: -* `filepath`: A path to the 3MF file to import. -* `global_scale` (default `1`): A scaling factor to apply to the scene after importing. All of the mesh data loaded from the 3MF files will get scaled by this factor from the origin of the coordinate system. +- Import and export 3MF files +- Material and color support using modern Blender material APIs +- Embedded viewport thumbnails in exported 3MF files +- Correct handling of units and build structure +- **Public API** for programmatic/headless workflows ([documentation](docs/site/index.html)) +- Multiple 3MF spec-compliant extensions: + - Core Materials (basematerials) + - Production Extension (multi-object builds, color zones) + - Vendor extensions for Orca Slicer, BambuStudio, and PrusaSlicer -You can export a 3MF mesh by executing the following function call: +### Slicer Compatibility +| Slicer | Round-Trip Support | Notes | +| ----------------------------- | ----------------- | ---------------------------------------------------------------------------------------------- | +| **Orca Slicer / BambuStudio** | Partial | Per-triangle material/color zones preserved. Does **not** reproduce slicer paint workflows | +| **PrusaSlicer** | Partial | Per-triangle material/color zones preserved. No Blender paint-mode support | +| **Standard 3MF** | Full | Geometry, materials, metadata | + +--- + +## Installation + +### Blender 4.2+ (Recommended) + +[**Official Blender Extensions Platform**](https://extensions.blender.org/add-ons/threemf-io) – Includes automatic updates! + +1. Open Blender +2. Go to *Edit → Preferences → Get Extensions* +3. Search for **"3MF"** +4. Click *Install* on **3MF Import/Export** + +### Manual Installation + +**Option 1: Drag & Drop** +1. Download the ZIP from [Releases](https://github.com/Ghostkeeper/Blender3mfFormat/releases) +2. Open Blender +3. Drag the downloaded ZIP file into Blender +4. Enable the add-on + +**Option 2: Preferences** +1. Download the ZIP from [Releases](https://github.com/Ghostkeeper/Blender3mfFormat/releases) +2. Open *Edit → Preferences → Add-ons* +3. Click *Install…* and select the downloaded ZIP file +4. Enable **3MF Import/Export** + +--- + +## Usage + +Menus after installation: +- **File → Import → 3D Manufacturing Format (.3mf)** +- **File → Export → 3D Manufacturing Format (.3mf)** + +### Import Options +- **Scale** – Uniform scale applied from the scene origin +- **Import Materials** – Import material colors (disable for geometry-only) +- **Placement** – Choose object placement: + - **Keep** – Keep positions from the 3MF file + - **World Origin** – Move to scene origin + - **3D Cursor** – Place at the current 3D cursor +- **Reset Object Origins** – Reset each object’s origin before placement + +### Export Options +- **Selection Only** +- **Scale** +- **Apply Modifiers** +- **Coordinate Precision** +- **Export Hidden Objects** +- **Multi-Material Format** – Per-triangle material assignment using Standard 3MF, Orca/Bambu Slicer, or PrusaSlicer MMU + - **Orca Slicer** – `Production Extension` with `paint_color` attributes + - **PrusaSlicer** – `slic3rpe:mmu_segmentation` attributes for color metadata and round-trip fidelity + +### MMU Paint Suite + +Built-in multi-material texture painting system for creating per-triangle filament assignments directly in Blender's 3D Viewport. + +**Features:** +- **Texture-Based Painting** – Paint multi-filament regions using Blender's native paint tools +- **Visual Filament Palette** – Click-to-switch color swatches in the 3D View sidebar (N-panel → 3MF tab) +- **Filament Management** – Add, remove, and reassign filament colors during painting + +**Usage:** +1. Import a 3MF file with multi-material data, or select any mesh object +2. Open sidebar (N-panel) → 3MF tab → MMU Paint Suite +3. Add filaments and click "Initialize Painting" +4. Click filament swatches to switch active color, then paint in Texture Paint mode +5. Export to 3MF with desired slicer format + +--- + +## Programmatic API + +Version 2.0.0 introduces a public Python API for headless/programmatic use without `bpy.ops`: + +```python +from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + +# Inspect without importing +info = inspect_3mf("model.3mf") +print(info.unit, info.num_objects, info.num_triangles_total) + +# Import +result = import_3mf("model.3mf", import_materials="PAINT") +print(result.status, result.num_loaded) + +# Export specific objects +result = export_3mf("output.3mf", objects=my_objects, use_orca_format="AUTO") + +# Batch operations +from io_mesh_3mf.api import batch_import +results = batch_import(["a.3mf", "b.3mf"], target_collection="Imports") ``` -bpy.ops.export_mesh.threemf(filepath="/path/to/file.3mf") -``` -This export function has five relevant parameters: -* `filepath`: The location to store the 3MF file. -* `use_selection` (default `False`): Only export the objects that are selected. Other objects will not be included in the 3MF file. -* `global_scale` (default `1`): A scaling factor to apply to the models in the 3MF file. The models are scaled by this factor from the coordinate origin. -* `use_mesh_modifiers` (default `True`): Apply the modifiers to the mesh data before exporting. This embeds these modifiers permanently in the file. If this is disabled, the unmodified meshes will be saved to the 3MF file instead. -* `coordinate_precision` (default `4`): Number of decimals to use for coordinates in the 3MF file. Greater precision will result in a larger file size. +Full documentation: **[API Docs](docs/site/index.html)** | Rebuild with ``docs/build.ps1`` after API changes + +--- + +## Development & Contributing + +Current features and roadmap are in **[ROADMAP.md](ROADMAP.md)** | Full changelog in **[CHANGELOG.md](CHANGELOG.md)** + +--- + +## 3MF Specification Support + +This add-on targets **3MF Core Specification v1.4.0**. It includes checks to warn or stop on specification-specific conditions. + +### Behavior Notes + +The 3MF spec requires consumers to fail hard on malformed files. In Blender, this is often impractical, so the add-on handles recoverable issues gracefully: +- Core requirements (ZIP/OPC structure, model XML, units, build definitions) are enforced on export +- Partial or malformed files may import with warnings instead of failing +- Conflicting metadata from multiple files may be skipped to preserve scene integrity + +### Extensions + +Supported 3MF extensions for improved slicer interoperability: +| Extension | Namespace | Support | +| -------------------------------- | ----------------------------------------------------------------- | ------------- | +| Core Materials (`basematerials`) | Core Spec v1.3.0 | Full | +| Production Extension | `http://schemas.microsoft.com/3dmanufacturing/production/2015/06` | Full | +| Materials Extension v1.2.1 | `http://schemas.microsoft.com/3dmanufacturing/material/2015/02` | Full (Active PBR) | + +**Materials Extension Features:** +- **Colorgroups & Textures**: Full import/export with UV coordinates +- **PBR Metallic**: Metallic/roughness values applied to Principled BSDF +- **PBR Specular**: Specular color/glossiness mapped to Blender materials +- **Translucent**: IOR, transmission, and attenuation for glass-like materials +- **Round-trip**: All element types preserved for lossless re-export + +--- + +## Orca Slicer / BambuStudio + +Per-triangle material handling for Orca Slicer and BambuStudio files. Does **not** include slicer paint-mode workflows. + +**Import** +- Reads multi-file Production Extension structure (`3D/Objects/*.model`) +- Imports `paint_color` attributes as Blender materials +- Loads filament colors from `Metadata/project_settings.config` +- Supports Orca, BambuStudio, and PrusaSlicer files + +**Export** +- Writes multi-file Production Extension structure +- Exports per-triangle `paint_color` attributes +- Generates `project_settings.config` with filament colors +- Creates correct OPC relationships +- Embeds viewport thumbnail previews + +Filament colors reload automatically from metadata for accurate material recreation. + +--- + +## PrusaSlicer Compatibility + +**Import** +- Reads `slic3rpe:mmu_segmentation` attributes +- Preserves multi-material zones as Blender materials + +**Export** +- Standard 3MF export works +- Orca-format color zones compatible with PrusaSlicer painting tools + +PrusaSlicer does not embed actual RGB colors in 3MF files; it uses filament indices referencing local profiles. Round-tripping through Blender generates colors based on zone indices and may not match original filament colors exactly. + +--- + +## Project History + +Forked from Ghostkeeper’s original Blender 3MF add-on and modernized by Jack (2025–). + +- Original author: Ghostkeeper (2020–2023) +- Fork & maintenance: Jack (2025–) -Support ----- -This add-on currently supports the full [3MF Core Specification](https://github.com/3MFConsortium/spec_core/blob/1.2.3/3MF%20Core%20Specification.md) version 1.2.3. However there are a number of places where it deviates from the specification on purpose. +All original attribution and **GPL v2+ license** are preserved. -The 3MF specification demands that consumers of 3MF files (i.e. importing 3MF files) must fail quickly and catastrophically when anything is wrong. If a single field is wrong, the entire archive should not get loaded. This add-on has the opposite approach: If something small is wrong with the file, the rest of the file can still be loaded, but for instance without loading that particular triangle that's wrong. You'll get an incomplete file and a warning is placed in the Blender log. +--- -The 3MF specification is also not designed to handle loading multiple 3MF files at once, or to load 3MF files into existing scenes together with other 3MF files. This add-on will try to load as much as possible, but if there are conflicts with parts of the files, it will load neither. One example is the scene metadata such as the title of the scene. If loading two files with the same title, that title is kept. However when combining files with multiple titles, no title will be loaded. +## License -No 3MF format extensions are currently supported. That is a goal for future development. +GPL v2+ diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3a8834b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,88 @@ +# 🗺️ Development Roadmap + +> **3MF Import/Export for Blender** — Future Development Plan + +Features and improvements organized by priority. Complexity ratings help with planning but don't determine feasibility — we can tackle hard problems with proper research. + +--- + +## 📊 Legend + +| Symbol | Meaning | +|--------|---------| +| 📋 | Planned | +| 💭 | Needs Research | + +**Complexity:** `🟢 Easy` `🟡 Medium` `🔴 Hard` + +--- + +## 🖨️ Slicer Compatibility + +### OrcaSlicer-FullSpectrum (Snapmaker U1) +| Status | Feature | Complexity | Description | +|--------|---------|------------|-------------| +| 📋 | Round-trip passthrough | 🟢 | Verify `project_settings.config` passthrough preserves `mixed_filament_definitions` and related keys without data loss | +| 📋 | Detection | 🟢 | Detect FullSpectrum files via `mixed_filament_definitions` key in `project_settings.config` | +| 📋 | Import — blended colors | 🟡 | Parse `mixed_filament_definitions`; for 2-way blends use `lerp(A, B, ratio/100)`; for patterned blends compute weighted average from digit frequencies in the pattern field. Extend paint code table to cover virtual IDs (5+). | +| 📋 | Import — metadata preservation | 🟢 | Store parsed virtual filament definitions as a custom mesh/scene property so they survive round-trip export | +| 📋 | MMU Paint UI — virtual filament palette | 🔴 | Extend MMU Paint sidebar to show and define virtual (mixed) filaments: two-physical-filament picker, ratio slider, optional pattern field for 3-way+ blends, dithering mode selector, live blended color preview swatch. Two-tier display: "Mini" (6 quick 50/50 pairs) and "Full" (custom ratio/pattern entries). | +| 📋 | Bake — virtual filament mapping | 🔴 | Extend bake pipeline so quantized regions can be assigned to virtual filaments; baked colors that fall between physical filament colors can be auto-matched to the nearest virtual filament's computed blend color (including 3-way pastel/earth tones via pattern blends) | +| 📋 | Export — mixed filament definitions | 🟡 | Serialize virtual filament definitions back to `mixed_filament_definitions` string on Orca export; preserve u-numbers and trailing pattern field; write virtual IDs as paint codes using extended Bambu hex series | + +#### Research Notes + +**Format:** FullSpectrum is a fork of Snapmaker/OrcaSlicer. The 3D model geometry (`3dmodel.model`, `paint_color` attributes) is **identical** to standard Orca — all new data lives exclusively in `Metadata/project_settings.config` as additional JSON keys. + +**`mixed_filament_definitions` encoding:** Semicolon-separated row entries. Each row is CSV: `A,B,enabled,custom,mix_b_percent[,prefixed_metadata...[,pattern]]`. Field 3 is `custom` (0=auto pair, 1=user-created), NOT "gradient" as initially assumed. Metadata tokens use single-char prefixes (`g`=gradient IDs, `w`=weights, `m`=mode, `z`=sublayers, `xa`/`xb`=offsets, `d`=deleted, `o`=origin_auto, `u`=stable_id). Trailing unrecognized tokens form the manual pattern. + +**u-numbers are stable_ids, NOT virtual slot numbers.** The `uN` value is a persistent identity marker for round-trip stability. Virtual filament slot = `num_physical + 1 + enabled_index` (counting only enabled && !deleted entries in array order). Gaps in u-numbers are normal. + +**Two-tier palette system:** +- **Auto pairs:** C(N,2) pairwise 50/50 blends, `custom=0, origin_auto=1` +- **Custom entries:** User-defined ratios + patterns, `custom=1, origin_auto=0` + +**Color blending uses FilamentMixer** — a degree-4 polynomial regression (MIT licensed) approximating Mixbox pigment mixing. Blue+Yellow → Green, not Teal. NOT simple RGB lerp. Multi-color blending is sequential pairwise accumulation. + +**Full implementation guide:** See `docs/fullspectrum-implementation-guide.md` + +**Reference file:** `PeggyPalette38+Mini+BRYW` (Cyan, Magenta, Yellow, White) is a developer-made calibration model. It encodes a complete CMY color wheel: 6 primary pairwise blends at 5 ratio steps each (outer ring), 9 three-way White pastel blends (inner ring), 1 equal 3-way "drab" center, and 9 advanced cadence-pattern blends — 34 virtual filaments used in this model, with 6 additional "Mini" definitions available but unused by the model geometry. + +--- + +### Cura +| Status | Feature | Complexity | Description | +|--------|---------|------------|-------------| +| 💭 | MMU Research Needed | 🔴 | Import/Export of cura MMU Data Needed (I'll be honest, I don't want to do this. PRs would be very welcome) | + +--- + +## 🧪 Testing & Docs + +| Status | Feature | Complexity | Description | +|--------|---------|------------|-------------| +| 📋 | User Guide | 🟡 | Usage documentation (needs improvement — tutorial-style docs for common workflows) | +| 📋 | API Documentation | 🟡 | Public API reference (needs improvement — auto-generated docs) | +| 📋 | Performance Benchmarks | 🟡 | Establish benchmarks for large-file import/export (100k+ triangles, many objects) to catch regressions | + +--- + +### Research Notes + +#### Cura MMU Data +Cura stores MMU data in a PNG texture file using the blue hue channel. It supports 8 colors, mapping them to the first 8 values of blue: 1/255 blue = material index 1, 2/255 = index 2, etc. +It reads this for color zones, but uses another file for actual color data. No seam or support data seems to be stored in the texture file. Cura doesn't appear to have seam painting at all. + +--- + +## 🤝 Contributing + +Help wanted: +1. **Testing** — Try different slicers, report issues +2. **Research** — Document undocumented slicer formats +3. **Bug fixes** — If there's an open issue you think you can tackle, comment to claim it and we can discuss the approach +4. **Code** — Pick something from the roadmap and PR it + +--- + +*Current version: 2.2.1* diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..73bf84d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,22 @@ +# Security Policy + +## Supported Versions + +Only the latest release is actively maintained. Please update before reporting. + +| Version | Supported | +| ------- | --------- | +| Latest | ✅ | +| Older | ❌ | + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +If you find a security issue (e.g. malicious .3mf files that could trigger arbitrary code execution, path traversal on archive extraction, etc.), please report it privately: + +1. Go to the **[Security tab → Report a vulnerability](../../security/advisories/new)** on this repository. +2. Describe the issue, steps to reproduce, and any proof-of-concept. +3. You'll receive a response within a few days. + +We'll work with you to understand the scope, prepare a fix, and credit you in the release notes. diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..900ff44 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,73 @@ +Core API +======== + +The public API lives in :mod:`io_mesh_3mf.api`. All functions return +lightweight result dataclasses — they never raise exceptions for normal +failures (corrupt files, empty scenes, etc.). + +.. module:: io_mesh_3mf.api + :synopsis: Programmatic 3MF import, export, and inspection. + +Version & Capabilities +---------------------- + +.. autodata:: API_VERSION + :annotation: + +.. autodata:: API_VERSION_STRING + :annotation: + +.. autodata:: API_CAPABILITIES + :annotation: + +Result Dataclasses +------------------ + +.. autoclass:: ImportResult + :members: + :no-undoc-members: + +.. autoclass:: ExportResult + :members: + :no-undoc-members: + +.. autoclass:: InspectResult + :members: + :no-undoc-members: + +Import +------ + +.. autofunction:: import_3mf + +Export +------ + +.. autofunction:: export_3mf + +Inspect +------- + +.. autofunction:: inspect_3mf + +Batch Operations +---------------- + +.. autofunction:: batch_import + +.. autofunction:: batch_export + +Callback Types +-------------- + +The following type aliases describe the callback signatures accepted by +the import/export functions: + +``ProgressCallback`` + ``Callable[[int, str], None]`` — receives ``(percentage, message)``. + +``WarningCallback`` + ``Callable[[str], None]`` — receives a warning message string. + +``ObjectCreatedCallback`` + ``Callable[[Any, str], None]`` — receives ``(blender_object, resource_id)``. diff --git a/docs/build.ps1 b/docs/build.ps1 new file mode 100644 index 0000000..9254571 --- /dev/null +++ b/docs/build.ps1 @@ -0,0 +1,52 @@ +<# +.SYNOPSIS + Build the 3MF API reference documentation using Sphinx. + +.DESCRIPTION + Generates HTML documentation from Python docstrings in io_mesh_3mf/api.py. + Output goes to docs/site/ (committed to the repo so users don't need to + build). Intermediate doctrees go to docs/_build/ (gitignored). + + Only contributors who change the API or docstrings need to rebuild. + +.EXAMPLE + .\docs\build.ps1 # Build HTML docs + .\docs\build.ps1 -Clean # Clean then rebuild +#> + +param( + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +$docsDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = Split-Path -Parent $docsDir +$cacheDir = Join-Path $docsDir "_build" +$siteDir = Join-Path $docsDir "site" + +Push-Location $projectRoot +try { + if ($Clean) { + if (Test-Path $cacheDir) { + Write-Host "Cleaning $cacheDir..." -ForegroundColor Yellow + Remove-Item -Recurse -Force $cacheDir + } + if (Test-Path $siteDir) { + Write-Host "Cleaning $siteDir..." -ForegroundColor Yellow + Remove-Item -Recurse -Force $siteDir + } + } + + Write-Host "Building HTML docs..." -ForegroundColor Cyan + sphinx-build -b html -d docs/_build/doctrees docs docs/site -W --keep-going 2>&1 + + if ($LASTEXITCODE -eq 0) { + $indexPath = Join-Path $siteDir "index.html" + Write-Host "`nDocs built successfully!" -ForegroundColor Green + Write-Host "Open: $indexPath" -ForegroundColor Gray + } else { + Write-Host "`nBuild completed with warnings." -ForegroundColor Yellow + } +} finally { + Pop-Location +} \ No newline at end of file diff --git a/docs/building-blocks.rst b/docs/building-blocks.rst new file mode 100644 index 0000000..244851f --- /dev/null +++ b/docs/building-blocks.rst @@ -0,0 +1,52 @@ +Building Blocks +=============== + +The API re-exports common modules for custom workflows. These are the +same modules used internally by the import/export pipeline. + +.. code-block:: python + + from io_mesh_3mf.api import colors, types, segmentation, units + +Colors +------ + +.. automodule:: io_mesh_3mf.common.colors + :members: + :undoc-members: + +Units +----- + +.. automodule:: io_mesh_3mf.common.units + :members: + :undoc-members: + +Types (Dataclasses) +------------------- + +.. automodule:: io_mesh_3mf.common.types + :members: + :undoc-members: + :show-inheritance: + +Segmentation Codec +------------------ + +.. automodule:: io_mesh_3mf.common.segmentation + :members: + :undoc-members: + +Extensions +---------- + +.. automodule:: io_mesh_3mf.common.extensions + :members: + :no-undoc-members: + +Metadata +-------- + +.. automodule:: io_mesh_3mf.common.metadata + :members: + :undoc-members: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9d6285b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,184 @@ +# Sphinx configuration for 3MF Format API reference +# Build with: sphinx-build -b html -d docs/_build/doctrees docs docs/site +# Or use: docs/build.ps1 + +import sys +import os +from unittest.mock import MagicMock + +# --------------------------------------------------------------------------- +# Mock Blender modules so Sphinx can import io_mesh_3mf outside Blender +# --------------------------------------------------------------------------- + +class _BlenderMock(MagicMock): + """A MagicMock that also acts as a module with arbitrary nested attrs.""" + + @classmethod + def __getattr__(cls, name): + return MagicMock() + + +MOCK_MODULES = [ + "bpy", + "bpy.app", + "bpy.app.handlers", + "bpy.ops", + "bpy.props", + "bpy.types", + "bpy.utils", + "bpy_extras", + "bpy_extras.io_utils", + "bpy_extras.node_shader_utils", + "bl_operators", + "bl_operators.presets", + "bmesh", + "mathutils", + "idprop", + "idprop.types", +] + +for mod_name in MOCK_MODULES: + sys.modules[mod_name] = _BlenderMock() + + +# Blender classes used as base classes in the addon. Python requires real +# classes (not MagicMock instances) when listed in a class's bases, otherwise +# the metaclass resolution between two MagicMock bases will fail. +# Each stand-in must be a *unique* class so Python doesn't complain about +# duplicate base classes (e.g. ``class Foo(Operator, ImportHelper)``). + +def _make_base(name: str) -> type: + """Return a unique empty class with the given name.""" + return type(name, (), {}) + + +# bpy.types.* +_bpy_types = sys.modules["bpy.types"] +for _name in ( + "Operator", "Panel", "UIList", "PropertyGroup", + "FileHandler", "AddonPreferences", +): + setattr(_bpy_types, _name, _make_base(_name)) + +# Menu needs a draw_preset attribute (used by EXPORT_MT_threemf_presets) +_Menu = _make_base("Menu") +_Menu.draw_preset = lambda self, context: None +_bpy_types.Menu = _Menu + +# Also set on the parent so `bpy.types.X` resolves either way +_bpy = sys.modules["bpy"] +_bpy.types = _bpy_types + +# bpy_extras.io_utils.* +_io_utils = sys.modules["bpy_extras.io_utils"] +_io_utils.ImportHelper = _make_base("ImportHelper") +_io_utils.ExportHelper = _make_base("ExportHelper") +sys.modules["bpy_extras"].io_utils = _io_utils + +# bpy_extras.node_shader_utils (keep as mock, nothing inherits from it) +sys.modules["bpy_extras"].node_shader_utils = sys.modules["bpy_extras.node_shader_utils"] + +# bl_operators.presets.* +_presets = sys.modules["bl_operators.presets"] +_presets.AddPresetBase = _make_base("AddPresetBase") +sys.modules["bl_operators"].presets = _presets + +# Wire up bpy sub-modules so attribute access matches sys.modules lookups +_bpy.app = sys.modules["bpy.app"] +_bpy.app.handlers = sys.modules["bpy.app.handlers"] +_bpy.ops = sys.modules["bpy.ops"] +_bpy.props = sys.modules["bpy.props"] +_bpy.utils = sys.modules["bpy.utils"] + +# Make bpy.app.version return a tuple so version checks don't crash +_bpy.app.version = (5, 0, 0) +_bpy.app.driver_namespace = {} + +# Add the project root to sys.path so `import io_mesh_3mf` works +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +# --------------------------------------------------------------------------- +# Project info +# --------------------------------------------------------------------------- + +project = "3MF Format for Blender" +copyright = "2025, Jack" +author = "Jack" + +# Pull version from the api module +try: + from io_mesh_3mf.api import API_VERSION_STRING + release = API_VERSION_STRING +except Exception: + release = "1.0.0" + +version = release + +# --------------------------------------------------------------------------- +# Extensions +# --------------------------------------------------------------------------- + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", # Google/NumPy docstring support + "sphinx.ext.viewcode", # [source] links + "sphinx.ext.intersphinx", # Cross-ref to Python stdlib docs +] + +# --------------------------------------------------------------------------- +# Autodoc settings +# --------------------------------------------------------------------------- + +autodoc_default_options = { + "members": True, + "undoc-members": False, + "show-inheritance": True, + "member-order": "bysource", +} + +# Don't show the full module path for every class/function +add_module_names = False + +# Type hints in the signature, not repeated in the body +autodoc_typehints = "signature" + +# Napoleon settings (for Google/NumPy style docstrings) +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True +napoleon_use_param = True +napoleon_use_rtype = True + +# --------------------------------------------------------------------------- +# Intersphinx +# --------------------------------------------------------------------------- + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# --------------------------------------------------------------------------- +# HTML output +# --------------------------------------------------------------------------- + +html_theme = "furo" +html_theme_options = { + "light_css_variables": { + "color-brand-primary": "#6d5cae", + "color-brand-content": "#6d5cae", + }, + "dark_css_variables": { + "color-brand-primary": "#9bb8d3", + "color-brand-content": "#9bb8d3", + }, + "sidebar_hide_name": False, + "navigation_with_keys": True, + "top_of_page_buttons": [], +} + +html_title = f"3MF Format API — v{release}" +html_short_title = "3MF API" +html_show_sourcelink = False + +# Output directory (relative to docs/) +# Build creates docs/_build/html/ diff --git a/docs/discovery.rst b/docs/discovery.rst new file mode 100644 index 0000000..cef74e9 --- /dev/null +++ b/docs/discovery.rst @@ -0,0 +1,40 @@ +API Discovery +============= + +Other Blender addons can detect and use the 3MF API at runtime. Three +strategies are available, from simplest to most robust. + +Direct Import (recommended) +--------------------------- + +If you know the addon is installed, a plain ``try``/``except`` is the +simplest and most Pythonic approach:: + + try: + from io_mesh_3mf.api import import_3mf, export_3mf + except ImportError: + import_3mf = export_3mf = None + +Discovery Functions (from ``api``) +---------------------------------- + +.. autofunction:: io_mesh_3mf.api.is_available + +.. autofunction:: io_mesh_3mf.api.get_api + +.. autofunction:: io_mesh_3mf.api.has_capability + +.. autofunction:: io_mesh_3mf.api.check_version + +Standalone Discovery Helper +---------------------------- + +For addons that want **zero runtime dependency** on the 3MF addon, copy +``io_mesh_3mf/threemf_discovery.py`` into your addon. It resolves the +addon's import path automatically via ``addon_utils``, caches the result, +and works regardless of extension repo prefix or addon load order. + +.. automodule:: io_mesh_3mf.threemf_discovery + :members: + :undoc-members: + :exclude-members: import_3mf, export_3mf, inspect_3mf diff --git a/docs/fullspectrum-implementation-guide.md b/docs/fullspectrum-implementation-guide.md new file mode 100644 index 0000000..79710e9 --- /dev/null +++ b/docs/fullspectrum-implementation-guide.md @@ -0,0 +1,536 @@ +# OrcaSlicer-FullSpectrum: Implementation Guide + +> **Source audit completed April 2026** against `ReferenceFiles/OrcaSlicer-FullSpectrum-main/` (v0.9.7). +> This document captures everything needed to implement FullSpectrum support in the Blender 3MF addon. +> Delete this file once the features are shipped. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Serialization Format](#2-serialization-format) +3. [Virtual Filament ID Mapping](#3-virtual-filament-id-mapping) +4. [FilamentMixer Color Model](#4-filamentmixer-color-model) +5. [Config Keys](#5-config-keys) +6. [Resolution Pipeline](#6-resolution-pipeline-slicer-side-only) +7. [Implementation Tasks](#7-implementation-tasks) +8. [Data Flow](#8-data-flow) +9. [Risks & Open Questions](#9-risks--open-questions) +10. [Implementation Order](#10-implementation-order) +11. [Source File Reference](#11-source-file-reference) + +--- + +## 1. Overview + +**Fork chain:** Slic3r → PrusaSlicer → BambuStudio → OrcaSlicer → Snapmaker/OrcaSlicer → ratdoux/OrcaSlicer-FullSpectrum + +**Key insight:** The 3D model geometry (`3dmodel.model`, `Objects/*.model`, `paint_color` per-triangle attributes) is **completely identical** to standard OrcaSlicer. All FullSpectrum data lives exclusively in `Metadata/project_settings.config` JSON keys. The OPC archive structure, vertices, triangles, components, seam/support paint — all unchanged. `bbs_3mf.cpp` is effectively unmodified for geometry I/O. + +**What's new:** "Virtual mixed-color filaments" — computed blends of physical filaments that the slicer resolves to layer-by-layer physical filament switches at slice time. These appear as additional entries in the `paint_color` code table beyond the physical filament indices. + +--- + +## 2. Serialization Format + +### 2.1 Container + +`mixed_filament_definitions` is a `coString` config option stored in `project_settings.config`. Its value is a semicolon-separated list of row definitions. + +**Source:** `MixedFilament.cpp` — `serialize_custom_entries()` (L1673-1703), `load_custom_entries()` (L1706-1889) + +### 2.2 Row Format + +Each row is comma-separated. Parsed by `parse_row_definition()` (L361-580). + +**Fixed positional fields (tokens 0–4):** + +| Index | Field | Type | Notes | +|-------|-------|------|-------| +| 0 | `component_a` | uint | 1-based physical filament ID | +| 1 | `component_b` | uint | 1-based physical filament ID | +| 2 | `enabled` | int→bool | 0/1 | +| 3 | `custom` | int→bool | 0 = auto-generated pair, 1 = user-created | +| 4 | `mix_b_percent` | int | 0–100, percentage of component B | + +Legacy 4-token format: `a,b,enabled,mix` — `custom` defaults to `true`. + +**Variable metadata tokens (index 5+), identified by single-char prefix:** + +| Prefix | Field | Type | Notes | +|--------|-------|------|-------| +| `g`/`G` | `gradient_component_ids` | string | Digit string of 1-based physical IDs, e.g. `"123"` for 3-way blend | +| `w`/`W` | `gradient_component_weights` | string | Slash-separated ints, e.g. `"50/25/25"` | +| `m`/`M` | `distribution_mode` | int | 0 = LayerCycle, 1 = SameLayerPointillisme, 2 = Simple | +| `z`/`Z` | `local_z_max_sublayers` | int | Max sublayer depth for local-Z | +| `xa`/`Xa` | `component_a_surface_offset` | float | mm, clamped ±5.0 | +| `xb`/`Xb` | `component_b_surface_offset` | float | mm, clamped ±5.0 | +| `d`/`D` | `deleted` | int→bool | Soft-deleted (force-disables `enabled`) | +| `o`/`O` | `origin_auto` | int→bool | Was auto-generated originally | +| `u`/`U` | `stable_id` | uint64 | Persistent identity for round-trip stability | +| *(none)* | `manual_pattern` | string | Remaining unrecognized tokens joined with commas. Digit string (1–9) with optional `,` group separators | + +### 2.3 Serialization Output Order + +`serialize_custom_entries()` writes fields in this order: + +``` +a,b,enabled,custom,mix_b_percent,pointillism_all_filaments,gIDs,wWeights,mMode,zSublayers,xaOffset,xbOffset,dDeleted,oOriginAuto,uStableId[,pattern] +``` + +**Quirk:** `pointillism_all_filaments` is written positionally at index 5 during serialization (`0` or `1`), but during parsing the tokens at index 5+ are all prefix-matched. The parser sees bare `0`/`1` as an unrecognized token that falls through to pattern accumulation — except it's actually consumed at position 5 before prefix matching begins. Handle this carefully. + +### 2.4 Example Rows + +``` +# Simple 50/50 auto-pair, component 1+2, stable_id=1 +1,2,1,0,50,0,g0,w0,m2,z0,xa0.0,xb0.0,d0,o1,u1 + +# Custom 70/30 blend, component 1+3, with 3-way pattern +1,3,1,1,30,0,g123,w50/25/25,m0,z0,xa0.0,xb0.0,d0,o0,u19,12321 + +# Disabled/deleted pair +2,3,0,0,50,0,g0,w0,m2,z0,xa0.0,xb0.0,d1,o1,u4 +``` + +### 2.5 Pattern Field Details + +- Digits `1`–`9` are physical filament indices +- `'a'` → `'1'` (component_a), `'b'` → `'2'` (component_b) — aliases +- Commas separate **perimeter groups** (e.g., `"12,21"` = outer perimeters use pattern `12`, inner use `21`) +- `flatten_manual_pattern_groups()` strips commas for flat layer-cycle use +- `normalize_manual_pattern()` validates and canonicalizes + +**Resolution:** `'1'` always maps to `component_a`, `'2'` to `component_b`, `'3'`–`'9'` are direct physical filament IDs. (See `physical_filament_from_pattern_step()`) + +### 2.6 Auto-Generate vs Custom + +`auto_generate()` creates all C(N,2) pairwise combinations of N physical filaments at 50/50 ratio. These have `custom=false, origin_auto=true`. User-created rows have `custom=true`. On load, auto rows are matched by canonical pair key; custom rows are appended after. + +--- + +## 3. Virtual Filament ID Mapping + +**Source:** `mixed_index_from_filament_id()` (L2104-2118) + +``` +virtual_filament_id = num_physical + 1 + enabled_index +``` + +Where `enabled_index` counts only entries where `enabled=true AND deleted=false`, walking the `m_mixed` array in order. + +**The `stable_id` (uN) is NOT the virtual slot number.** It's a persistent identity marker that survives rebuilds of the mixed list. Virtual slot numbers are computed dynamically from array position. + +**Total filament count:** `total_filaments(n) = n + enabled_count()` + +**For our addon:** When `paint_color` codes map to filament indices > `num_physical`, those are virtual filaments. Walk the definitions list counting enabled entries to resolve the index to a specific `MixedFilament` entry. + +--- + +## 4. FilamentMixer Color Model + +### 4.1 Architecture + +**Source:** `filament_mixer_model.h` (L1-812), `filament_mixer.cpp` (L1-82) + +This is **NOT** simple RGB lerp. It's a degree-4 polynomial regression trained to approximate [Mixbox](https://scrtwpns.com/mixbox/) pigment/subtractive mixing. Blue + Yellow → **Green** (not Teal). + +| Property | Value | +|----------|-------| +| Model type | Degree-4 polynomial regression | +| Inputs | 7 (R1, G1, B1, R2, G2, B2, t) — sRGB 0–255 | +| Features | 330 (all monomials up to degree 4) | +| Outputs | 3 (R, G, B) — sRGB 0–255, clamped | +| Accuracy | Mean Delta-E ≈ 2.07 | +| License | **MIT** (Copyright 2026 Justin Hayes) — separate from AGPL slicer | +| Generator | `scripts/export_poly_coefficients.py` (not in public repo) | + +### 4.2 Data Tables + +| Table | Dimensions | Type | Data Size | +|-------|-----------|------|-----------| +| `POWERS` | 330 × 7 | int | 2,310 values | +| `COEF` | 330 × 3 | double | 990 values | +| `INTERCEPT` | 3 | double | 3 values | +| **Total** | | | **3,303 values** (~35KB) | + +### 4.3 Algorithm + +```python +# Pseudocode for filament_mixer_lerp +def lerp(r1, g1, b1, r2, g2, b2, t): + if t <= 0: return (r1, g1, b1) + if t >= 1: return (r2, g2, b2) + + x = [r1, g1, b1, r2, g2, b2, t] # 7 inputs + + # Compute 330 polynomial features + features = [] + for exponents in POWERS: # 330 rows of 7 exponents each + val = 1.0 + for j in range(7): + if exponents[j] != 0: + val *= x[j] ** exponents[j] + features.append(val) + + # Dot product with coefficients + rgb = [] + for c in range(3): + val = INTERCEPT[c] + for i in range(330): + val += features[i] * COEF[i][c] + rgb.append(clamp(int(val), 0, 255)) + + return tuple(rgb) +``` + +### 4.4 Multi-Color Blending + +`blend_color_multi()` does **sequential pairwise accumulation**: + +```python +# Start with first color +result = colors[0] +accumulated_weight = weights[0] + +for i in range(1, len(colors)): + new_total = accumulated_weight + weights[i] + t = weights[i] / new_total + result = filament_mixer_lerp(*result, *colors[i], t) + accumulated_weight = new_total +``` + +This is order-dependent but matches the slicer's display. + +### 4.5 Porting Notes + +- The polynomial evaluation is pure math — no external dependencies +- With numpy: vectorize the feature computation across all 330 monomials +- The `POWERS` table is sparse (most exponents are 0) — can skip zero-power terms +- A `_linear_float` variant exists that converts linear → sRGB before blending, then sRGB → linear after — use for Blender's linear color space +- Coefficients can be stored inline in Python source or as a JSON data file + +### 4.6 Display Color Computation Priority + +From `compute_mixed_filament_display_color()` (L1399-1458): + +1. **Bias-apparent blend** — if component bias enabled, adjust percentages by nozzle diameter +2. **Manual pattern sequence** — count digit frequencies, build weighted sequence, multi-blend +3. **Gradient sequence** — ≥3 gradient_component_ids, weighted sequence, multi-blend +4. **Effective pair preview** — Bresenham-style interleaved A/B sequence from mix_b_percent +5. **Simple ratio blend** — `blend(colorA, colorB, 100-mix_b, mix_b)` +6. **Fallback** — `#26A69A` (teal) + +For our addon's purposes, a simplified version covering cases 2, 3, and 5 covers the vast majority of real-world files. + +--- + +## 5. Config Keys + +### 5.1 Keys in `project_settings.config` (13 keys) + +From `PresetBundle.cpp` L62-86 (`s_project_options`): + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `mixed_filament_definitions` | `coString` | `""` | The serialized definitions string | +| `mixed_filament_gradient_mode` | `coBool` | `false` | Height-weighted cadence toggle | +| `mixed_filament_height_lower_bound` | `coFloat` | `0.04` | Gradient lower bound (mm) | +| `mixed_filament_height_upper_bound` | `coFloat` | `0.16` | Gradient upper bound (mm) | +| `mixed_filament_advanced_dithering` | `coBool` | `false` | Ordered dithering pattern | +| `mixed_filament_component_bias_enabled` | `coBool` | `false` | Per-row nozzle-diameter bias control | +| `mixed_filament_surface_indentation` | `coFloat` | `0.0` | XY offset for mixed regions (±2.0mm) | +| `mixed_filament_region_collapse` | `coBool` | `true` | Merge same-color mixed regions | +| `mixed_color_layer_height_a` | `coFloat` | `0.0` | Dithering cadence height A | +| `mixed_color_layer_height_b` | `coFloat` | `0.0` | Dithering cadence height B | +| `dithering_z_step_size` | `coFloat` | `0.0` | Layer height in Z dithering zones | +| `dithering_local_z_mode` | `coBool` | `false` | Local Z dithering toggle | +| `dithering_step_painted_zones_only` | `coBool` | `true` | Limit Z step to painted zones | + +### 5.2 Keys NOT in project_settings (print preset only) + +These are stored in the print preset, not the project config. Our passthrough should handle them if present, but they won't appear in `project_settings.config` under normal circumstances. + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `mixed_filament_pointillism_pixel_size` | `coFloat` | `0.0` | Same-layer pointillism segment length | +| `mixed_filament_pointillism_line_gap` | `coFloat` | `0.0` | Pointillism spacing | +| `local_z_wipe_tower_purge_lines` | `coFloat` | `3.0` | Wipe tower purge for local Z | + +--- + +## 6. Resolution Pipeline (Slicer-Side Only) + +We don't need to implement this — it's how the slicer decides which physical filament to extrude on each layer. Understanding it informs what metadata matters for round-trip. + +**Priority order per extrusion entity:** + +1. **Same-Layer Pointillism** (`distribution_mode == 1`): Paths split into segments of `pointillism_pixel_size` length, each segment assigned from a weighted repeating sequence. Phase advances by layer for offset. +2. **Grouped Manual Pattern** (pattern contains `,`): Perimeters split by index, each gets a physical filament from the corresponding pattern group. +3. **Height-weighted cadence** (`layer_height_a/b > 0`): Z-position-based cycling. +4. **Manual pattern** (flat, no groups): `layer_index % pattern_length` → digit → physical filament. +5. **Gradient sequence** (3+ `gradient_component_ids`): Weighted sequence, `layer_index % len`. +6. **Advanced dithering**: Bresenham-style phase-shifted interleaving. +7. **Simple ratio cycle**: `layer_index % (ratio_a + ratio_b) < ratio_a` → component_a, else component_b. + +--- + +## 7. Implementation Tasks + +### 7.1 Passthrough Verification 🟢 + +**Effort:** Small — integration test only + +Our existing passthrough preserves arbitrary JSON keys in `project_settings.config`. Verify all 13 keys round-trip without data loss. + +**Action:** Add a round-trip integration test with a FullSpectrum reference file. + +**Files:** Test files only. + +### 7.2 Detection 🟢 + +**Effort:** ~20 lines + +Add FullSpectrum detection to `detect_vendor()` in `import_3mf/slicer/detection.py`. Check if `project_settings.config` JSON contains `mixed_filament_definitions` with a non-empty value. + +**Decision needed:** New vendor string (`"orca_fullspectrum"`) vs sub-flag on `"orca"`. Flag approach is cleaner since the file format is Orca-compatible. + +**Files:** `import_3mf/slicer/detection.py` + +### 7.3 Core Module — Parse, Serialize, Compute Colors 🟡 + +**Effort:** Medium — new module, ~300-400 lines + +**New file:** `common/mixed_filaments.py` + +Contents: +1. **`MixedFilament` dataclass** mirroring the C++ struct (all fields from §2.2) +2. **`parse_mixed_filament_definitions(defs_string) -> list[MixedFilament]`** — port of `parse_row_definition()` + semicolon split +3. **`serialize_mixed_filament_definitions(entries) -> str`** — port of `serialize_custom_entries()` +4. **`compute_display_color(entry, physical_colors) -> str`** — simplified `compute_mixed_filament_display_color()` +5. **`resolve_virtual_filament_index(filament_id, num_physical, entries) -> int`** — port of `mixed_index_from_filament_id()` +6. **`normalize_manual_pattern(pattern) -> str`** — validation + canonicalization +7. **`auto_generate_pairs(num_physical) -> list[MixedFilament]`** — C(N,2) pairwise generation + +### 7.4 FilamentMixer Port 🟡 + +**Effort:** Medium — data extraction + ~50 lines of logic + +**New file:** `common/filament_mixer.py` + +Contents: +1. `POWERS`, `COEF`, `INTERCEPT` tables extracted from `filament_mixer_model.h` +2. `filament_mixer_lerp(r1, g1, b1, r2, g2, b2, t) -> (r, g, b)` — polynomial evaluation +3. `filament_mixer_lerp_linear(r1, g1, b1, r2, g2, b2, t) -> (r, g, b)` — linear-space variant for Blender +4. `blend_multi(color_percents: list[tuple[str, int]]) -> str` — sequential pairwise accumulation +5. `blend_two(hex_a, hex_b, ratio_a, ratio_b) -> str` — convenience wrapper + +**Data storage decision:** Inline in Python source (~35KB), separate JSON, or numpy `.npy`. Inline is simplest for a Blender addon. The `POWERS` table is especially sparse — could compress by only storing non-zero exponents. + +**Testing:** Compare output against known C++ results from reference files. The MIT license permits porting the coefficient data. + +### 7.5 Import — Extend Paint Code Table 🟡 + +**Effort:** Small-medium + +Current `ORCA_PAINT_TO_INDEX` / `ORCA_FILAMENT_CODES` stop around index 29-32. Virtual filament IDs can reach 44+ (PeggyPalette has 40 definitions with 4 physical filaments). + +**Changes:** +- Extend tables to cover indices up to at least 64 +- Or compute codes programmatically (the Bambu hex series has a pattern) +- When a `paint_color` maps to a virtual index, look up the computed display color from parsed definitions +- The `filament_colour` array in `project_settings.config` already includes virtual display colors appended after physical ones + +**Files:** `import_3mf/slicer/paint.py`, `export_3mf/materials/base.py` + +### 7.6 Import — Store Definitions as Custom Properties 🟢 + +**Effort:** Small + +Store parsed data as scene-level custom properties for round-trip: +- `scene["3mf_mixed_filament_definitions"]` = raw string (exact round-trip) +- Optionally structured properties for UI display + +**Files:** `import_3mf/slicer/colors.py` (reading), `import_3mf/scene.py` (storing) + +### 7.7 Export — Serialize Definitions 🟡 + +**Effort:** Medium + +In the OrcaExporter: +- If mixed filament definitions exist (stashed from import or user-created), serialize back to `mixed_filament_definitions` +- Extend `ctx.vertex_colors` to include virtual filament entries with indices > `num_physical` +- Extend `ORCA_FILAMENT_CODES` for virtual indices +- Write computed display colors into `filament_colour` array (physical first, then virtual) +- Preserve all 13 project-settings keys + +**Critical:** `paint_color` attribute uses the same Bambu hex code series for physical and virtual. Triangle painted with virtual filament 5 (first enabled mixed entry, 4 physical filaments) gets the code for index 5. + +**Files:** `export_3mf/orca.py`, `export_3mf/materials/base.py`, `export_3mf/context.py` + +### 7.8 MMU Paint UI — Virtual Filament Palette 🔴 + +**Effort:** Large — full UI feature + +**User spec:** +- The entire advanced (mixed filament) feature is **hidden by default**. It becomes visible either when the user explicitly enables it in addon Preferences, OR when importing a file that contains `mixed_filament_definitions`. +- The "Mix Colors" section lives in a **separate, collapsed-by-default sub-panel** (dropdown) inside the texture paint mode panel only — NOT in the seam or support paint panels. It should be visually distinct from the regular filament palette above it. +- Within that sub-panel: show/add/remove/reorder virtual mixed filaments. + +**UI structure:** +``` +[MMU Paint Panel - Texture Paint mode only] + ┌─────────────────────────────────────┐ + │ [Regular filament palette here] │ + ├─────────────────────────────────────┤ + │ ▶ Mix Colors (collapsed by default)│ ← separate sub-panel + │ [Mixed filament list] │ + │ [+ Add Mix] [× Remove] │ + │ Per-entry: A picker, B picker, │ + │ ratio slider, mode dropdown, │ + │ pattern field, color swatch │ + └─────────────────────────────────────┘ +``` + +**Visibility logic:** +- Addon preference: `show_mixed_filaments: BoolProperty` (default False) +- Scene property: `has_mixed_filaments: BoolProperty` (set True on import if defs found) +- Panel polls: `show_mixed_filaments OR has_mixed_filaments` +- The "Mix Colors" sub-panel is its own `bpy.types.Panel` with `bl_parent_id` pointing to the main MMU panel — this lets it collapse independently + +**New properties:** +- `MMUMixedFilamentItem` PropertyGroup with all MixedFilament fields +- Scene-level `CollectionProperty` on `MMUPaintSettings` +- Addon preference `show_mixed_filaments` in the existing preferences class in `__init__.py` + +**Files:** `paint/properties.py`, `paint/mmu_panel.py`, `paint/operators.py`, `__init__.py` + +### 7.9 Bake Pipeline — Virtual Filament Matching 🔴 + +**Effort:** Large — algorithm design needed + +Extend bake and quantize pipelines: +- Include virtual filament display colors in quantization palette +- Record virtual filament index when pixel quantizes to a virtual color +- Handle ambiguity: virtual colors can be very close (40%/50%/60% blends of same pair) + +**Files:** `paint/bake.py`, `paint/quantize.py` + +--- + +## 8. Data Flow + +``` +IMPORT: + project_settings.config JSON + → read "mixed_filament_definitions" string + → parse_mixed_filament_definitions() → [MixedFilament, ...] + → read "filament_colour" array → physical colors + → compute_display_color() for each enabled entry → virtual colors + → extend palette: physical + virtual colors + → paint_color attribute on triangles → ORCA_PAINT_TO_INDEX → filament index + → index ≤ num_physical: physical filament + → index > num_physical: virtual filament (look up by enabled_index) + → assign colors to paint texture + → store raw definitions string as scene property + +EXPORT: + MMU Paint texture + definitions (stashed or user-created) + → serialize_mixed_filament_definitions() → string + → write to project_settings.config JSON + → physical + virtual display colors → "filament_colour" array + → per-triangle filament index → ORCA_FILAMENT_CODES[index] → paint_color + → preserve all 13 mixed/dithering config keys +``` + +--- + +## 9. Risks & Open Questions + +### Known Risks + +1. **FilamentMixer accuracy** — Python port must match C++ polynomial evaluation exactly. Off-by-one in integer clamping or feature computation produces wrong colors. Unit tests against known C++ results are essential. + +2. **Paint code table overflow** — Current table stops around index 32. Must verify the Bambu hex series pattern continues predictably, or compute codes programmatically. + +3. **stable_id management** — When users create new mixed filaments in Blender, stable_ids must not collide with imported ones. The slicer uses `dedupe_stable_id()` with a seen-set. We should replicate this. + +4. **auto_generate vs custom rows** — The slicer auto-generates C(N,2) pairs and separately tracks custom rows. On export, decide: auto-generate all, or only emit what the user defined? Emitting only defined rows is safer for round-trip. + +5. **Quantization ambiguity** — Virtual filament colors (e.g., 40% vs 50% vs 60% blends of same pair) can be very similar. The HSV-weighted distance metric may need priority rules or minimum-distance thresholds. + +### Open Questions + +1. **Detection: new vendor or flag?** `"orca_fullspectrum"` vs `vendor="orca"` with `has_mixed=True`. Flag is cleaner since the format is Orca-compatible. + +2. **Coefficient storage format?** Inline Python (~35KB), JSON file, or numpy `.npy`? Inline avoids file I/O at import time. + +3. **Phase 1 scope?** Ship passthrough + display first (tasks 7.1–7.7), defer full editing (7.8–7.9) to phase 2? + +4. **Pointillism/grouped patterns in UI?** These are slicer-side resolution only. Show pattern field in UI for authoring, but don't try to preview the per-perimeter effect. + +5. **Fallback for missing FilamentMixer?** Offer simple sRGB lerp as a fast path when pigment accuracy isn't needed? + +--- + +## 10. Implementation Order + +| Phase | Task | Section | Complexity | +|-------|------|---------|------------| +| 1 | Passthrough verification | §7.1 | 🟢 | +| 1 | Core parse/serialize module | §7.3 | 🟡 | +| 1 | FilamentMixer port | §7.4 | 🟡 | +| 1 | Detection | §7.2 | 🟢 | +| 1 | Import display colors | §7.5 + §7.6 | 🟡 | +| 1 | Export serialization | §7.7 | 🟡 | +| 2 | MMU Paint UI | §7.8 | 🔴 | +| 2 | Bake virtual matching | §7.9 | 🔴 | + +Phase 1 gets faithful import → display → export round-trip without the full editing UI. +Phase 2 adds authoring capabilities in Blender. + +--- + +## 11. Source File Reference + +### FullSpectrum Source (read during audit) + +| File | What we read | Key findings | +|------|-------------|--------------| +| `src/libslic3r/MixedFilament.hpp` | Full (L1-400) | MixedFilament struct, MixedFilamentManager class, DistributionMode enum | +| `src/libslic3r/MixedFilament.cpp` | L1-2207 | serialize/parse/resolve/auto_generate/color computation/pattern normalization | +| `src/libslic3r/filament_mixer_model.h` | Full (L1-812) | POWERS/COEF/INTERCEPT tables, compute_poly_features(), lerp() | +| `src/libslic3r/filament_mixer.cpp` | Full (L1-82) | Wrapper functions, linear-space variant | +| `src/libslic3r/PrintConfig.cpp` | Mixed/dithering options | 16 config option definitions with types and defaults | +| `src/libslic3r/PresetBundle.cpp` | L62-86 | 13 project-settings keys (s_project_options) | +| `src/libslic3r/Format/bbs_3mf.cpp` | L611-622 | Import reads definitions → auto_generate → load_custom_entries → total_filaments | +| `src/libslic3r/GCode.cpp` | L3797-5345 | Pointillism path splitting, grouped pattern perimeter splitting | +| `src/libslic3r/GCode/ToolOrdering.cpp` | L30-134 | resolve_mixed_with_layer_heights(), height-weighted cadence, resolve_perimeter() | + +### Our Addon Files (to modify) + +| File | Changes needed | +|------|---------------| +| `common/mixed_filaments.py` | **NEW** — MixedFilament dataclass, parse/serialize, display color computation | +| `common/filament_mixer.py` | **NEW** — polynomial regression color model port | +| `import_3mf/slicer/detection.py` | Add FullSpectrum detection | +| `import_3mf/slicer/colors.py` | Read + parse mixed_filament_definitions, compute virtual colors | +| `import_3mf/slicer/paint.py` | Extend ORCA_PAINT_TO_INDEX / ORCA_FILAMENT_CODES for virtual indices | +| `import_3mf/scene.py` | Store definitions as scene custom property | +| `export_3mf/orca.py` | Serialize definitions, write virtual display colors, preserve config keys | +| `export_3mf/materials/base.py` | Extend filament code tables | +| `export_3mf/context.py` | Possibly extend ExportContext for mixed filament state | +| `paint/properties.py` | (Phase 2) MMUMixedFilamentItem PropertyGroup | +| `paint/mmu_panel.py` | (Phase 2) Virtual filament UI | +| `paint/operators.py` | (Phase 2) Mixed filament creation/editing operators | +| `paint/bake.py` | (Phase 2) Virtual color quantization | +| `paint/quantize.py` | (Phase 2) Extended palette matching | + +### Reference Files + +| File | Description | +|------|-------------| +| `Multi-color Cute Dragon by IK3Digital/` | Simple 6-definition file (u1–u6), 6 physical filaments | +| `ReferenceFiles/PeggyPalette38+Mini+BRYW/` | Complex 40-definition file with patterns, 4 physical (CMYW) | +| `ReferenceFiles/OrcaSlicer-FullSpectrum-main/` | Full slicer source code | diff --git a/docs/generate_site_docs.py b/docs/generate_site_docs.py new file mode 100644 index 0000000..84864b8 --- /dev/null +++ b/docs/generate_site_docs.py @@ -0,0 +1,1224 @@ +#!/usr/bin/env python3 +""" +Generate Nuxt Content markdown docs from the Python API source. + +This script parses ``io_mesh_3mf/api.py`` (and common modules) with the +``ast`` module — no Blender or bpy required — and produces the 5 markdown +files consumed by the Nuxt site's ``@nuxt/content`` docs collection. + +Usage:: + + # From the repo root: + python docs/generate_site_docs.py + + # Custom output directory (e.g. the Nuxt site's content folder): + python docs/generate_site_docs.py --output-dir /path/to/content/docs/3mf + +The generated files are: + + 1.guide.md — Getting Started (template + export table from source) + 2.recipes.md — Recipes (template with verified signatures) + 3.api-reference.md — Full API reference (auto-generated from source) + 4.discovery.md — Discovery functions (auto-generated from source) + 5.building-blocks.md — Building block modules (auto-generated from source) +""" + +from __future__ import annotations + +import argparse +import ast +import os +import re +import sys +import textwrap +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# ── Paths ───────────────────────────────────────────────────────────────── +REPO_ROOT = Path(__file__).resolve().parent.parent +API_PY = REPO_ROOT / "io_mesh_3mf" / "api.py" +COLORS_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "colors.py" +UNITS_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "units.py" +TYPES_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "types.py" +SEGMENTATION_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "segmentation.py" +EXTENSIONS_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "extensions.py" +METADATA_PY = REPO_ROOT / "io_mesh_3mf" / "common" / "metadata.py" + + +# ── AST helpers ─────────────────────────────────────────────────────────── + +@dataclass +class ParamInfo: + """Extracted parameter metadata.""" + name: str + annotation: str = "" + default: str = "" + description: str = "" # from docstring :param: lines + + +@dataclass +class FuncInfo: + """Extracted function metadata.""" + name: str + params: List[ParamInfo] = field(default_factory=list) + return_annotation: str = "" + docstring: str = "" + lineno: int = 0 + + +@dataclass +class ClassInfo: + """Extracted dataclass metadata.""" + name: str + fields: List[ParamInfo] = field(default_factory=list) + docstring: str = "" + lineno: int = 0 + + +@dataclass +class ConstInfo: + """Extracted module-level constant.""" + name: str + value_repr: str = "" + comment: str = "" + lineno: int = 0 + + +def _unparse_annotation(node: ast.expr) -> str: + """Convert an AST annotation node to a readable string.""" + try: + return ast.unparse(node) + except Exception: + return "" + + +def _unparse_default(node: ast.expr) -> str: + """Convert default value node to a string.""" + try: + return ast.unparse(node) + except Exception: + return "" + + +def _get_docstring(node: ast.AST) -> str: + """Extract docstring from a function or class node.""" + if (node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant)): + val = node.body[0].value + return val.value if isinstance(val.value, str) else "" + return "" + + +def _parse_param_descriptions(docstring: str) -> Dict[str, str]: + """Extract :param name: description lines from a docstring.""" + descriptions: Dict[str, str] = {} + if not docstring: + return descriptions + pattern = re.compile(r':param\s+(\w+):\s*(.*?)(?=\n\s*:|$)', re.DOTALL) + for match in pattern.finditer(docstring): + name = match.group(1) + desc = match.group(2).strip() + # Collapse continuation lines + desc = re.sub(r'\s*\n\s+', ' ', desc) + descriptions[name] = desc + return descriptions + + +def _parse_return_description(docstring: str) -> str: + """Extract :return: description from a docstring. + + Stops at Example:: blocks to avoid leaking code into the description. + """ + match = re.search(r':return:\s*(.*?)(?=\n\s*:|\n\s*Example::|$)', docstring, re.DOTALL) + if match: + desc = match.group(1).strip() + return re.sub(r'\s*\n\s+', ' ', desc) + return "" + + +def extract_functions(tree: ast.Module, names: Optional[set] = None) -> List[FuncInfo]: + """Extract function definitions from a module AST.""" + results = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name.startswith('_'): + continue + if names and node.name not in names: + continue + + docstring = _get_docstring(node) + param_descs = _parse_param_descriptions(docstring) + + params = [] + args = node.args + # Positional and keyword-only args + all_args = list(args.args) + list(args.kwonlyargs) + # Defaults: args.defaults align to the END of args.args; + # args.kw_defaults align 1:1 with args.kwonlyargs + num_pos = len(args.args) + pos_defaults = [None] * (num_pos - len(args.defaults)) + list(args.defaults) + kw_defaults = list(args.kw_defaults) + + for i, arg in enumerate(all_args): + if arg.arg == 'self': + continue + ann = _unparse_annotation(arg.annotation) if arg.annotation else "" + # Determine default value + default = "" + if i < num_pos: + d = pos_defaults[i] if i < len(pos_defaults) else None + if d is not None: + default = _unparse_default(d) + else: + kw_idx = i - num_pos + d = kw_defaults[kw_idx] if kw_idx < len(kw_defaults) else None + if d is not None: + default = _unparse_default(d) + + params.append(ParamInfo( + name=arg.arg, + annotation=ann, + default=default, + description=param_descs.get(arg.arg, ""), + )) + + # **kwargs + if args.kwarg: + params.append(ParamInfo( + name=f"**{args.kwarg.arg}", + annotation="", + default="", + description=param_descs.get(args.kwarg.arg, ""), + )) + + ret = _unparse_annotation(node.returns) if node.returns else "" + + results.append(FuncInfo( + name=node.name, + params=params, + return_annotation=ret, + docstring=docstring, + lineno=node.lineno, + )) + return results + + +def extract_classes(tree: ast.Module, names: Optional[set] = None) -> List[ClassInfo]: + """Extract dataclass definitions from a module AST.""" + results = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + if names and node.name not in names: + continue + docstring = _get_docstring(node) + fields = [] + for item in node.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + ann = _unparse_annotation(item.annotation) if item.annotation else "" + default = "" + if item.value is not None: + default = _unparse_default(item.value) + fields.append(ParamInfo( + name=item.target.id, + annotation=ann, + default=default, + )) + results.append(ClassInfo( + name=node.name, + fields=fields, + docstring=docstring, + lineno=node.lineno, + )) + return results + + +def extract_constants(tree: ast.Module, source_lines: List[str], + names: Optional[set] = None) -> List[ConstInfo]: + """Extract module-level constant assignments.""" + results = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + if names and target.id not in names: + continue + if target.id.startswith('_'): + continue + value_repr = _unparse_default(node.value) + # Check for inline #: comment on preceding line + comment = "" + if node.lineno >= 2: + prev = source_lines[node.lineno - 2].strip() + if prev.startswith("#:"): + comment = prev[2:].strip() + results.append(ConstInfo( + name=target.id, + value_repr=value_repr, + comment=comment, + lineno=node.lineno, + )) + return results + + +def _extract_attributes_from_docstring(docstring: str) -> Dict[str, str]: + """Extract Attributes: section from a dataclass docstring. + + Returns {field_name: description}. + Handles multi-line indented descriptions and nested list items. + """ + descs: Dict[str, str] = {} + if not docstring: + return descs + # Find "Attributes:" block — grab everything after it + match = re.search(r'Attributes:\s*\n(.*)', docstring, re.DOTALL) + if not match: + return descs + block = match.group(1) + current_name = None + current_desc: List[str] = [] + for line in block.split('\n'): + stripped = line.strip() + # Lines like "status: ``"FINISHED"`` on success, ..." + field_match = re.match(r'^(\w+):\s*(.*)', stripped) + if field_match and not stripped.startswith('-'): + if current_name: + descs[current_name] = ' '.join(current_desc).strip() + current_name = field_match.group(1) + current_desc = [field_match.group(2)] + elif current_name and stripped: + # Continuation line — append unless it's a new section + current_desc.append(stripped) + if current_name: + descs[current_name] = ' '.join(current_desc).strip() + return descs + + +# ── Module parsers ──────────────────────────────────────────────────────── + +def _parse_module(path: Path) -> Tuple[ast.Module, List[str]]: + """Parse a Python file, returning (AST, source_lines).""" + source = path.read_text(encoding='utf-8') + return ast.parse(source), source.splitlines() + + +def _extract_color_functions(tree: ast.Module) -> List[FuncInfo]: + """Extract public functions from colors.py.""" + target = { + "srgb_to_linear", "linear_to_srgb", + "hex_to_rgb", "hex_to_linear_rgb", + "rgb_to_hex", "linear_rgb_to_hex", + } + return extract_functions(tree, target) + + +def _extract_unit_dicts(tree: ast.Module, source_lines: List[str]) -> List[ConstInfo]: + """Extract dict constants from units.py.""" + results = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and not target.id.startswith('_'): + # Only include the top-level dicts and functions + results.append(ConstInfo( + name=target.id, + value_repr="dict" if isinstance(node.value, ast.Dict) else _unparse_default(node.value), + lineno=node.lineno, + )) + return results + + +def _extract_unit_functions(tree: ast.Module) -> List[FuncInfo]: + """Extract public functions from units.py.""" + return [f for f in extract_functions(tree) if not f.name.startswith('_')] + + +def _extract_types_classes(tree: ast.Module) -> List[ClassInfo]: + """Extract all dataclass definitions from types.py.""" + return extract_classes(tree) + + +def _extract_segmentation_classes(tree: ast.Module) -> List[ClassInfo]: + """Extract key classes from segmentation.py.""" + target = {"SegmentationDecoder", "SegmentationEncoder", "SegmentationNode", "TriangleSubdivider"} + return extract_classes(tree, target) + + +def _extract_extension_classes(tree: ast.Module) -> List[ClassInfo]: + """Extract key classes from extensions.py.""" + target = {"Extension", "ExtensionManager"} + return extract_classes(tree, target) + + +# ── Markdown generation ────────────────────────────────────────────────── + +def _md_escape(text: str) -> str: + """Escape pipe characters for markdown tables.""" + return text.replace('|', '\\|').replace('\n', ' ') + + +def _clean_description(desc: str) -> str: + """Clean up RST-style markup in descriptions for markdown.""" + # Convert ``code`` to `code` + desc = re.sub(r'``(.*?)``', r'`\1`', desc) + # Convert :func:`name` to `name` + desc = re.sub(r':(?:func|class|mod|meth|attr):`([^`]+)`', r'`\1`', desc) + # Convert *text* (RST emphasis) — leave as-is (also valid markdown) + return desc.strip() + + +# Canonical order & descriptions for API_CAPABILITIES entries +_CAPABILITY_COMMENTS = { + "import": "import_3mf() available", + "export": "export_3mf() available", + "inspect": "inspect_3mf() available", + "batch": "batch_import/batch_export available", + "callbacks": "on_progress, on_warning, on_object_created", + "target_collection": "import to specific collection", + "orca_format": "Orca/BambuStudio export format", + "prusa_format": "PrusaSlicer export format", + "paint_mode": "MMU paint segmentation", + "project_template": "Custom Orca project template", + "object_settings": "Per-object Orca settings", + "building_blocks": "colors, types, segmentation sub-namespaces", + "global_scale": "Scale multiplier parameter (import & export)", + "compression": "Configurable ZIP compression level (export)", + "thumbnail": "Thumbnail generation (export)", + "use_components": "Component instancing for linked duplicates (export)", + "auto_smooth": "Auto smooth-by-angle on import", + "subdivision_depth": "Paint segmentation subdivision depth control", + "flatten_hierarchy": "Flatten parented meshes into top-level build items (export)", + "modifier_parts": "Orca/BambuStudio modifier part subtypes (import, export, inspect)", +} + + +def _format_capabilities(value_repr: str) -> str: + """Format a frozenset literal into a readable multi-line block.""" + # Extract string items from the repr + items = re.findall(r"'([^']+)'", value_repr) + # Sort by canonical order + ordered = [k for k in _CAPABILITY_COMMENTS if k in items] + # Add any extras not in our canonical list + for item in items: + if item not in ordered: + ordered.append(item) + + lines = ["API_CAPABILITIES = frozenset({"] + for cap in ordered: + comment = _CAPABILITY_COMMENTS.get(cap, "") + pad = max(1, 23 - len(cap)) + if comment: + lines.append(f' "{cap}",{" " * pad}# {comment}') + else: + lines.append(f' "{cap}",') + lines.append("})") + return "\n".join(lines) + + +def _signature_str(func: FuncInfo) -> str: + """Build a Python function signature string.""" + parts = [] + has_kw_only = False + for p in func.params: + if p.name.startswith('**'): + parts.append(p.name) + continue + piece = p.name + if p.annotation: + # Simplify Optional[X] to X | None + ann = p.annotation + ann = re.sub(r'Optional\[(.+)\]', r'\1 | None', ann) + piece += f": {ann}" + if p.default: + piece += f" = {p.default}" + parts.append(piece) + + sig = f"def {func.name}(" + # Check if there are keyword-only params (after a bare *) + # We detect this by checking if the original function had keyword-only args + # For simplicity, just join all params + sig += ",\n ".join(parts) + sig += f",\n)" + if func.return_annotation: + ret = func.return_annotation + ret = re.sub(r'Optional\[(.+)\]', r'\1 | None', ret) + sig += f" -> {ret}" + return sig + + +def _param_table(func: FuncInfo) -> str: + """Generate a markdown parameter table for a function.""" + lines = [ + "| Parameter | Type | Default | Description |", + "| --- | --- | --- | --- |", + ] + for p in func.params: + name = f"`{p.name}`" + ann = f"`{p.annotation}`" if p.annotation else "" + # Simplify annotation display + ann = ann.replace('Optional[', '').rstrip(']`') + '`' if 'Optional[' in ann else ann + if 'Optional' in ann: + ann = ann.replace('Optional[', '').replace(']', ' | None') + # Fix None annotations + ann = re.sub(r'Optional\[(.+?)\]', r'\1 \\| None', ann) + if p.name.startswith('**'): + default = "" + elif p.default: + default = f"`{p.default}`" + else: + default = "*required*" + desc = _md_escape(_clean_description(p.description)) + lines.append(f"| {name} | {ann} | {default} | {desc} |") + return "\n".join(lines) + + +def _simple_param_table(func: FuncInfo) -> str: + """Generate a 3-column parameter table (no default column).""" + lines = [ + "| Parameter | Type | Description |", + "| --- | --- | --- |", + ] + for p in func.params: + name = f"`{p.name}`" + ann = f"`{p.annotation}`" if p.annotation else "" + desc = _md_escape(_clean_description(p.description)) + lines.append(f"| {name} | {ann} | {desc} |") + return "\n".join(lines) + + +def _field_table(cls: ClassInfo) -> str: + """Generate a field table for a dataclass.""" + attr_descs = _extract_attributes_from_docstring(cls.docstring) + lines = [ + "| Field | Type | Description |", + "| --- | --- | --- |", + ] + for f in cls.fields: + name = f"`{f.name}`" + ann = f"`{f.annotation}`" if f.annotation else "" + desc = _md_escape(_clean_description(attr_descs.get(f.name, ""))) + lines.append(f"| {name} | {ann} | {desc} |") + return "\n".join(lines) + + +# ── Page generators ────────────────────────────────────────────────────── + +def generate_guide(api_funcs: List[FuncInfo], consts: List[ConstInfo]) -> str: + """Generate 1.guide.md — Getting Started page.""" + # This is mostly prose with some verified references to the API + return textwrap.dedent("""\ + --- + title: Getting Started + description: Programmatic 3MF import, export, and inspection for Blender — without bpy.ops. + --- + + # Getting Started + + The public API in `io_mesh_3mf.api` provides headless/programmatic access to the full 3MF pipeline. It runs the same code as the Blender operators but skips UI-specific behaviour (progress bars, popups, camera zoom), making it suitable for: + + - **CLI automation** — batch processing from Blender's `--python` mode + - **Addon integration** — other Blender addons importing/exporting 3MF + - **Headless pipelines** — render farms, CI/CD, asset processing + - **Custom workflows** — building on top of the low-level building blocks + + ## Quick Start + + ```python + from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + + # Import a 3MF file + result = import_3mf("/path/to/model.3mf") + print(result.status, result.num_loaded) + + # Export selected objects + result = export_3mf("/path/to/output.3mf", use_selection=True) + print(result.status, result.num_written) + + # Inspect without importing (no Blender objects created) + info = inspect_3mf("/path/to/model.3mf") + print(info.unit, info.num_objects, info.num_triangles_total) + ``` + + All functions return lightweight dataclasses — they never raise exceptions for normal failures (corrupt files, empty scenes, etc.). Check `result.status` instead. + + ## Export Format Reference + + The export dispatch uses a three-way mode controlled by `use_orca_format`: + + | `use_orca_format` | `mmu_slicer_format` | Output | + | --- | --- | --- | + | `"AUTO"` | — | Chooses best format based on scene content | + | `"STANDARD"` | — | Spec-compliant single-model 3MF | + | `"PAINT"` | `"ORCA"` | Multi-file Orca/Bambu structure with `paint_color` attributes | + | `"PAINT"` | `"PRUSA"` | Single-file with `slic3rpe:mmu_segmentation` hash strings | + + In **AUTO** mode the addon inspects your scene and picks the best path: + + - Objects with MMU paint textures → Orca exporter with segmentation + - Objects with material slots → Standard exporter with basematerials/colorgroups + - Geometry-only objects → Standard exporter, geometry only + - If `project_template` or `object_settings` is provided → Orca exporter + + ## Callbacks + + All three callback types are optional and work the same way across `import_3mf`, `export_3mf`, and the batch helpers. + + ```python + def on_progress(percentage: int, message: str): + \\"\\"\\"Called with 0-100 percentage and a status message.\\"\\"\\" + print(f"[{percentage:3d}%] {message}") + + def on_warning(message: str): + \\"\\"\\"Called for each warning (non-manifold geometry, missing data, etc.).\\"\\"\\" + print(f"WARNING: {message}") + + def on_object_created(blender_object, resource_id: str): + \\"\\"\\"Called after each Blender object is built during import.\\"\\"\\" + blender_object.color = (1, 0, 0, 1) # Tint red + ``` + + ## Error Handling + + All API functions return result dataclasses instead of raising exceptions. Check `result.status`: + + ```python + result = import_3mf("model.3mf") + if result.status == "FINISHED": + print(f"Success: {result.num_loaded} objects") + else: + print(f"Failed: {result.warnings}") + ``` + + - Archive-level errors (corrupt ZIP, missing model files) set `status = "CANCELLED"`. + - Per-object warnings (non-manifold geometry, missing textures) are collected in `warnings` but don't prevent completion. + - `inspect_3mf` uses `status = "OK"` / `"ERROR"` with a separate `error_message` field. + + ## CLI Usage + + Run from the command line using Blender's `--python` flag: + + ```bash + # Inspect a file + blender --background --python-expr " + from io_mesh_3mf.api import inspect_3mf + info = inspect_3mf('model.3mf') + print(f'{info.num_objects} objects, {info.num_triangles_total} triangles') + " + + # Batch convert + blender --background --python my_script.py + ``` + + **Example script** (`convert_to_orca.py`): + + ```python + \\"\\"\\"Convert a standard 3MF to Orca Slicer format.\\"\\"\\" + import sys + from io_mesh_3mf.api import import_3mf, export_3mf + + input_path = sys.argv[sys.argv.index("--") + 1] + output_path = input_path.replace(".3mf", "_orca.3mf") + + result = import_3mf(input_path, import_materials="MATERIALS") + if result.status == "FINISHED": + export_result = export_3mf( + output_path, + objects=result.objects, + use_orca_format="AUTO", + ) + print(f"Converted: {export_result.num_written} objects → {output_path}") + ``` + + ```bash + blender --background --python convert_to_orca.py -- input.3mf + ``` + + ## Notes + + - **Blender context required** — `import_3mf` and `export_3mf` need `bpy.context`. They work in `--background` mode but not outside Blender entirely. + - **inspect_3mf is lightweight** — it only opens the ZIP and parses XML. No Blender objects, materials, or images are created. + - **Thread safety** — Blender's Python API is not thread-safe. Don't call these functions from background threads. + - **Batch isolation** — `batch_import` and `batch_export` catch per-file exceptions so one failure doesn't stop the batch. + - **API vs addon version** — `API_VERSION` tracks the API contract stability. It increments independently of the addon release version. + """).replace('\\"\\"\\"', '"""') + + +def generate_recipes() -> str: + """Generate 2.recipes.md — practical code patterns.""" + return textwrap.dedent("""\ + --- + title: Recipes + description: Practical code patterns for common 3MF workflows. + --- + + # Recipes + + Ready-to-use patterns for common workflows. + + ## Import with Material Painting + + ```python + from io_mesh_3mf.api import import_3mf + + result = import_3mf( + "/models/multicolor.3mf", + import_materials="PAINT", + import_location="ORIGIN", + ) + + for obj in result.objects: + print(f" {obj.name}: {len(obj.data.vertices)} verts") + ``` + + ## Import into a Specific Collection + + ```python + result = import_3mf( + "/models/part.3mf", + target_collection="Imported Parts", + reuse_materials=True, + ) + ``` + + ## Export for Orca Slicer + + The export dispatch uses a three-way mode: `AUTO`, `STANDARD`, or `PAINT`. + + - **AUTO** (default) — detects materials and paint data, choosing the best exporter automatically. + - **STANDARD** — always uses the spec-compliant StandardExporter. + - **PAINT** — forces segmentation export for multi-material painting. + + ```python + from io_mesh_3mf.api import export_3mf + import bpy + + cubes = [o for o in bpy.data.objects if o.type == "MESH" and "Cube" in o.name] + + result = export_3mf( + "/output/cubes.3mf", + objects=cubes, + use_orca_format="AUTO", + ) + print(f"Exported {result.num_written} objects") + ``` + + ## Export for PrusaSlicer with MMU Paint + + ```python + result = export_3mf( + "/output/painted.3mf", + use_orca_format="PAINT", + mmu_slicer_format="PRUSA", + use_selection=True, + ) + ``` + + ## Custom Orca Project Template + + Use a custom printer/filament profile extracted from Orca Slicer: + + ```python + result = export_3mf( + "/output/custom_printer.3mf", + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + project_template="/templates/bambu_x1c_asa.json", + object_settings={ + supports_obj: { + "layer_height": "0.12", + "wall_loops": "2", + "sparse_infill_density": "10%", + }, + detail_part: { + "layer_height": "0.08", + "outer_wall_speed": "50", + }, + }, + ) + ``` + + ::alert{type="info"} + **Getting custom templates:** Export a project from Orca Slicer as `.3mf`, open the archive with a ZIP tool, and extract `Metadata/project_settings.config`. This JSON file contains all printer, filament, and print settings. The addon patches `filament_colour` automatically based on your painted objects. + :: + + ## Round-Trip Conversion + + ```python + from io_mesh_3mf.api import import_3mf, export_3mf + + # Import from one format, export to another + result = import_3mf("/input/prusa_model.3mf", import_materials="PAINT") + if result.status == "FINISHED": + export_3mf( + "/output/orca_model.3mf", + objects=result.objects, + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + ) + ``` + + ## Inspect Without Importing + + ```python + from io_mesh_3mf.api import inspect_3mf + + info = inspect_3mf("/models/assembly.3mf") + + if info.status == "OK": + print(f"Unit: {info.unit}") + print(f"Objects: {info.num_objects}") + print(f"Total triangles: {info.num_triangles_total}") + print(f"Vendor: {info.vendor_format or 'standard'}") + print(f"Extensions: {info.extensions_used}") + + for obj in info.objects: + flags = [] + if obj["has_materials"]: + flags.append("materials") + if obj["has_segmentation"]: + flags.append("MMU paint") + print(f" {obj['name']}: {obj['num_triangles']} tris [{', '.join(flags)}]") + else: + print(f"Error: {info.error_message}") + ``` + + ## Batch Operations + + ```python + from io_mesh_3mf.api import batch_import, batch_export + import bpy + + # Import multiple files with per-file error isolation + results = batch_import( + ["part_a.3mf", "part_b.3mf", "part_c.3mf"], + import_materials="PAINT", + target_collection="Batch Import", + ) + + total = sum(r.num_loaded for r in results) + failed = [r for r in results if r.status != "FINISHED"] + print(f"Imported {total} objects, {len(failed)} failures") + + # Export multiple files + cubes = [o for o in bpy.data.objects if "Cube" in o.name] + spheres = [o for o in bpy.data.objects if "Sphere" in o.name] + + results = batch_export( + [ + ("cubes.3mf", cubes), + ("spheres.3mf", spheres), + ("everything.3mf", None), # None = all scene objects + ], + use_orca_format="AUTO", + ) + ``` + """) + + +def generate_api_reference( + funcs: List[FuncInfo], + classes: List[ClassInfo], + consts: List[ConstInfo], +) -> str: + """Generate 3.api-reference.md — complete reference page.""" + sections = [] + + # Frontmatter + sections.append(textwrap.dedent("""\ + --- + title: API Reference + description: Complete parameter reference for all 3MF API functions. + --- + + # API Reference + + All functions live in `io_mesh_3mf.api`. Import them directly: + + ```python + from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + ``` + """)) + + # Version & Capabilities + api_version = None + api_caps = None + for c in consts: + if c.name == "API_VERSION": + api_version = c + elif c.name == "API_CAPABILITIES": + api_caps = c + + sections.append("## Version & Capabilities\n") + if api_version: + sections.append(f"```python\nAPI_VERSION = {api_version.value_repr}\n" + f"API_VERSION_STRING = \".\".join(str(v) for v in API_VERSION)\n") + if api_caps: + # Format the frozenset nicely with one entry per line and comments + caps_repr = _format_capabilities(api_caps.value_repr) + sections.append(f"\n{caps_repr}\n```\n") + else: + sections.append("```\n") + + # Result Dataclasses + sections.append("## Result Dataclasses\n") + class_map = {c.name: c for c in classes} + for cls_name in ("ImportResult", "ExportResult", "InspectResult"): + cls = class_map.get(cls_name) + if cls: + sections.append(f"### {cls_name}\n") + sections.append(_field_table(cls)) + sections.append("") + + # Functions + func_map = {f.name: f for f in funcs} + for func_name in ("import_3mf", "export_3mf", "inspect_3mf", "batch_import", "batch_export"): + func = func_map.get(func_name) + if not func: + continue + + sections.append(f"## {func_name}\n") + + # Signature block + sig = _signature_str(func) + sections.append(f"```python\n{sig}\n```\n") + + # Brief description (first line of docstring) + if func.docstring: + first_line = func.docstring.strip().split('\n')[0] + sections.append(f"{_clean_description(first_line)}\n") + + # Parameter table + if func.params: + has_defaults = any(p.default for p in func.params) + if has_defaults: + sections.append(_param_table(func)) + else: + sections.append(_simple_param_table(func)) + sections.append("") + + # Return value + if func.return_annotation: + ret = func.return_annotation + ret = re.sub(r'Optional\[(.+)\]', r'\1 | None', ret) + ret_desc = _parse_return_description(func.docstring) + ret_text = f"`{ret}`" + if ret_desc: + ret_text += f" — {_clean_description(ret_desc)}" + sections.append(f"**Returns:** {ret_text}\n") + + # Callback Types + sections.append("## Callback Types\n") + sections.append("| Type | Signature | Description |") + sections.append("| --- | --- | --- |") + sections.append("| `ProgressCallback` | `(int, str) -> None` | `(percentage 0-100, message)` |") + sections.append("| `WarningCallback` | `(str,) -> None` | `(warning_message)` |") + sections.append("| `ObjectCreatedCallback` | `(Any, str) -> None` | `(blender_object, resource_id)` |") + sections.append("") + + return "\n".join(sections) + + +def generate_discovery(funcs: List[FuncInfo]) -> str: + """Generate 4.discovery.md — API discovery functions.""" + sections = [] + + sections.append(textwrap.dedent("""\ + --- + title: API Discovery + description: Detect and feature-check the 3MF API from other Blender addons. + --- + + # API Discovery + + Other Blender addons can detect and use the 3MF API at runtime. Three strategies are available, from simplest to most robust. + + ## Direct Import (recommended) + + If you know the addon is installed, a plain `try`/`except` is the simplest approach: + + ```python + try: + from io_mesh_3mf.api import import_3mf, export_3mf + except ImportError: + import_3mf = export_3mf = None + ``` + + This is Python-idiomatic and survives Blender restarts. + + ## Discovery Functions + """)) + + func_map = {f.name: f for f in funcs} + for func_name in ("is_available", "get_api", "has_capability", "check_version"): + func = func_map.get(func_name) + if not func: + continue + + sections.append(f"### {func_name}\n") + + sig = _signature_str(func) + sections.append(f"```python\n{sig}\n```\n") + + if func.docstring: + first_line = func.docstring.strip().split('\n')[0] + sections.append(f"{_clean_description(first_line)}\n") + + if func.params: + sections.append(_simple_param_table(func)) + sections.append("") + + if func.return_annotation: + ret = func.return_annotation + ret_desc = _parse_return_description(func.docstring) + ret_text = f"`{ret}`" + if ret_desc: + ret_text += f" — {_clean_description(ret_desc)}" + sections.append(f"**Returns:** {ret_text}\n") + + # Standalone helper + sections.append(textwrap.dedent("""\ + ## Standalone Discovery Helper + + For addons that want **zero runtime dependency** on the 3MF addon, copy `io_mesh_3mf/threemf_discovery.py` into your addon. It resolves the addon's import path automatically via `addon_utils`, caches the result, and works regardless of extension repo prefix or addon load order. + + ```python + # In your addon — no dependency on io_mesh_3mf at import time + from .threemf_discovery import get_threemf_api + + api = get_threemf_api() + if api is not None: + if api.has_capability("paint_mode"): + result = api.export_3mf("output.3mf", use_orca_format="PAINT") + ``` + + The standalone module also provides `import_3mf`, `export_3mf`, and `inspect_3mf` as convenience wrappers that return `None` if the 3MF addon isn't installed. + """)) + + return "\n".join(sections) + + +def generate_building_blocks( + color_funcs: List[FuncInfo], + unit_funcs: List[FuncInfo], + type_classes: List[ClassInfo], + seg_classes: List[ClassInfo], + ext_classes: List[ClassInfo], +) -> str: + """Generate 5.building-blocks.md — low-level module docs.""" + sections = [] + + sections.append(textwrap.dedent("""\ + --- + title: Building Blocks + description: Low-level modules re-exported by the API for custom workflows. + --- + + # Building Blocks + + The API re-exports common modules for custom workflows. These are the same modules used internally by the import/export pipeline. + + ```python + from io_mesh_3mf.api import colors, types, segmentation, units + ``` + + ## Colors + + `io_mesh_3mf.common.colors` — hex/RGB conversion and sRGB/linear transforms. + + """)) + + # Colors table + sections.append("| Function | Signature | Description |") + sections.append("| --- | --- | --- |") + for func in color_funcs: + params = ", ".join( + f"{p.name}: {p.annotation}" if p.annotation else p.name + for p in func.params + ) + ret = func.return_annotation or "" + sig = f"`({params}) -> {ret}`" if ret else f"`({params})`" + desc = "" + if func.docstring: + desc = _clean_description(func.docstring.strip().split('\n')[0]) + sections.append(f"| `{func.name}` | {sig} | {desc} |") + sections.append("") + + # Units + sections.append("## Units\n") + sections.append("`io_mesh_3mf.common.units` — unit conversion between 3MF and Blender.\n") + + sections.append("| Variable | Description |") + sections.append("| --- | --- |") + sections.append("| `threemf_to_metre` | Dict mapping 3MF unit strings to metre scale factors |") + sections.append("| `blender_to_metre` | Dict mapping Blender unit strings to metre scale factors |") + sections.append("") + + sections.append("| Function | Signature | Description |") + sections.append("| --- | --- | --- |") + for func in unit_funcs: + params = ", ".join( + f"{p.name}: {p.annotation}" if p.annotation else p.name + for p in func.params + ) + ret = func.return_annotation or "" + sig = f"`({params}) -> {ret}`" if ret else f"`({params})`" + desc = "" + if func.docstring: + desc = _clean_description(func.docstring.strip().split('\n')[0]) + sections.append(f"| `{func.name}` | {sig} | {desc} |") + sections.append("") + + # Types + sections.append("## Types (Dataclasses)\n") + sections.append("`io_mesh_3mf.common.types` — internal data structures used throughout the pipeline.\n") + sections.append("Key dataclasses:\n") + for cls in type_classes: + desc = "" + if cls.docstring: + desc = _clean_description(cls.docstring.strip().split('\n')[0]) + sections.append(f"- **`{cls.name}`** — {desc}") + sections.append("") + + # Segmentation + sections.append("## Segmentation Codec\n") + sections.append("`io_mesh_3mf.common.segmentation` — encode/decode hex segmentation strings " + "used for multi-material paint data.\n") + sections.append("| Class | Description |") + sections.append("| --- | --- |") + for cls in seg_classes: + desc = "" + if cls.docstring: + desc = _clean_description(cls.docstring.strip().split('\n')[0]) + sections.append(f"| `{cls.name}` | {desc} |") + sections.append("") + sections.append("The codec implements the recursive 4-bit nibble encoding used by Orca Slicer " + "and PrusaSlicer, where each nibble `xxyy` encodes the state (2 bits) and " + "split direction (2 bits) of a triangle subdivision.\n") + + # Extensions + sections.append("## Extensions\n") + sections.append("`io_mesh_3mf.common.extensions` — extension registry and validation.\n") + sections.append("| Class | Description |") + sections.append("| --- | --- |") + for cls in ext_classes: + desc = "" + if cls.docstring: + desc = _clean_description(cls.docstring.strip().split('\n')[0]) + sections.append(f"| `{cls.name}` | {desc} |") + sections.append("") + + # Metadata + sections.append("## Metadata\n") + sections.append("`io_mesh_3mf.common.metadata` — metadata storage and merging.\n") + + return "\n".join(sections) + + +# ── Main ────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Generate Nuxt Content markdown docs from the Python API source." + ) + parser.add_argument( + "--output-dir", "-o", + default=None, + help="Output directory for the generated .md files. " + "Defaults to content/docs/3mf/ in the Nuxt site if found, " + "otherwise ./generated_docs/", + ) + parser.add_argument( + "--dry-run", "-n", + action="store_true", + help="Print output paths without writing files.", + ) + args = parser.parse_args() + + # Resolve output directory + if args.output_dir: + out_dir = Path(args.output_dir) + else: + # Try to detect the Nuxt site relative to the repo + nuxt_candidate = REPO_ROOT.parent.parent.parent / "Web Development" / "my-site-nuxt2" / "content" / "docs" / "3mf" + if nuxt_candidate.exists(): + out_dir = nuxt_candidate + else: + out_dir = REPO_ROOT / "generated_docs" + out_dir = out_dir.resolve() + + print(f"Output directory: {out_dir}") + print(f"Parsing {API_PY.relative_to(REPO_ROOT)}...") + + # ── Parse API module ── + api_tree, api_lines = _parse_module(API_PY) + + api_funcs = extract_functions(api_tree, { + "import_3mf", "export_3mf", "inspect_3mf", + "batch_import", "batch_export", + "is_available", "get_api", "has_capability", "check_version", + }) + api_classes = extract_classes(api_tree, { + "ImportResult", "ExportResult", "InspectResult", + }) + api_consts = extract_constants(api_tree, api_lines, { + "API_VERSION", "API_VERSION_STRING", "API_CAPABILITIES", + }) + + print(f" Found {len(api_funcs)} functions, {len(api_classes)} classes, {len(api_consts)} constants") + + # ── Parse building-block modules ── + print(f"Parsing building-block modules...") + + color_tree, _ = _parse_module(COLORS_PY) + color_funcs = _extract_color_functions(color_tree) + print(f" colors: {len(color_funcs)} functions") + + unit_tree, _ = _parse_module(UNITS_PY) + unit_funcs = _extract_unit_functions(unit_tree) + print(f" units: {len(unit_funcs)} functions") + + types_tree, _ = _parse_module(TYPES_PY) + type_classes = _extract_types_classes(types_tree) + print(f" types: {len(type_classes)} classes") + + seg_tree, _ = _parse_module(SEGMENTATION_PY) + seg_classes = _extract_segmentation_classes(seg_tree) + print(f" segmentation: {len(seg_classes)} classes") + + ext_tree, _ = _parse_module(EXTENSIONS_PY) + ext_classes = _extract_extension_classes(ext_tree) + print(f" extensions: {len(ext_classes)} classes") + + # ── Generate pages ── + pages = { + "1.guide.md": generate_guide(api_funcs, api_consts), + "2.recipes.md": generate_recipes(), + "3.api-reference.md": generate_api_reference(api_funcs, api_classes, api_consts), + "4.discovery.md": generate_discovery(api_funcs), + "5.building-blocks.md": generate_building_blocks( + color_funcs, unit_funcs, type_classes, seg_classes, ext_classes, + ), + } + + # ── Write output ── + if args.dry_run: + print("\n[DRY RUN] Would write:") + for name, content in pages.items(): + path = out_dir / name + lines = content.count('\n') + print(f" {path} ({lines} lines)") + return + + out_dir.mkdir(parents=True, exist_ok=True) + for name, content in pages.items(): + path = out_dir / name + path.write_text(content, encoding='utf-8') + lines = content.count('\n') + print(f" Wrote {path.name} ({lines} lines)") + + print(f"\nDone! Generated {len(pages)} files in {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/docs/guide.rst b/docs/guide.rst new file mode 100644 index 0000000..3ae33a4 --- /dev/null +++ b/docs/guide.rst @@ -0,0 +1,208 @@ +Getting Started +=============== + +The public API in :mod:`io_mesh_3mf.api` provides headless/programmatic access +to the full 3MF pipeline. It runs the same code as the Blender operators but +skips UI-specific behaviour (progress bars, popups, camera zoom), making it +suitable for: + +- **CLI automation** — batch processing from Blender's ``--python`` mode +- **Addon integration** — other Blender addons importing/exporting 3MF +- **Headless pipelines** — render farms, CI/CD, asset processing +- **Custom workflows** — building on top of the low-level building blocks + + +Quick Start +----------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + + # Import a 3MF file + result = import_3mf("/path/to/model.3mf") + print(result.status, result.num_loaded) + + # Export selected objects + result = export_3mf("/path/to/output.3mf", use_selection=True) + print(result.status, result.num_written) + + # Inspect without importing (no Blender objects created) + info = inspect_3mf("/path/to/model.3mf") + print(info.unit, info.num_objects, info.num_triangles_total) + +All functions return lightweight dataclasses — they never raise exceptions for +normal failures (corrupt files, empty scenes, etc.). Check ``result.status`` +instead. + + +Export Format Reference +----------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - ``use_orca_format`` + - ``mmu_slicer_format`` + - Output + * - ``"AUTO"`` + - — + - Chooses best format based on scene content + * - ``"STANDARD"`` + - — + - Spec-compliant single-model 3MF + * - ``"PAINT"`` + - ``"ORCA"`` + - Multi-file Orca/Bambu structure with ``paint_color`` attributes + * - ``"PAINT"`` + - ``"PRUSA"`` + - Single-file with ``slic3rpe:mmu_segmentation`` hash strings + +In **AUTO** mode the addon inspects your scene and picks the best path: + +- Objects with MMU paint textures → Orca exporter with segmentation +- Objects with material slots → Standard exporter with basematerials/colorgroups +- Geometry-only objects → Standard exporter, geometry only +- If *project_template* or *object_settings* is provided → Orca exporter + + +Modifier Parts (Orca / BambuStudio) +------------------------------------ + +Orca Slicer and BambuStudio support **modifier meshes** — child objects that +override print settings for a region of the parent without adding visible +geometry. The addon stores the part type as a custom property on the Blender +**Object** (not Mesh): + +.. code-block:: python + + obj["3mf_part_subtype"] = "modifier_part" + +Valid subtypes: + +- ``"normal_part"`` — standard geometry (default when property is absent) +- ``"modifier_part"`` — settings modifier (infill, walls, etc.) +- ``"support_enforcer"`` — force supports in this region +- ``"support_blocker"`` — suppress supports in this region +- ``"negative_part"`` — subtract this volume from the parent + +These are automatically imported from Orca/BambuStudio ``.3mf`` files and +written back on export. You can also set them via the **3MF Metadata** sidebar +panel (Part Type dropdown) or the API: + +.. code-block:: python + + from io_mesh_3mf.api import export_3mf, inspect_3mf + + # Tag an object as a modifier before export + bpy.data.objects["Cylinder"]["3mf_part_subtype"] = "support_enforcer" + export_3mf("output.3mf", use_orca_format="AUTO") + + # Inspect modifier parts without importing + info = inspect_3mf("model.3mf") + for part in info.part_subtypes: + print(f" Part {part['part_id']}: {part['subtype']} ({part.get('name', '')})") + + +Callbacks +--------- + +All three callback types are optional and work the same way across +:func:`~io_mesh_3mf.api.import_3mf`, :func:`~io_mesh_3mf.api.export_3mf`, and +the batch helpers. + +.. code-block:: python + + def on_progress(percentage: int, message: str): + """Called with 0-100 percentage and a status message.""" + print(f"[{percentage:3d}%] {message}") + + def on_warning(message: str): + """Called for each warning (non-manifold geometry, missing data, etc.).""" + print(f"WARNING: {message}") + + def on_object_created(blender_object, resource_id: str): + """Called after each Blender object is built during import.""" + blender_object.color = (1, 0, 0, 1) # Tint red + + +Error Handling +-------------- + +All API functions return result dataclasses instead of raising exceptions. +Check ``result.status``: + +.. code-block:: python + + result = import_3mf("model.3mf") + if result.status == "FINISHED": + print(f"Success: {result.num_loaded} objects") + else: + print(f"Failed: {result.warnings}") + +- Archive-level errors (corrupt ZIP, missing model files) set + ``status = "CANCELLED"``. +- Per-object warnings (non-manifold geometry, missing textures) are collected in + ``warnings`` but don't prevent completion. +- :func:`~io_mesh_3mf.api.inspect_3mf` uses ``status = "OK"`` / ``"ERROR"`` + with a separate ``error_message`` field. + + +CLI Usage +--------- + +Run from the command line using Blender's ``--python`` flag: + +.. code-block:: bash + + # Inspect a file + blender --background --python-expr " + from io_mesh_3mf.api import inspect_3mf + info = inspect_3mf('model.3mf') + print(f'{info.num_objects} objects, {info.num_triangles_total} triangles') + " + + # Batch convert + blender --background --python my_script.py + +**Example script** (``convert_to_orca.py``): + +.. code-block:: python + + """Convert a standard 3MF to Orca Slicer format.""" + import sys + from io_mesh_3mf.api import import_3mf, export_3mf + + input_path = sys.argv[sys.argv.index("--") + 1] + output_path = input_path.replace(".3mf", "_orca.3mf") + + result = import_3mf(input_path, import_materials="MATERIALS") + if result.status == "FINISHED": + export_result = export_3mf( + output_path, + objects=result.objects, + use_orca_format="AUTO", + ) + print(f"Converted: {export_result.num_written} objects → {output_path}") + +.. code-block:: bash + + blender --background --python convert_to_orca.py -- input.3mf + + +Notes +----- + +- **Blender context required** — :func:`~io_mesh_3mf.api.import_3mf` and + :func:`~io_mesh_3mf.api.export_3mf` need ``bpy.context``. They work in + ``--background`` mode but not outside Blender entirely. +- **inspect_3mf is lightweight** — it only opens the ZIP and parses XML. + No Blender objects, materials, or images are created. +- **Thread safety** — Blender's Python API is not thread-safe. Don't call + these functions from background threads. +- **Batch isolation** — :func:`~io_mesh_3mf.api.batch_import` and + :func:`~io_mesh_3mf.api.batch_export` catch per-file exceptions so one + failure doesn't stop the batch. +- **API vs addon version** — ``API_VERSION`` tracks the API contract stability. + It increments independently of the addon release version. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..bfff680 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,19 @@ +3MF Format — API Documentation +================================ + +Programmatic 3MF import, export, and inspection for Blender — without +``bpy.ops``. + +Start with the :doc:`guide` for an overview and quick-start examples, then see +the :doc:`api` for the full auto‑generated parameter reference. The +:doc:`recipes` page collects ready-to-use patterns for common workflows. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + guide + recipes + api + discovery + building-blocks diff --git a/docs/recipes.rst b/docs/recipes.rst new file mode 100644 index 0000000..924079a --- /dev/null +++ b/docs/recipes.rst @@ -0,0 +1,212 @@ +Recipes +======= + +Practical patterns for common workflows. + + +Import with Material Painting +----------------------------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf + + result = import_3mf( + "/models/multicolor.3mf", + import_materials="PAINT", + import_location="ORIGIN", + ) + + for obj in result.objects: + print(f" {obj.name}: {len(obj.data.vertices)} verts") + + +Import into a Specific Collection +---------------------------------- + +.. code-block:: python + + result = import_3mf( + "/models/part.3mf", + target_collection="Imported Parts", + reuse_materials=True, + ) + + +Export for Orca Slicer +---------------------- + +The export dispatch uses a three-way mode: ``AUTO``, ``STANDARD``, or ``PAINT``. + +- **AUTO** (default) — detects materials and paint data, choosing the best + exporter automatically. +- **STANDARD** — always uses the spec-compliant StandardExporter. +- **PAINT** — forces segmentation export for multi-material painting. + +.. code-block:: python + + from io_mesh_3mf.api import export_3mf + import bpy + + cubes = [o for o in bpy.data.objects if o.type == "MESH" and "Cube" in o.name] + + result = export_3mf( + "/output/cubes.3mf", + objects=cubes, + use_orca_format="AUTO", + ) + print(f"Exported {result.num_written} objects") + + +Export for PrusaSlicer with MMU Paint +------------------------------------- + +.. code-block:: python + + result = export_3mf( + "/output/painted.3mf", + use_orca_format="PAINT", + mmu_slicer_format="PRUSA", + use_selection=True, + ) + + +Custom Orca Project Template +----------------------------- + +Use a custom printer/filament profile extracted from Orca Slicer: + +.. code-block:: python + + result = export_3mf( + "/output/custom_printer.3mf", + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + project_template="/templates/bambu_x1c_asa.json", + object_settings={ + supports_obj: { + "layer_height": "0.12", + "wall_loops": "2", + "sparse_infill_density": "10%", + }, + detail_part: { + "layer_height": "0.08", + "outer_wall_speed": "50", + }, + }, + ) + +.. tip:: + + **Getting custom templates:** Export a project from Orca Slicer as ``.3mf``, + open the archive with a ZIP tool, and extract + ``Metadata/project_settings.config``. This JSON file contains all printer, + filament, and print settings. The addon patches ``filament_colour`` + automatically based on your painted objects. + + +Round-Trip Conversion +--------------------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf, export_3mf + + # Import from one format, export to another + result = import_3mf("/input/prusa_model.3mf", import_materials="PAINT") + if result.status == "FINISHED": + export_3mf( + "/output/orca_model.3mf", + objects=result.objects, + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + ) + + +Modifier Parts +-------------- + +Tag objects as modifiers, support enforcers, or support blockers for +Orca Slicer / BambuStudio: + +.. code-block:: python + + import bpy + from io_mesh_3mf.api import export_3mf + + parent = bpy.data.objects["MainBody"] + modifier = bpy.data.objects["InfillRegion"] + blocker = bpy.data.objects["NoSupportZone"] + + modifier["3mf_part_subtype"] = "modifier_part" + blocker["3mf_part_subtype"] = "support_blocker" + + result = export_3mf( + "/output/with_modifiers.3mf", + objects=[parent, modifier, blocker], + use_orca_format="AUTO", + ) + +Valid subtypes: ``normal_part``, ``modifier_part``, ``support_enforcer``, +``support_blocker``, ``negative_part``. Objects without the property +default to ``normal_part``. + + +Inspect Without Importing +-------------------------- + +.. code-block:: python + + from io_mesh_3mf.api import inspect_3mf + + info = inspect_3mf("/models/assembly.3mf") + + if info.status == "OK": + print(f"Unit: {info.unit}") + print(f"Objects: {info.num_objects}") + print(f"Total triangles: {info.num_triangles_total}") + print(f"Vendor: {info.vendor_format or 'standard'}") + print(f"Extensions: {info.extensions_used}") + + for obj in info.objects: + flags = [] + if obj["has_materials"]: + flags.append("materials") + if obj["has_segmentation"]: + flags.append("MMU paint") + print(f" {obj['name']}: {obj['num_triangles']} tris [{', '.join(flags)}]") + else: + print(f"Error: {info.error_message}") + + +Batch Operations +---------------- + +.. code-block:: python + + from io_mesh_3mf.api import batch_import, batch_export + import bpy + + # Import multiple files with per-file error isolation + results = batch_import( + ["part_a.3mf", "part_b.3mf", "part_c.3mf"], + import_materials="PAINT", + target_collection="Batch Import", + ) + + total = sum(r.num_loaded for r in results) + failed = [r for r in results if r.status != "FINISHED"] + print(f"Imported {total} objects, {len(failed)} failures") + + # Export multiple files + cubes = [o for o in bpy.data.objects if "Cube" in o.name] + spheres = [o for o in bpy.data.objects if "Sphere" in o.name] + + results = batch_export( + [ + ("cubes.3mf", cubes), + ("spheres.3mf", spheres), + ("everything.3mf", None), # None = all scene objects + ], + use_orca_format="AUTO", + ) diff --git a/docs/site/.buildinfo b/docs/site/.buildinfo new file mode 100644 index 0000000..78b9030 --- /dev/null +++ b/docs/site/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 9d8f523873fd1e8e4f23002ca7fa4737 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/site/_modules/index.html b/docs/site/_modules/index.html new file mode 100644 index 0000000..ce796f1 --- /dev/null +++ b/docs/site/_modules/index.html @@ -0,0 +1,283 @@ + + + + + + + + Overview: module code - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+ +
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/api.html b/docs/site/_modules/io_mesh_3mf/api.html new file mode 100644 index 0000000..f12abba --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/api.html @@ -0,0 +1,1690 @@ + + + + + + + + io_mesh_3mf.api - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.api

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+# <pep8 compliant>
+
+"""
+Public API for programmatic 3MF import and export.
+
+These entry points let other Blender addons, CLI scripts, and headless
+automation workflows import or export 3MF files *without* going through
+Blender operator invocation (``bpy.ops``).  They build the appropriate
+context objects, run the same pipeline code as the operators, and return
+lightweight result dataclasses.
+
+Quick start::
+
+    from io_mesh_3mf.api import import_3mf, export_3mf
+
+    # Import
+    result = import_3mf("/path/to/model.3mf", import_materials="PAINT")
+    print(result.status, result.num_loaded, result.objects)
+
+    # Export
+    result = export_3mf(
+        "/path/to/output.3mf",
+        use_orca_format="AUTO",
+        use_selection=True,
+    )
+    print(result.status, result.num_written)
+
+Inspect without importing::
+
+    from io_mesh_3mf.api import inspect_3mf
+
+    info = inspect_3mf("/path/to/model.3mf")
+    print(info.unit, info.num_objects, info.num_triangles_total)
+    for obj in info.objects:
+        print(obj["name"], obj["num_vertices"], obj["num_triangles"])
+
+Batch operations::
+
+    from io_mesh_3mf.api import batch_import
+
+    results = batch_import(["/a.3mf", "/b.3mf"], import_materials="PAINT")
+    for r in results:
+        print(r.status, r.num_loaded)
+
+Building blocks for custom workflows::
+
+    from io_mesh_3mf.api import colors, types, segmentation, units
+"""
+
+from __future__ import annotations
+
+import os
+import xml.etree.ElementTree
+import zipfile
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, Optional, Sequence, Set, Tuple
+
+import bpy
+
+from .common.constants import (
+    RELS_MIMETYPE,
+    MODEL_MIMETYPE,
+    MODEL_NAMESPACES,
+    SUPPORTED_EXTENSIONS,
+    MATERIAL_NAMESPACE,
+)
+from .common.extensions import ExtensionManager
+from .common.logging import debug, warn, error
+from .common.metadata import Metadata, MetadataEntry
+from .common.annotations import Annotations
+from .common.units import (
+    blender_to_metre,
+    threemf_to_metre,
+    export_unit_scale,
+)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# API Version & Registry
+# ═══════════════════════════════════════════════════════════════════════════
+#
+# This module self-registers in bpy.app.driver_namespace so other addons can
+# discover and use the 3MF API without parsing addon directories.
+#
+# Usage from another addon:
+#
+#     import bpy
+#     threemf_api = bpy.app.driver_namespace.get("io_mesh_3mf")
+#     if threemf_api is not None:
+#         result = threemf_api.import_3mf("/path/to/model.3mf")
+#
+# Or using the provided discovery helper (see API.md):
+#
+#     from io_mesh_3mf.api import get_api, is_available
+#     if is_available():
+#         api = get_api()
+#         result = api.import_3mf("/path/to/model.3mf")
+
+#: API version following semantic versioning (MAJOR.MINOR.PATCH).
+#: - MAJOR: Breaking changes to existing functions/signatures
+#: - MINOR: New features, backward-compatible
+#: - PATCH: Bug fixes only
+API_VERSION = (1, 0, 0)
+
+#: Human-readable version string
+API_VERSION_STRING = ".".join(str(v) for v in API_VERSION)
+
+#: Capability flags for feature detection. Other addons can check these
+#: to determine what functionality is available without version parsing.
+API_CAPABILITIES = frozenset({
+    "import",              # import_3mf() available
+    "export",              # export_3mf() available
+    "inspect",             # inspect_3mf() available
+    "batch",               # batch_import/batch_export available
+    "callbacks",           # on_progress, on_warning, on_object_created
+    "target_collection",   # import to specific collection
+    "orca_format",         # Orca/BambuStudio export format
+    "prusa_format",        # PrusaSlicer export format
+    "paint_mode",          # MMU paint segmentation
+    "project_template",    # Custom Orca project template
+    "object_settings",     # Per-object Orca settings
+    "building_blocks",     # colors, types, segmentation sub-namespaces
+})
+
+#: Registry key in bpy.app.driver_namespace
+_REGISTRY_KEY = "io_mesh_3mf"
+
+
+def _register_api() -> None:
+    """Register this API module in bpy.app.driver_namespace for discovery."""
+    import sys
+    bpy.app.driver_namespace[_REGISTRY_KEY] = sys.modules[__name__]
+    debug(f"Registered 3MF API v{API_VERSION_STRING} in driver_namespace")
+
+
+def _unregister_api() -> None:
+    """Remove the API from bpy.app.driver_namespace."""
+    bpy.app.driver_namespace.pop(_REGISTRY_KEY, None)
+
+
+
+[docs] +def is_available() -> bool: + """Check if the 3MF API is registered and available. + + :return: True if the API is registered in bpy.app.driver_namespace. + + Example:: + + from io_mesh_3mf.api import is_available + if is_available(): + print("3MF API is ready") + """ + return _REGISTRY_KEY in bpy.app.driver_namespace
+ + + +
+[docs] +def get_api(): + """Get the registered 3MF API module. + + :return: The io_mesh_3mf.api module, or None if not registered. + :rtype: module | None + + Example:: + + from io_mesh_3mf.api import get_api + api = get_api() + if api: + result = api.import_3mf("/model.3mf") + """ + return bpy.app.driver_namespace.get(_REGISTRY_KEY)
+ + + +
+[docs] +def has_capability(capability: str) -> bool: + """Check if a specific API capability is available. + + Use this for forward-compatible feature detection instead of version + checks. New capabilities may be added in minor versions. + + :param capability: Capability name (e.g., "paint_mode", "batch"). + :return: True if the capability is supported. + + Example:: + + from io_mesh_3mf.api import has_capability + if has_capability("object_settings"): + # Safe to use object_settings parameter + result = export_3mf(path, object_settings={...}) + """ + return capability in API_CAPABILITIES
+ + + +
+[docs] +def check_version(minimum: Tuple[int, int, int]) -> bool: + """Check if the API version meets a minimum requirement. + + :param minimum: Tuple of (major, minor, patch) minimum version. + :return: True if API_VERSION >= minimum. + + Example:: + + from io_mesh_3mf.api import check_version + if check_version((1, 2, 0)): + # Use features added in v1.2.0 + ... + """ + return API_VERSION >= minimum
+ + + +# Auto-register when this module is imported (deferred to first use for safety) +try: + _register_api() +except Exception: + pass # Blender may not be fully initialized during startup + + +__all__ = [ + # --- API discovery & versioning --- + "API_VERSION", + "API_VERSION_STRING", + "API_CAPABILITIES", + "is_available", + "get_api", + "has_capability", + "check_version", + # --- Core functions --- + "import_3mf", + "export_3mf", + "inspect_3mf", + "batch_import", + "batch_export", + # --- Result types --- + "ImportResult", + "ExportResult", + "InspectResult", + # --- Building-block sub-namespaces --- + "colors", + "types", + "segmentation", + "units", + "extensions", + "xml_tools", + "metadata", + "components", +] + + +# ═══════════════════════════════════════════════════════════════════════════ +# Result dataclasses +# ═══════════════════════════════════════════════════════════════════════════ + +
+[docs] +@dataclass +class ImportResult: + """Return value from :func:`import_3mf`. + + Attributes: + status: ``"FINISHED"`` on success, ``"CANCELLED"`` on failure. + num_loaded: Number of objects successfully imported. + objects: List of ``bpy.types.Object`` instances created during import. + warnings: Accumulated warning messages (if any). + """ + + status: str = "FINISHED" + num_loaded: int = 0 + objects: List = field(default_factory=list) + warnings: List[str] = field(default_factory=list)
+ + + +
+[docs] +@dataclass +class ExportResult: + """Return value from :func:`export_3mf`. + + Attributes: + status: ``"FINISHED"`` on success, ``"CANCELLED"`` on failure. + num_written: Number of objects written to the archive. + filepath: Absolute path of the written ``.3mf`` file. + warnings: Accumulated warning messages (if any). + """ + + status: str = "FINISHED" + num_written: int = 0 + filepath: str = "" + warnings: List[str] = field(default_factory=list)
+ + + +
+[docs] +@dataclass +class InspectResult: + """Return value from :func:`inspect_3mf`. + + A lightweight summary of a 3MF archive's contents, extracted *without* + creating any Blender objects or materials. + + Attributes: + status: ``"OK"`` on success, ``"ERROR"`` on failure. + error_message: Human-readable error string when ``status == "ERROR"``. + unit: The unit declared in the model file (``"millimeter"`` etc.). + metadata: Top-level ``<metadata>`` key/value pairs from the model. + objects: Per-object summary dicts with keys: + + - ``"id"`` — resource ID string + - ``"name"`` — object name (or ``""`` if unnamed) + - ``"type"`` — object type attribute (``"model"`` / ``"solidsupport"`` / …) + - ``"num_vertices"`` — vertex count + - ``"num_triangles"`` — triangle count + - ``"num_components"`` — number of component references + - ``"has_materials"`` — whether face materials are present + - ``"has_segmentation"`` — whether MMU paint segmentation is present + + materials: Per-material-group summary dicts with keys: + + - ``"id"`` — resource ID string + - ``"type"`` — ``"basematerials"`` | ``"colorgroup"`` | ``"texture2dgroup"`` + - ``"count"`` — number of entries in the group + + textures: Per-texture summary dicts with keys: + + - ``"id"`` — resource ID string + - ``"path"`` — internal archive path + - ``"contenttype"`` — MIME type string + + extensions_used: Set of namespace URIs for extensions referenced + in the model's ``requiredextensions`` / ``recommendedextensions``. + vendor_format: Detected slicer vendor format (``"orca"`` / ``None``). + archive_files: List of all file paths inside the ZIP archive. + num_objects: Total number of ``<object>`` resources. + num_triangles_total: Sum of all triangle counts across objects. + num_vertices_total: Sum of all vertex counts across objects. + warnings: Accumulated warnings during inspection. + """ + + status: str = "OK" + error_message: str = "" + unit: str = "" + metadata: Dict[str, str] = field(default_factory=dict) + objects: List[Dict] = field(default_factory=list) + materials: List[Dict] = field(default_factory=list) + textures: List[Dict] = field(default_factory=list) + extensions_used: Set[str] = field(default_factory=set) + vendor_format: Optional[str] = None + archive_files: List[str] = field(default_factory=list) + num_objects: int = 0 + num_triangles_total: int = 0 + num_vertices_total: int = 0 + warnings: List[str] = field(default_factory=list)
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# Callback type aliases (for documentation clarity) +# ═══════════════════════════════════════════════════════════════════════════ + +# Called with (percentage: int 0-100, message: str) +ProgressCallback = Callable[[int, str], None] +# Called with (warning_message: str) +WarningCallback = Callable[[str], None] +# Called with (blender_object, resource_id: str) after each object is built +ObjectCreatedCallback = Callable[..., None] + + +# ═══════════════════════════════════════════════════════════════════════════ +# inspect_3mf — read-only archive inspection (no Blender objects created) +# ═══════════════════════════════════════════════════════════════════════════ + +
+[docs] +def inspect_3mf(filepath: str) -> InspectResult: + """Inspect a 3MF file without importing anything into Blender. + + Opens the archive, parses the XML model file(s), and returns a + summary of objects, materials, textures, metadata, and extensions. + No Blender objects, meshes, or materials are created. + + :param filepath: Path to the ``.3mf`` file. + :return: :class:`InspectResult` with archive metadata and statistics. + + Example:: + + info = inspect_3mf("model.3mf") + if info.status == "OK": + for obj in info.objects: + print(f"{obj['name']}: {obj['num_triangles']} tris") + """ + from .common.constants import MODEL_DEFAULT_UNIT + + filepath = os.path.abspath(filepath) + result = InspectResult() + + # --- Open archive ------------------------------------------------------- + try: + archive = zipfile.ZipFile(filepath, "r") + except (zipfile.BadZipFile, EnvironmentError) as e: + result.status = "ERROR" + result.error_message = f"Unable to read archive: {e}" + return result + + result.archive_files = archive.namelist() + + # --- Find model files --------------------------------------------------- + # Look for [Content_Types].xml to resolve MIME types, but fall back to + # scanning for *.model files if the content-types file is missing. + model_paths: List[str] = [] + for name in result.archive_files: + lower = name.lower() + if lower.endswith(".model"): + model_paths.append(name) + + if not model_paths: + result.status = "ERROR" + result.error_message = "No .model files found in archive" + archive.close() + return result + + # --- Parse each model file ---------------------------------------------- + for model_path in model_paths: + try: + with archive.open(model_path) as f: + tree = xml.etree.ElementTree.ElementTree(file=f) + except xml.etree.ElementTree.ParseError as e: + result.warnings.append(f"Malformed XML in {model_path}: {e}") + continue + + root = tree.getroot() + + # Unit. + if not result.unit: + result.unit = root.attrib.get("unit", MODEL_DEFAULT_UNIT) + + # Top-level metadata. + for meta_node in root.iterfind("./3mf:metadata", MODEL_NAMESPACES): + name = meta_node.attrib.get("name", "") + if name: + result.metadata[name] = meta_node.text or "" + + # Extensions referenced. + for attr_key in ("requiredextensions", "recommendedextensions"): + ext_str = root.attrib.get(attr_key, "") + if ext_str: + resolved = _resolve_prefixes(root, ext_str) + result.extensions_used.update(resolved) + + # Vendor detection (lightweight — check namespace presence). + from .import_3mf.slicer import detect_vendor + detected = detect_vendor(root) + if detected and result.vendor_format is None: + result.vendor_format = detected + + # ---- Materials / textures ------------------------------------------ + _inspect_materials(root, result) + _inspect_textures(root, result) + + # ---- Objects ------------------------------------------------------- + for obj_node in root.iterfind( + "./3mf:resources/3mf:object", MODEL_NAMESPACES + ): + obj_id = obj_node.attrib.get("id", "") + obj_name = obj_node.attrib.get("name", "") + obj_type = obj_node.attrib.get("type", "model") + + # Count vertices. + vert_nodes = obj_node.findall( + "./3mf:mesh/3mf:vertices/3mf:vertex", MODEL_NAMESPACES + ) + num_verts = len(vert_nodes) + + # Count triangles. + tri_nodes = obj_node.findall( + "./3mf:mesh/3mf:triangles/3mf:triangle", MODEL_NAMESPACES + ) + num_tris = len(tri_nodes) + + # Count components. + comp_nodes = obj_node.findall( + "./3mf:components/3mf:component", MODEL_NAMESPACES + ) + num_components = len(comp_nodes) + + # Check for materials (pid/pindex on object or any triangle). + has_materials = "pid" in obj_node.attrib + if not has_materials: + for tri in tri_nodes[:1]: + if "pid" in tri.attrib: + has_materials = True + break + + # Check for segmentation (slic3rpe or Orca paint_color). + has_seg = False + for tri in tri_nodes[:1]: + if tri.attrib.get( + "{http://schemas.slic3r.org/3mf/2017/06}mmu_segmentation" + ): + has_seg = True + break + if tri.attrib.get("paint_color"): + has_seg = True + break + + obj_summary = { + "id": obj_id, + "name": obj_name, + "type": obj_type, + "num_vertices": num_verts, + "num_triangles": num_tris, + "num_components": num_components, + "has_materials": has_materials, + "has_segmentation": has_seg, + } + result.objects.append(obj_summary) + result.num_triangles_total += num_tris + result.num_vertices_total += num_verts + + result.num_objects = len(result.objects) + archive.close() + return result
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# import_3mf +# ═══════════════════════════════════════════════════════════════════════════ + +
+[docs] +def import_3mf( + filepath: str, + *, + global_scale: float = 1.0, + import_materials: str = "MATERIALS", + reuse_materials: bool = True, + import_location: str = "KEEP", + origin_to_geometry: str = "KEEP", + grid_spacing: float = 0.1, + auto_smooth: bool = False, + auto_smooth_angle: float = 0.5236, + paint_uv_method: str = "SMART", + paint_texture_size: int = 0, + target_collection: Optional[str] = None, + on_progress: Optional[ProgressCallback] = None, + on_warning: Optional[WarningCallback] = None, + on_object_created: Optional[ObjectCreatedCallback] = None, +) -> ImportResult: + """Import a 3MF file into the current Blender scene. + + This is the headless/programmatic counterpart to the ``Import3MF`` + operator. It skips UI-specific behaviour (progress bars, camera zoom, + paint popups) but runs the exact same import pipeline. + + :param filepath: Path to the ``.3mf`` file to import. + :param global_scale: Scale multiplier (default 1.0). + :param import_materials: ``"MATERIALS"`` | ``"PAINT"`` | ``"NONE"``. + :param reuse_materials: Reuse existing Blender materials by name/color. + :param import_location: ``"ORIGIN"`` | ``"CURSOR"`` | ``"KEEP"`` | ``"GRID"``. + :param origin_to_geometry: ``"KEEP"`` | ``"CENTER"`` | ``"BOTTOM"``. + :param grid_spacing: Spacing between objects in grid layout mode. + :param auto_smooth: Apply Smooth by Angle modifier to imported objects. + :param auto_smooth_angle: Maximum angle (radians) for smooth shading + (default 0.5236 = 30 degrees). + :param paint_uv_method: ``"SMART"`` (default) or ``"LIGHTMAP"``. + Smart UV groups adjacent faces; Lightmap gives each face unique space. + :param paint_texture_size: Override texture resolution (0 = auto). + :param target_collection: Name of an existing Blender collection to place + imported objects into. If *None*, objects are added to the active + collection. If the named collection does not exist it will be created + and linked to the scene. + :param on_progress: Optional ``(percentage: int, message: str)`` callback. + :param on_warning: Optional ``(message: str)`` callback fired for each warning. + :param on_object_created: Optional callback fired after each Blender + object is built. Receives ``(blender_object, resource_id)`` arguments. + :return: :class:`ImportResult` with status, loaded count, and object list. + """ + from .import_3mf.context import ImportContext, ImportOptions + from .import_3mf import archive as archive_mod + from .import_3mf import geometry as geometry_mod + from .import_3mf import builder as builder_mod + from .import_3mf.scene import apply_grid_layout + from .import_3mf.slicer import ( + detect_vendor, + read_all_slicer_colors, + ) + from .import_3mf.materials import ( + read_materials as _read_materials_impl, + read_textures as _read_textures_impl, + read_texture_groups as _read_texture_groups_impl, + extract_textures_from_archive as _extract_textures_impl, + read_pbr_metallic_properties as _read_pbr_metallic_impl, + read_pbr_specular_properties as _read_pbr_specular_impl, + read_pbr_translucent_properties as _read_pbr_translucent_impl, + read_pbr_texture_display_properties as _read_pbr_texture_display_impl, + read_composite_materials as _read_composite_impl, + read_multiproperties as _read_multiproperties_impl, + store_passthrough_materials as _store_passthrough_impl, + ) + + filepath = os.path.abspath(filepath) + result = ImportResult() + warnings_list = result.warnings + + if on_progress: + on_progress(0, "Starting import…") + + # Build context (no operator). + options = ImportOptions( + global_scale=global_scale, + import_materials=import_materials, + reuse_materials=reuse_materials, + import_location=import_location, + origin_to_geometry=origin_to_geometry, + grid_spacing=grid_spacing, + auto_smooth=auto_smooth, + auto_smooth_angle=auto_smooth_angle, + paint_uv_method=paint_uv_method, + paint_texture_size=paint_texture_size, + ) + ctx = ImportContext(options=options, operator=None) + + # Wire up warning callback (intercept ctx.safe_report for WARNING level). + if on_warning is not None: + _original_safe_report = ctx.safe_report + + def _intercepted_safe_report(level, message): + _original_safe_report(level, message) + if "WARNING" in level: + on_warning(message) + warnings_list.append(message) + + ctx.safe_report = _intercepted_safe_report # type: ignore[assignment] + + scene_metadata = Metadata() + scene_metadata.retrieve(bpy.context.scene) + del scene_metadata["Title"] + annotations_obj = Annotations() + annotations_obj.retrieve() + + # Switch to object mode, deselect everything. + if bpy.ops.object.mode_set.poll(): + bpy.ops.object.mode_set(mode="OBJECT") + if bpy.ops.object.select_all.poll(): + bpy.ops.object.select_all(action="DESELECT") + + # --- Collection targeting ----------------------------------------------- + original_collection = bpy.context.view_layer.active_layer_collection + if target_collection is not None: + col = bpy.data.collections.get(target_collection) + if col is None: + col = bpy.data.collections.new(target_collection) + bpy.context.scene.collection.children.link(col) + # Find the layer collection wrapper for this collection. + layer_col = _find_layer_collection( + bpy.context.view_layer.layer_collection, col, + ) + if layer_col is not None: + bpy.context.view_layer.active_layer_collection = layer_col + + ctx.current_archive_path = filepath + + if on_progress: + on_progress(5, "Reading archive…") + + # --- Read archive ------------------------------------------------------- + try: + files_by_content_type = archive_mod.read_archive(ctx, filepath) + except Exception as e: + error(f"Failed to read archive {filepath}: {e}") + result.status = "CANCELLED" + # Restore collection. + bpy.context.view_layer.active_layer_collection = original_collection + return result + + # If no model files were found, the archive is unreadable or invalid. + if not files_by_content_type.get(MODEL_MIMETYPE): + error(f"No model files found in archive: {filepath}") + result.status = "CANCELLED" + bpy.context.view_layer.active_layer_collection = original_collection + return result + + # Relationships & content types. + for rels_file in files_by_content_type.get(RELS_MIMETYPE, []): + annotations_obj.add_rels(rels_file) + annotations_obj.add_content_types(files_by_content_type) + archive_mod.must_preserve(ctx, files_by_content_type, annotations_obj) + + # Stash slicer config files for round-trip export. + archive_mod.stash_slicer_configs(ctx, filepath) + + if on_progress: + on_progress(15, "Parsing model files…") + + # --- Parse model files -------------------------------------------------- + for model_file in files_by_content_type.get(MODEL_MIMETYPE, []): + try: + document = xml.etree.ElementTree.ElementTree(file=model_file) + except xml.etree.ElementTree.ParseError as e: + error(f"3MF document is malformed: {e}") + warnings_list.append(f"Malformed XML: {e}") + continue + if document is None: + continue + root = document.getroot() + + # Vendor detection. + if ctx.options.import_materials != "NONE": + ctx.vendor_format = detect_vendor(root) + else: + ctx.vendor_format = None + + # Extension activation. + _activate_extensions_api(ctx, root) + + # Unit scale. + context = bpy.context + scale_unit = _import_unit_scale(context, root, global_scale) + + # Reset per-model resource dictionaries. + ctx.resource_objects = {} + ctx.resource_materials = {} + ctx.resource_textures = {} + ctx.resource_texture_groups = {} + ctx.orca_filament_colors = {} + ctx.object_default_extruders = {} + + if on_progress: + on_progress(20, "Reading filament colours…") + + # Read filament colours (single archive open, priority order). + read_all_slicer_colors(ctx, filepath) + + # Metadata. + for metadata_node in root.iterfind("./3mf:metadata", MODEL_NAMESPACES): + if "name" not in metadata_node.attrib: + continue + name = metadata_node.attrib["name"] + preserve_str = metadata_node.attrib.get("preserve", "0") + preserve = preserve_str != "0" and preserve_str.lower() != "false" + datatype = metadata_node.attrib.get("type", "") + value = metadata_node.text + scene_metadata[name] = MetadataEntry( + name=name, preserve=preserve, datatype=datatype, value=value, + ) + + if on_progress: + on_progress(30, "Reading materials…") + + # Materials. + if ctx.options.import_materials != "NONE": + material_ns = {"m": MATERIAL_NAMESPACE} + pbr_metallic = _read_pbr_metallic_impl(ctx, root, material_ns) + pbr_specular = _read_pbr_specular_impl(ctx, root, material_ns) + pbr_translucent = _read_pbr_translucent_impl(ctx, root, material_ns) + _read_pbr_texture_display_impl(ctx, root, material_ns) + + display_properties = {} + display_properties.update(pbr_metallic) + display_properties.update(pbr_specular) + display_properties.update(pbr_translucent) + + _read_materials_impl(ctx, root, material_ns, display_properties) + _read_textures_impl(ctx, root, material_ns) + _read_texture_groups_impl(ctx, root, material_ns, display_properties) + _read_composite_impl(ctx, root, material_ns) + _read_multiproperties_impl(ctx, root, material_ns) + + # Extract textures. + _extract_textures_impl(ctx, filepath) + + if on_progress: + on_progress(45, "Reading geometry…") + + # Objects. + geometry_mod.read_objects(ctx, root) + + if on_progress: + on_progress(60, "Building Blender objects…") + + # Build items (pass progress_callback through if available). + builder_mod.build_items(ctx, root, scale_unit, progress_callback=on_progress) + + # Fire on_object_created for each built object. + if on_object_created is not None: + for obj in ctx.imported_objects: + on_object_created(obj, str(getattr(obj, "name", ""))) + + # Store scene data. + scene_metadata.store(bpy.context.scene) + annotations_obj.store() + _store_passthrough_impl(ctx) + + # Grid layout. + if ctx.options.import_location == "GRID": + apply_grid_layout(ctx.imported_objects, ctx.options.grid_spacing) + + # Restore original collection. + bpy.context.view_layer.active_layer_collection = original_collection + + result.num_loaded = ctx.num_loaded + result.objects = list(ctx.imported_objects) + result.status = "FINISHED" + + if on_progress: + on_progress(100, "Import complete") + + debug(f"API: Imported {ctx.num_loaded} objects from {filepath}") + return result
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# export_3mf +# ═══════════════════════════════════════════════════════════════════════════ + +
+[docs] +def export_3mf( + filepath: str, + *, + objects=None, + use_selection: bool = False, + export_hidden: bool = False, + skip_disabled: bool = True, + global_scale: float = 1.0, + use_mesh_modifiers: bool = True, + coordinate_precision: int = 9, + compression_level: int = 3, + use_orca_format: str = "AUTO", + use_components: bool = True, + mmu_slicer_format: str = "ORCA", + subdivision_depth: int = 7, + thumbnail_mode: str = "AUTO", + thumbnail_resolution: int = 256, + thumbnail_image: str = "", + project_template: Optional[str] = None, + object_settings: Optional[Dict] = None, + on_progress: Optional[ProgressCallback] = None, + on_warning: Optional[WarningCallback] = None, +) -> ExportResult: + """Export Blender objects to a 3MF file. + + This is the headless/programmatic counterpart to the ``Export3MF`` + operator. It skips UI-specific behaviour (progress bars, status text) + but runs the exact same export pipeline. + + :param filepath: Destination path for the ``.3mf`` file. + :param objects: Explicit list of ``bpy.types.Object`` to export. + If *None*, falls back to ``use_selection`` logic or all scene objects. + :param use_selection: Export selected objects only (ignored when *objects* is given). + :param export_hidden: Include hidden objects. + :param skip_disabled: Skip objects disabled for rendering (camera icon) + and objects in excluded/hidden collections (default *True*). + :param global_scale: Scale multiplier (default 1.0). + :param use_mesh_modifiers: Apply modifiers before exporting. + :param coordinate_precision: Decimal precision for vertex coordinates. + :param compression_level: ZIP deflate compression level (0–9, default 3). + 0 = no compression (fastest, largest), 9 = max compression (slowest, + smallest). 3 balances speed and file size. + :param use_orca_format: ``"AUTO"`` | ``"STANDARD"`` | ``"PAINT"``. + ``AUTO`` (default) detects materials and paint data, choosing the + best exporter automatically. ``STANDARD`` always uses the + spec-compliant StandardExporter with proper component instancing. + ``PAINT`` forces segmentation export. When *project_template* or + *object_settings* is provided, the Orca exporter is used + automatically even in ``AUTO`` mode. + :param use_components: Use component instances for linked duplicates. + :param mmu_slicer_format: ``"ORCA"`` | ``"PRUSA"`` (only relevant when + *use_orca_format* is ``"PAINT"``). + :param subdivision_depth: Maximum recursive subdivision depth for paint + segmentation (4–10, default 7). Higher = finer detail but slower. + :param thumbnail_mode: ``"AUTO"`` (render clean preview), ``"CUSTOM"`` + (use *thumbnail_image*), or ``"NONE"`` (no thumbnail). + :param thumbnail_resolution: Width and height in pixels for AUTO mode + (default 256). + :param thumbnail_image: Absolute path to an image file for CUSTOM mode. + :param project_template: Absolute path to a JSON file to use as the Orca + ``project_settings.config`` instead of the built-in template. The + addon loads this file, patches ``filament_colour`` and resizes + filament arrays to match the export, then writes it to the archive. + If the file does not exist or is invalid JSON, a warning is logged + and the built-in template is used as a fallback. Only relevant for + Orca/BambuStudio exports (``mmu_slicer_format="ORCA"``). + :param object_settings: Per-object Orca Slicer setting overrides. + A dict mapping ``bpy.types.Object`` instances to dicts of + ``{setting_key: value_string}`` pairs. These are written as + ``<metadata>`` entries in ``model_settings.config`` so that Orca + applies different print settings to individual objects. Keys are + passed through without validation — any valid Orca setting key + (e.g. ``"layer_height"``, ``"wall_loops"``, ``"sparse_infill_speed"``) + is accepted. Objects not present in this dict use project defaults. + + Example:: + + object_settings={ + supports_obj: { + "layer_height": "0.12", + "wall_loops": "2", + "sparse_infill_speed": "50", + }, + # other objects use project defaults + } + + :param on_progress: Optional ``(percentage: int, message: str)`` callback. + :param on_warning: Optional ``(message: str)`` callback for warnings. + :return: :class:`ExportResult` with status, written count, and filepath. + """ + from .export_3mf.context import ExportContext, ExportOptions + from .export_3mf.archive import create_archive + from .export_3mf.components import collect_mesh_objects + from .export_3mf.geometry import check_non_manifold_geometry + from .export_3mf.standard import StandardExporter + from .export_3mf.orca import OrcaExporter + from .export_3mf.prusa import PrusaExporter + + filepath = os.path.abspath(filepath) + result = ExportResult(filepath=filepath) + + if on_progress: + on_progress(0, "Starting export…") + + options = ExportOptions( + use_selection=use_selection, + export_hidden=export_hidden, + skip_disabled=skip_disabled, + global_scale=global_scale, + use_mesh_modifiers=use_mesh_modifiers, + coordinate_precision=coordinate_precision, + compression_level=compression_level, + use_orca_format=use_orca_format, + use_components=use_components, + mmu_slicer_format=mmu_slicer_format, + subdivision_depth=subdivision_depth, + thumbnail_mode=thumbnail_mode, + thumbnail_resolution=thumbnail_resolution, + thumbnail_image=thumbnail_image, + ) + ctx = ExportContext( + options=options, + operator=None, + filepath=filepath, + extension_manager=ExtensionManager(), + ) + + # Wire up custom project template path. + if project_template is not None: + ctx.project_template_path = os.path.abspath(project_template) + + # Wire up per-object setting overrides (convert Object keys to name strings). + if object_settings is not None: + for obj, settings_dict in object_settings.items(): + obj_name = str(obj.name) + ctx.object_settings[obj_name] = { + str(k): str(v) for k, v in settings_dict.items() + } + + # Wire up warning callback. + if on_warning is not None: + _original_safe_report = ctx.safe_report + + def _intercepted_safe_report(level, message): + _original_safe_report(level, message) + if "WARNING" in level: + on_warning(message) + result.warnings.append(message) + + ctx.safe_report = _intercepted_safe_report # type: ignore[assignment] + + if on_progress: + on_progress(10, "Creating archive…") + + # Create archive. + archive = create_archive(filepath, ctx.safe_report, ctx.options.compression_level) + if archive is None: + result.status = "CANCELLED" + return result + + # Determine objects to export. + context = bpy.context + if objects is not None: + blender_objects = objects + elif use_selection: + blender_objects = context.selected_objects + mesh_objects = collect_mesh_objects(blender_objects, export_hidden=True) + if not mesh_objects: + error("Export cancelled: No mesh objects in selection") + result.status = "CANCELLED" + return result + else: + blender_objects = context.scene.objects + + if on_progress: + on_progress(20, "Checking geometry…") + + # Non-manifold check. + # Use collect_mesh_objects to walk into Empty hierarchies (e.g. when + # the caller passes a parent Empty grouping several mesh children). + mesh_objects = collect_mesh_objects(blender_objects, export_hidden=True) + if mesh_objects: + non_manifold = check_non_manifold_geometry(mesh_objects, use_mesh_modifiers) + if non_manifold: + msg = f"Non-manifold geometry detected in: {non_manifold[0]}" + warn(msg) + result.warnings.append( + "Exported geometry contains non-manifold issues." + ) + if on_warning: + on_warning(msg) + + if on_progress: + on_progress(30, "Writing 3MF data…") + + scale = export_unit_scale(context, global_scale) + + # Check if any mesh has materials assigned. + # Must check EVALUATED objects because Geometry Nodes "Set Material" + # nodes only create material slots on the evaluated depsgraph copy. + # We detect ANY material (not just multi-material) because slicers + # like Orca/BambuStudio ignore core-spec <basematerials> and only + # read the Orca-style colorgroup/paint_color attributes written by + # OrcaExporter. + has_materials = False + if mesh_objects and use_mesh_modifiers: + depsgraph = context.evaluated_depsgraph_get() + for obj in mesh_objects: + eval_obj = obj.evaluated_get(depsgraph) + if len(eval_obj.material_slots) >= 1: + has_materials = True + break + elif mesh_objects: + has_materials = any( + len(obj.material_slots) >= 1 for obj in mesh_objects + ) + + # Dispatch to exporter. + try: + if use_orca_format == "PAINT": + if mmu_slicer_format == "ORCA": + exporter = OrcaExporter(ctx) + else: + if ctx.project_template_path or ctx.object_settings: + warn( + "project_template and object_settings are Orca-specific " + "features and will be ignored for PrusaSlicer export" + ) + exporter = PrusaExporter(ctx) + elif use_orca_format == "STANDARD": + # Explicit standard mode — always spec-compliant + debug("API: Standard mode requested, using StandardExporter") + exporter = StandardExporter(ctx) + else: + # AUTO mode + if ctx.project_template_path or ctx.object_settings: + exporter = OrcaExporter(ctx) + else: + # Check for MMU paint textures + has_paint = any( + obj.data.get("3mf_is_paint_texture") + for obj in mesh_objects + if obj.type == "MESH" and obj.data is not None + ) + if has_paint: + debug("API AUTO: paint textures detected — promoting to PAINT mode") + ctx.options.use_orca_format = "PAINT" + if mmu_slicer_format == "ORCA": + exporter = OrcaExporter(ctx) + else: + exporter = PrusaExporter(ctx) + elif has_materials: + debug("API AUTO: materials detected, using OrcaExporter") + exporter = OrcaExporter(ctx) + else: + exporter = StandardExporter(ctx) + + status_set = exporter.execute(context, archive, blender_objects, scale) + result.status = next(iter(status_set)) if status_set else "FINISHED" + except Exception as e: + error(f"Export failed: {e}") + result.status = "CANCELLED" + result.warnings.append(str(e)) + return result + + result.num_written = ctx.num_written + + if on_progress: + on_progress(100, "Export complete") + + debug(f"API: Exported {ctx.num_written} objects to {filepath}") + return result
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# batch_import / batch_export +# ═══════════════════════════════════════════════════════════════════════════ + +
+[docs] +def batch_import( + filepaths: Sequence[str], + *, + on_progress: Optional[ProgressCallback] = None, + on_warning: Optional[WarningCallback] = None, + on_object_created: Optional[ObjectCreatedCallback] = None, + **import_kwargs, +) -> List[ImportResult]: + """Import multiple 3MF files in sequence with per-file error isolation. + + Each file is imported independently — a failure in one file does not + prevent the others from being processed. All keyword arguments + supported by :func:`import_3mf` can be passed via ``**import_kwargs`` + and will be applied to every file. + + :param filepaths: Sequence of ``.3mf`` file paths to import. + :param on_progress: Optional global progress callback. Receives + ``(percentage, message)`` where percentage spans 0-100 across + *all* files. + :param on_warning: Warning callback forwarded to each :func:`import_3mf` call. + :param on_object_created: Object-created callback forwarded to each call. + :param import_kwargs: Keyword arguments forwarded to :func:`import_3mf`. + :return: List of :class:`ImportResult`, one per input file (same order). + + Example:: + + results = batch_import( + ["a.3mf", "b.3mf", "c.3mf"], + import_materials="PAINT", + target_collection="Imports", + ) + total = sum(r.num_loaded for r in results) + print(f"Imported {total} objects total") + """ + results: List[ImportResult] = [] + total = len(filepaths) + + for idx, fp in enumerate(filepaths): + # Per-file progress wrapper. + file_progress: Optional[ProgressCallback] = None + if on_progress: + base_pct = int((idx / total) * 100) + span_pct = int(100 / total) if total else 100 + + def _file_progress(pct: int, msg: str, _base=base_pct, _span=span_pct): + overall = _base + int(pct * _span / 100) + on_progress(min(overall, 100), f"[{idx + 1}/{total}] {msg}") + + file_progress = _file_progress + + try: + r = import_3mf( + fp, + on_progress=file_progress, + on_warning=on_warning, + on_object_created=on_object_created, + **import_kwargs, + ) + except Exception as e: + error(f"batch_import: Failed on {fp}: {e}") + r = ImportResult(status="CANCELLED", warnings=[str(e)]) + results.append(r) + + if on_progress: + on_progress(100, "Batch import complete") + + return results
+ + + +
+[docs] +def batch_export( + items: Sequence[Tuple[str, Optional[List]]], + *, + on_progress: Optional[ProgressCallback] = None, + on_warning: Optional[WarningCallback] = None, + **export_kwargs, +) -> List[ExportResult]: + """Export multiple 3MF files in sequence with per-file error isolation. + + Each item is a ``(filepath, objects)`` tuple. If *objects* is ``None``, + the export falls back to the ``use_selection`` / all-scene logic from + :func:`export_3mf`. + + :param items: Sequence of ``(filepath, objects_or_None)`` tuples. + :param on_progress: Optional global progress callback. + :param on_warning: Warning callback forwarded to each :func:`export_3mf` call. + :param export_kwargs: Keyword arguments forwarded to :func:`export_3mf`. + :return: List of :class:`ExportResult`, one per item (same order). + + Example:: + + cubes = [o for o in bpy.data.objects if "Cube" in o.name] + spheres = [o for o in bpy.data.objects if "Sphere" in o.name] + results = batch_export([ + ("cubes.3mf", cubes), + ("spheres.3mf", spheres), + ], use_orca_format="AUTO") + """ + results: List[ExportResult] = [] + total = len(items) + + for idx, (fp, objs) in enumerate(items): + file_progress: Optional[ProgressCallback] = None + if on_progress: + base_pct = int((idx / total) * 100) + span_pct = int(100 / total) if total else 100 + + def _file_progress(pct: int, msg: str, _base=base_pct, _span=span_pct): + overall = _base + int(pct * _span / 100) + on_progress(min(overall, 100), f"[{idx + 1}/{total}] {msg}") + + file_progress = _file_progress + + try: + r = export_3mf( + fp, + objects=objs, + on_progress=file_progress, + on_warning=on_warning, + **export_kwargs, + ) + except Exception as e: + error(f"batch_export: Failed on {fp}: {e}") + r = ExportResult(status="CANCELLED", filepath=fp, warnings=[str(e)]) + results.append(r) + + if on_progress: + on_progress(100, "Batch export complete") + + return results
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# Internal helpers +# ═══════════════════════════════════════════════════════════════════════════ + +def _find_layer_collection( + layer_collection, + target_collection, +): + """Recursively find the LayerCollection wrapping *target_collection*.""" + if layer_collection.collection == target_collection: + return layer_collection + for child in layer_collection.children: + found = _find_layer_collection(child, target_collection) + if found is not None: + return found + return None + + +def _resolve_prefixes(root, prefixes_str: str) -> Set[str]: + """Resolve extension prefix strings to namespace URIs.""" + from .common.constants import PRODUCTION_NAMESPACE + + if not prefixes_str: + return set() + prefix_to_ns = {} + for attr_name, attr_value in root.attrib.items(): + if attr_name.startswith("{"): + continue + if attr_name.startswith("xmlns:"): + prefix_to_ns[attr_name[6:]] = attr_value + known = { + "p": PRODUCTION_NAMESPACE, + "m": "http://schemas.microsoft.com/3dmanufacturing/material/2015/02", + "slic3rpe": "http://schemas.slic3r.org/3mf/2017/06", + } + prefix_to_ns.update({k: v for k, v in known.items() if k not in prefix_to_ns}) + resolved: Set[str] = set() + for prefix in prefixes_str.split(): + prefix = prefix.strip() + if not prefix: + continue + if prefix in prefix_to_ns: + resolved.add(prefix_to_ns[prefix]) + else: + resolved.add(prefix) + return resolved + + +def _inspect_materials(root, result: InspectResult) -> None: + """Scan material resources for inspect_3mf without creating Blender data.""" + mat_ns = {"m": MATERIAL_NAMESPACE} + + # basematerials + for bm_node in root.iterfind( + "./3mf:resources/m:basematerials", {**MODEL_NAMESPACES, **mat_ns} + ): + mat_id = bm_node.attrib.get("id", "") + bases = bm_node.findall("m:base", mat_ns) + result.materials.append({ + "id": mat_id, + "type": "basematerials", + "count": len(bases), + }) + + # colorgroups + for cg_node in root.iterfind( + "./3mf:resources/m:colorgroup", {**MODEL_NAMESPACES, **mat_ns} + ): + cg_id = cg_node.attrib.get("id", "") + colors = cg_node.findall("m:color", mat_ns) + result.materials.append({ + "id": cg_id, + "type": "colorgroup", + "count": len(colors), + }) + + # texture2dgroups + for tg_node in root.iterfind( + "./3mf:resources/m:texture2dgroup", {**MODEL_NAMESPACES, **mat_ns} + ): + tg_id = tg_node.attrib.get("id", "") + coords = tg_node.findall("m:tex2coord", mat_ns) + result.materials.append({ + "id": tg_id, + "type": "texture2dgroup", + "count": len(coords), + }) + + +def _inspect_textures(root, result: InspectResult) -> None: + """Scan texture resources for inspect_3mf.""" + mat_ns = {"m": MATERIAL_NAMESPACE} + for tex_node in root.iterfind( + "./3mf:resources/m:texture2d", {**MODEL_NAMESPACES, **mat_ns} + ): + tex_id = tex_node.attrib.get("id", "") + result.textures.append({ + "id": tex_id, + "path": tex_node.attrib.get("path", ""), + "contenttype": tex_node.attrib.get("contenttype", ""), + }) + + +def _import_unit_scale( + context: bpy.types.Context, + root: xml.etree.ElementTree.Element, + global_scale: float, +) -> float: + """Calculate unit scale exactly like the Import3MF operator.""" + from .common.constants import MODEL_DEFAULT_UNIT + + scale = global_scale + blender_unit_to_metre = context.scene.unit_settings.scale_length + if blender_unit_to_metre == 0: + blender_unit = context.scene.unit_settings.length_unit + blender_unit_to_metre = blender_to_metre[blender_unit] + + threemf_unit = root.attrib.get("unit", MODEL_DEFAULT_UNIT) + threemf_unit_to_metre = threemf_to_metre[threemf_unit] + scale *= threemf_unit_to_metre / blender_unit_to_metre + return scale + + +def _activate_extensions_api( + ctx, + root: xml.etree.ElementTree.Element, +) -> None: + """Activate extensions on ctx.extension_manager (no operator needed).""" + for attr_key in ("requiredextensions", "recommendedextensions"): + ext_str = root.attrib.get(attr_key, "") + if ext_str: + resolved = _resolve_prefixes(root, ext_str) + for ns in resolved: + if ns in SUPPORTED_EXTENSIONS: + ctx.extension_manager.activate(ns) + debug(f"API: Activated extension: {ns}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# Building-block re-exports +# ═══════════════════════════════════════════════════════════════════════════ +# +# These sub-namespace imports expose the common building blocks so addon +# developers and CLI scripts can access them through ``api.*`` without +# needing to know the internal package layout. +# +# from io_mesh_3mf.api import colors +# r, g, b = colors.hex_to_rgb("#CC3319") +# +# from io_mesh_3mf.api import types +# obj = types.ResourceObject(vertices=[], triangles=[], ...) +# +# from io_mesh_3mf.api import segmentation +# tree = segmentation.decode_segmentation_string("A3F0") + +from .common import colors # hex_to_rgb, rgb_to_hex, srgb_to_linear, ... # noqa: E402 +from .common import types # ResourceObject, Component, ResourceMaterial, ... # noqa: E402 +from .common import segmentation # SegmentationDecoder, SegmentationEncoder, ... # noqa: E402 +from .common import units # blender_to_metre, threemf_to_metre, import_unit_scale, ... # noqa: E402 +from .common import extensions # ExtensionManager, Extension, MATERIALS_EXTENSION, ... # noqa: E402 +from .common import xml as xml_tools # parse_transformation, format_transformation, ... # noqa: E402 +from .common import metadata # Metadata, MetadataEntry # noqa: E402 +from .export_3mf import components # detect_linked_duplicates, ComponentGroup, ... # noqa: E402 +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/colors.html b/docs/site/_modules/io_mesh_3mf/common/colors.html new file mode 100644 index 0000000..b7eeff3 --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/colors.html @@ -0,0 +1,404 @@ + + + + + + + + io_mesh_3mf.common.colors - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.colors

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Color space conversion helpers.
+
+3MF hex colors are sRGB.  Blender materials work in linear.  These helpers
+bridge the two representations.
+"""
+
+from typing import Tuple
+
+__all__ = [
+    "srgb_to_linear",
+    "linear_to_srgb",
+    "hex_to_rgb",
+    "hex_to_linear_rgb",
+    "rgb_to_hex",
+    "linear_rgb_to_hex",
+]
+
+
+# ---------------------------------------------------------------------------
+#  Color space conversion
+# ---------------------------------------------------------------------------
+
+
+
+[docs] +def srgb_to_linear(c: float) -> float: + """Convert a single sRGB gamma component to linear. + + 3MF hex colors are sRGB. Blender materials work in linear. + Apply this when **importing** colors from 3MF into Blender materials. + """ + if c <= 0.04045: + return c / 12.92 + return pow((c + 0.055) / 1.055, 2.4)
+ + + +
+[docs] +def linear_to_srgb(c: float) -> float: + """Convert a single linear component to sRGB gamma. + + Blender materials are linear. 3MF hex colors are sRGB. + Apply this when **exporting** colors from Blender materials to 3MF hex. + """ + if c <= 0.0031308: + return c * 12.92 + return 1.055 * pow(c, 1.0 / 2.4) - 0.055
+ + + +# --------------------------------------------------------------------------- +# Hex / RGB helpers +# --------------------------------------------------------------------------- + + +
+[docs] +def hex_to_rgb(hex_str: str) -> Tuple[float, float, float]: + """Convert ``#RRGGBB`` hex string to an ``(r, g, b)`` tuple of 0-1 floats. + + Returns **raw sRGB** values — no gamma conversion. Use this for the paint + texture pipeline where sRGB pixel values must round-trip exactly. + For Blender material colors, use :func:`hex_to_linear_rgb` instead. + + Leading ``#`` is optional. + """ + hex_str = hex_str.lstrip("#") + return ( + int(hex_str[0:2], 16) / 255.0, + int(hex_str[2:4], 16) / 255.0, + int(hex_str[4:6], 16) / 255.0, + )
+ + + +
+[docs] +def hex_to_linear_rgb(hex_str: str) -> Tuple[float, float, float]: + """Convert ``#RRGGBB`` hex string to an ``(r, g, b)`` tuple in **linear** space. + + Parses the sRGB hex value and applies sRGB-to-linear conversion. + Use this when the result will be assigned to Blender material properties + (e.g. ``principled.base_color``). + + Leading ``#`` is optional. + """ + r, g, b = hex_to_rgb(hex_str) + return (srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b))
+ + + +
+[docs] +def rgb_to_hex(r: float, g: float, b: float) -> str: + """Convert 0-1 float RGB values to a ``#RRGGBB`` hex string. + + Expects **raw sRGB** values. For Blender linear colors, use + :func:`linear_rgb_to_hex` instead. + """ + return "#%02X%02X%02X" % ( + min(255, max(0, int(r * 255 + 0.5))), + min(255, max(0, int(g * 255 + 0.5))), + min(255, max(0, int(b * 255 + 0.5))), + )
+ + + +
+[docs] +def linear_rgb_to_hex(r: float, g: float, b: float) -> str: + """Convert linear RGB (0-1) to a ``#RRGGBB`` sRGB hex string. + + Applies linear-to-sRGB conversion before encoding. + Use this when reading colors from Blender materials for 3MF export. + """ + return rgb_to_hex(linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b))
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/extensions.html b/docs/site/_modules/io_mesh_3mf/common/extensions.html new file mode 100644 index 0000000..1bdaf1b --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/extensions.html @@ -0,0 +1,567 @@ + + + + + + + + io_mesh_3mf.common.extensions - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.extensions

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Extension registry and management for 3MF extensions.
+
+This module provides a framework for registering and using 3MF extensions,
+both official extensions from the 3MF Consortium and vendor-specific extensions.
+"""
+
+from typing import Dict, Set, Optional, List
+from dataclasses import dataclass
+from enum import Enum
+
+
+
+[docs] +class ExtensionType(Enum): + """Type of 3MF extension.""" + + OFFICIAL = "official" # Official 3MF Consortium extension + VENDOR = "vendor" # Vendor-specific extension
+ + + +
+[docs] +@dataclass +class Extension: + """ + Represents a 3MF extension with its metadata and capabilities. + + Attributes: + namespace: XML namespace URI for this extension + prefix: Preferred XML namespace prefix + name: Human-readable name + extension_type: Whether this is an official or vendor-specific extension + description: Brief description of what this extension provides + required: Whether this extension must be declared in requiredextensions + vendor_attribute: Optional vendor-specific attribute name (e.g., "BambuStudio:3mfVersion") + """ + + namespace: str + prefix: str + name: str + extension_type: ExtensionType + description: str + required: bool = False + vendor_attribute: Optional[str] = None
+ + + +# Official 3MF Consortium Extensions +# From: http://schemas.microsoft.com/3dmanufacturing/ + +MATERIALS_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/material/2015/02", + prefix="m", + name="Materials and Properties", + extension_type=ExtensionType.OFFICIAL, + description="Defines material properties, colors, and textures for objects", + required=False, +) + +PRODUCTION_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/production/2015/06", + prefix="p", + name="Production", + extension_type=ExtensionType.OFFICIAL, + description="Manufacturing metadata like UUID, path, and production instructions", + required=True, +) + +SLICE_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/slice/2015/07", + prefix="s", + name="Slice", + extension_type=ExtensionType.OFFICIAL, + description="Pre-sliced geometry for specific printers", + required=True, +) + +BEAM_LATTICE_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/beamlattice/2017/02", + prefix="b", + name="Beam Lattice", + extension_type=ExtensionType.OFFICIAL, + description="Efficient representation of lattice structures", + required=True, +) + +VOLUMETRIC_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/volumetric/2017/07", + prefix="v", + name="Volumetric", + extension_type=ExtensionType.OFFICIAL, + description="Voxel-based 3D models", + required=True, +) + +TRIANGLE_SETS_EXTENSION = Extension( + namespace="http://schemas.microsoft.com/3dmanufacturing/trianglesets/2021/07", + prefix="t", + name="Triangle Sets", + extension_type=ExtensionType.OFFICIAL, + description="Grouping of triangles for selection workflows and property assignment", + required=False, +) + +# Vendor-Specific Extensions + +ORCA_EXTENSION = Extension( + namespace="http://schemas.bambulab.com/package/2021", + prefix="BambuStudio", + name="Orca Slicer / BambuStudio", + extension_type=ExtensionType.VENDOR, + description="BambuLab/Orca Slicer specific features including color zones and project settings", + required=False, + vendor_attribute="BambuStudio:3mfVersion", +) + +# Extension Registry +# Maps namespace URI to Extension object +EXTENSION_REGISTRY: Dict[str, Extension] = { + MATERIALS_EXTENSION.namespace: MATERIALS_EXTENSION, + PRODUCTION_EXTENSION.namespace: PRODUCTION_EXTENSION, + SLICE_EXTENSION.namespace: SLICE_EXTENSION, + BEAM_LATTICE_EXTENSION.namespace: BEAM_LATTICE_EXTENSION, + VOLUMETRIC_EXTENSION.namespace: VOLUMETRIC_EXTENSION, + TRIANGLE_SETS_EXTENSION.namespace: TRIANGLE_SETS_EXTENSION, + ORCA_EXTENSION.namespace: ORCA_EXTENSION, +} + + +
+[docs] +class ExtensionManager: + """Manages active extensions for import/export operations.""" + +
+[docs] + def __init__(self): + """Initialize with no active extensions.""" + self._active_extensions: Set[str] = set()
+ + +
+[docs] + def activate(self, namespace: str) -> None: + """Activate an extension by its namespace URI. + + :raises ValueError: If the namespace is not registered. + """ + if namespace not in EXTENSION_REGISTRY: + raise ValueError(f"Unknown extension namespace: {namespace}") + self._active_extensions.add(namespace)
+ + +
+[docs] + def deactivate(self, namespace: str) -> None: + """Deactivate an extension.""" + self._active_extensions.discard(namespace)
+ + +
+[docs] + def is_active(self, namespace: str) -> bool: + """Check if an extension is currently active.""" + return namespace in self._active_extensions
+ + +
+[docs] + def clear(self) -> None: + """Deactivate all extensions.""" + self._active_extensions.clear()
+ + +
+[docs] + def get_active_extensions(self) -> List[Extension]: + """Get list of all active Extension objects.""" + return [EXTENSION_REGISTRY[ns] for ns in self._active_extensions]
+ + +
+[docs] + def get_required_extensions_string(self) -> str: + """Build the ``requiredextensions`` attribute value for the model element. + + :return: Space-separated string of namespace URIs that require declaration. + """ + required = [ + ext.namespace for ext in self.get_active_extensions() if ext.required + ] + return " ".join(required)
+ + +
+[docs] + def get_vendor_attributes(self) -> Dict[str, str]: + """Get vendor-specific attributes to add to the model element.""" + attrs = {} + for ext in self.get_active_extensions(): + if ext.extension_type == ExtensionType.VENDOR and ext.vendor_attribute: + attrs[ext.vendor_attribute] = "1" + return attrs
+ + +
+[docs] + def register_namespaces(self, xml_module) -> None: + """Register all active extension namespaces with ElementTree. + + :param xml_module: The ``xml.etree.ElementTree`` module. + """ + for ext in self.get_active_extensions(): + xml_module.register_namespace(ext.prefix, ext.namespace)
+
+ + + +# Convenience functions + +
+[docs] +def get_extension_by_namespace(namespace: str) -> Optional[Extension]: + """Get Extension object by namespace URI.""" + return EXTENSION_REGISTRY.get(namespace)
+ + + +
+[docs] +def get_extension_by_prefix(prefix: str) -> Optional[Extension]: + """Get Extension object by XML prefix.""" + for ext in EXTENSION_REGISTRY.values(): + if ext.prefix == prefix: + return ext + return None
+ + + +
+[docs] +def list_official_extensions() -> List[Extension]: + """Get all registered official 3MF Consortium extensions.""" + return [ + ext + for ext in EXTENSION_REGISTRY.values() + if ext.extension_type == ExtensionType.OFFICIAL + ]
+ + + +
+[docs] +def list_vendor_extensions() -> List[Extension]: + """Get all registered vendor-specific extensions.""" + return [ + ext + for ext in EXTENSION_REGISTRY.values() + if ext.extension_type == ExtensionType.VENDOR + ]
+ + + +__all__ = [ + "Extension", + "ExtensionType", + "ExtensionManager", + "EXTENSION_REGISTRY", + "MATERIALS_EXTENSION", + "PRODUCTION_EXTENSION", + "SLICE_EXTENSION", + "BEAM_LATTICE_EXTENSION", + "VOLUMETRIC_EXTENSION", + "TRIANGLE_SETS_EXTENSION", + "ORCA_EXTENSION", + "get_extension_by_namespace", + "get_extension_by_prefix", + "list_official_extensions", + "list_vendor_extensions", +] +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/metadata.html b/docs/site/_modules/io_mesh_3mf/common/metadata.html new file mode 100644 index 0000000..afb4c76 --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/metadata.html @@ -0,0 +1,442 @@ + + + + + + + + io_mesh_3mf.common.metadata - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.metadata

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2020 Ghostkeeper
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+# <pep8 compliant>
+
+"""
+Metadata storage for 3MF objects and scenes.
+
+Tracks metadata entries with conflict resolution — if the same key appears
+with different values across multiple 3MF files, that entry is marked
+conflicting and excluded from output.
+"""
+
+import collections
+from typing import Iterator, Union
+
+import bpy.types
+import idprop.types
+
+MetadataEntry = collections.namedtuple(
+    "MetadataEntry", ["name", "preserve", "datatype", "value"]
+)
+
+__all__ = [
+    "Metadata",
+    "MetadataEntry",
+]
+
+
+
+[docs] +class Metadata: + """ + Tracks metadata of a Blender object for 3MF round-trip. + + Behaves like a dictionary keyed by metadata name. Storing the same key + with a different value marks that entry as conflicting (``None``), so only + the intersection of consistent metadata survives a multi-file import. + """ + + def __init__(self): + self.metadata = {} + + def __setitem__(self, key: str, value: MetadataEntry) -> None: + if key not in self.metadata: + self.metadata[key] = value + return + + if self.metadata[key] is None: + return + + competing = self.metadata[key] + if value.value != competing.value or value.datatype != competing.datatype: + self.metadata[key] = None + return + + if not competing.preserve and value.preserve: + self.metadata[key] = MetadataEntry( + name=key, + preserve=True, + datatype=competing.datatype, + value=competing.value, + ) + + def __getitem__(self, key: str) -> MetadataEntry: + if key not in self.metadata or self.metadata[key] is None: + raise KeyError(key) + return self.metadata[key] + + def __contains__(self, item: str) -> bool: + return item in self.metadata and self.metadata[item] is not None + + def __bool__(self) -> bool: + return any(self.values()) + + def __len__(self) -> int: + return sum(1 for _ in self.values()) + + def __delitem__(self, key: str) -> None: + if key in self.metadata: + del self.metadata[key] + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Metadata): + return NotImplemented + return self.metadata == other.metadata + +
+[docs] + def store(self, blender_object: Union[bpy.types.Object, bpy.types.Scene]) -> None: + """Store this metadata in a Blender object as custom properties.""" + for metadata_entry in self.values(): + name = str(metadata_entry.name) + value = ( + str(metadata_entry.value) if metadata_entry.value is not None else "" + ) + if name == "Title": + blender_object.name = value + elif name == "3mf:partnumber": + blender_object[name] = value + else: + datatype = ( + str(metadata_entry.datatype) + if metadata_entry.datatype is not None + else "" + ) + blender_object[name] = { + "datatype": datatype, + "preserve": metadata_entry.preserve, + "value": value, + }
+ + +
+[docs] + def retrieve( + self, blender_object: Union[bpy.types.Object, bpy.types.Scene] + ) -> None: + """Retrieve metadata from a Blender object's custom properties.""" + for key in blender_object.keys(): + cached_key = str(key) + entry = blender_object[key] + if cached_key == "3mf:partnumber": + cached_entry = str(entry) + self[cached_key] = MetadataEntry( + name=cached_key, + preserve=True, + datatype="xs:string", + value=cached_entry, + ) + continue + if ( + isinstance(entry, idprop.types.IDPropertyGroup) + and "datatype" in entry.keys() + and "preserve" in entry.keys() + and "value" in entry.keys() + ): + self[key] = MetadataEntry( + name=key, + preserve=entry.get("preserve"), + datatype=entry.get("datatype"), + value=entry.get("value"), + ) + + self["Title"] = MetadataEntry( + name="Title", + preserve=True, + datatype="xs:string", + value=blender_object.name, + )
+ + +
+[docs] + def values(self) -> Iterator[MetadataEntry]: + """Yield all non-conflicting metadata entries.""" + yield from filter(lambda entry: entry is not None, self.metadata.values())
+
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/segmentation.html b/docs/site/_modules/io_mesh_3mf/common/segmentation.html new file mode 100644 index 0000000..213b391 --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/segmentation.html @@ -0,0 +1,755 @@ + + + + + + + + io_mesh_3mf.common.segmentation - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.segmentation

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Hash-based MMU segmentation string decoder/encoder for 3D printing slicers.
+
+This module implements parsing and encoding of multi-material segmentation using
+hex-encoded binary trees. Used by PrusaSlicer (slic3rpe:mmu_segmentation attribute)
+and Orca Slicer (paint_color attribute). The hash format is slicer-agnostic.
+
+The format encodes a recursive subdivision tree with material/color assignments per region.
+
+Format Reference (from PrusaSlicer TriangleSelector.cpp):
+
+- Each nibble (4 bits) encodes: ``xxyy``
+
+  - ``yy`` = number of split sides (0=leaf, 1-3=subdivided)
+  - ``xx`` = special_side (if split) OR state (if leaf with state < 3)
+
+- If leaf AND ``xx == 0b11``: next nibble contains ``(state - 3)``
+- Tree is traversed depth-first, children in reverse order
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import IntEnum
+from typing import Optional, List, Tuple
+
+from .logging import debug, warn
+
+
+
+[docs] +class TriangleState(IntEnum): + """Triangle paint state - maps to extruder numbers. + + State 0 = object's default extruder (resolved from metadata during import) + State N (N > 0) = Extruder N directly (1-based extruder index) + """ + + DEFAULT = 0 + EXTRUDER_1 = 1 + EXTRUDER_2 = 2 + EXTRUDER_3 = 3 + EXTRUDER_4 = 4 + EXTRUDER_5 = 5 + EXTRUDER_6 = 6 + EXTRUDER_7 = 7 + EXTRUDER_8 = 8 + EXTRUDER_9 = 9 + EXTRUDER_10 = 10 + EXTRUDER_11 = 11 + EXTRUDER_12 = 12 + EXTRUDER_13 = 13 + EXTRUDER_14 = 14 + EXTRUDER_15 = 15
+ + + +
+[docs] +@dataclass +class SegmentationNode: + """ + A node in the triangle subdivision tree. + + Either a leaf node with a state (material), or an internal node with children. + """ + + state: TriangleState = TriangleState.DEFAULT + + split_sides: int = 0 + special_side: int = 0 + + children: Optional[List[SegmentationNode]] = None + + @property + def is_leaf(self) -> bool: + """Check if this is a leaf node (no subdivision).""" + return self.split_sides == 0 + + @property + def num_children(self) -> int: + """Number of child triangles if subdivided.""" + return 0 if self.is_leaf else self.split_sides + 1
+ + + +
+[docs] +@dataclass +class SubdividedTriangle: + """ + A triangle resulting from segmentation subdivision. + + Contains vertex indices (into the expanded vertex list) and the material state. + """ + + v0: int + v1: int + v2: int + state: TriangleState + + source_triangle_index: int = -1
+ + + +
+[docs] +class SegmentationDecoder: + """ + Decodes PrusaSlicer segmentation strings into subdivision trees. + + The hex string is stored in REVERSED order in the 3MF file, so we reverse + it before parsing. Each nibble (hex character) encodes tree structure info. + + Usage:: + + decoder = SegmentationDecoder() + tree = decoder.decode("00000444344043040...") + + # Or decode and subdivide a triangle: + triangles = decoder.subdivide_triangle( + vertices=[(0,0,0), (1,0,0), (0.5,1,0)], + segmentation_string="0004..." + ) + """ + + def __init__(self): + self._hex_string: str = "" + self._nibble_index: int = 0 + + def _read_nibble(self) -> int: + """Read next nibble (4 bits) from the hex string.""" + if self._nibble_index >= len(self._hex_string): + raise ValueError( + f"Segmentation string truncated at nibble {self._nibble_index}/{len(self._hex_string)}" + ) + + try: + nibble = int(self._hex_string[self._nibble_index], 16) + except ValueError as e: + raise ValueError( + f"Invalid hex character '{self._hex_string[self._nibble_index]}' at position {self._nibble_index}" + ) from e + + self._nibble_index += 1 + return nibble + +
+[docs] + def decode(self, hex_string: str) -> Optional[SegmentationNode]: + """ + Decode a segmentation hex string into a tree structure. + + PrusaSlicer stores the hex string in REVERSED order, so we reverse it + before parsing to read the tree root-first. + + :param hex_string: The slic3rpe:mmu_segmentation attribute value + :return: Root node of the subdivision tree, or None if empty/invalid + """ + if not hex_string: + return None + + # The on-disk order is reversed; decode expects root-first order. + self._hex_string = hex_string[::-1] + self._nibble_index = 0 + + if len(self._hex_string) < 1: + return None + + try: + node = self._decode_node() + if self._nibble_index < len(self._hex_string): + debug( + f"Warning: {len(self._hex_string) - self._nibble_index} unused nibbles in segmentation string" + ) + return node + except Exception as e: + warn(f"Error decoding segmentation string (length {len(hex_string)}): {e}") + return None
+ + + def _decode_node(self) -> SegmentationNode: + """Recursively decode a single node from the bitstream.""" + code = self._read_nibble() + + split_sides = code & 0b11 + special_side = (code >> 2) & 0b11 + + if split_sides == 0: + if special_side == 0b11: + state = self._read_nibble() + 3 + else: + state = special_side + + return SegmentationNode(state=TriangleState(min(state, 15))) + else: + num_children = split_sides + 1 + + # Children are stored in the slicer order; decoder keeps that order + # and the encoder reverses at the end to match the file format. + children = [] + for _ in range(num_children): + children.append(self._decode_node()) + + return SegmentationNode( + split_sides=split_sides, special_side=special_side, children=children + )
+ + + +
+[docs] +class TriangleSubdivider: + """ + Subdivides triangles based on segmentation trees. + + This class takes the decoded segmentation tree and the original triangle + vertices, then produces a list of leaf triangles with their states. + """ + + def __init__(self): + self._vertices: List[Tuple[float, float, float]] = [] + self._vertex_map: dict = {} + self._result_triangles: List[SubdividedTriangle] = [] + +
+[docs] + def subdivide( + self, + v0: Tuple[float, float, float], + v1: Tuple[float, float, float], + v2: Tuple[float, float, float], + tree: SegmentationNode, + source_triangle_index: int = -1, + ) -> Tuple[List[Tuple[float, float, float]], List[SubdividedTriangle]]: + """ + Subdivide a triangle according to the segmentation tree. + + :param v0, v1, v2: Original triangle vertices + :param tree: Decoded segmentation tree + :param source_triangle_index: Index of original triangle (for roundtrip tracking) + :return: Tuple of (all vertices, list of leaf triangles) + """ + self._vertices = [v0, v1, v2] + self._vertex_map = {} + self._result_triangles = [] + + self._subdivide_node(tree, 0, 1, 2, source_triangle_index) + + return self._vertices, self._result_triangles
+ + + def _get_midpoint(self, idx1: int, idx2: int) -> int: + """Get or create vertex at midpoint of edge (idx1, idx2).""" + key = (min(idx1, idx2), max(idx1, idx2)) + + if key in self._vertex_map: + return self._vertex_map[key] + + v1 = self._vertices[idx1] + v2 = self._vertices[idx2] + midpoint = ((v1[0] + v2[0]) / 2.0, (v1[1] + v2[1]) / 2.0, (v1[2] + v2[2]) / 2.0) + + new_idx = len(self._vertices) + self._vertices.append(midpoint) + self._vertex_map[key] = new_idx + + return new_idx + + def _subdivide_node( + self, + node: SegmentationNode, + i0: int, + i1: int, + i2: int, + source_triangle_index: int, + ): + """ + Recursively subdivide based on a node. + + :param node: Current tree node + :param i0, i1, i2: Vertex indices of current triangle + :param source_triangle_index: Original triangle index for tracking + """ + if node.is_leaf: + self._result_triangles.append( + SubdividedTriangle( + v0=i0, + v1=i1, + v2=i2, + state=node.state, + source_triangle_index=source_triangle_index, + ) + ) + return + + split_sides = node.split_sides + special = node.special_side + + # special_side rotates which edge is treated as the first split edge. + # This matches slicer behavior when encoding non-3-way splits. + verts = [i0, i1, i2] + rotated = [verts[(special + j) % 3] for j in range(3)] + r0, r1, r2 = rotated[0], rotated[1], rotated[2] + + # Children are encoded in forward order but stored reversed in the file. + # Reverse here so subdivision order matches the original slicer output. + children = node.children[::-1] + + if split_sides == 1: + m = self._get_midpoint(r1, r2) + + self._subdivide_node(children[0], r0, r1, m, source_triangle_index) + self._subdivide_node(children[1], m, r2, r0, source_triangle_index) + + elif split_sides == 2: + m01 = self._get_midpoint(r0, r1) + m20 = self._get_midpoint(r2, r0) + + self._subdivide_node(children[0], r0, m01, m20, source_triangle_index) + self._subdivide_node(children[1], m01, r1, m20, source_triangle_index) + self._subdivide_node(children[2], r1, r2, m20, source_triangle_index) + + elif split_sides == 3: + m01 = self._get_midpoint(r0, r1) + m12 = self._get_midpoint(r1, r2) + m20 = self._get_midpoint(r2, r0) + + self._subdivide_node(children[0], r0, m01, m20, source_triangle_index) + self._subdivide_node(children[1], m01, r1, m12, source_triangle_index) + self._subdivide_node(children[2], m12, r2, m20, source_triangle_index) + self._subdivide_node(children[3], m01, m12, m20, source_triangle_index)
+ + + +
+[docs] +class SegmentationEncoder: + """ + Encodes subdivision trees back to PrusaSlicer hex strings. + + For roundtrip preservation, we can store the original string and avoid + re-encoding entirely. But this class allows generating new strings from + modified subdivision data. + """ + + def __init__(self): + self._nibbles: List[int] = [] + +
+[docs] + def encode(self, tree: SegmentationNode) -> str: + """ + Encode a segmentation tree to a hex string. + + Matches PrusaSlicer's serialization format exactly. + The output is reversed to match the stored format in 3MF files. + + :param tree: Root node of the subdivision tree + :return: Hex string suitable for slic3rpe:mmu_segmentation attribute + """ + self._nibbles = [] + self._encode_node(tree) + + # Reverse at the end to match slic3rpe:mmu_segmentation storage order. + hex_str = "".join(format(n, "X") for n in self._nibbles) + return hex_str[::-1]
+ + + def _encode_node(self, node: SegmentationNode): + """Recursively encode a node to nibbles.""" + if node.is_leaf: + state = int(node.state) + if state >= 3: + self._nibbles.append(0b1100) + self._nibbles.append(state - 3) + else: + self._nibbles.append((state << 2) | 0) + else: + code = (node.special_side << 2) | node.split_sides + self._nibbles.append(code) + + # Encode in forward order; reversal happens on the final hex string. + for child in node.children: + self._encode_node(child)
+ + + +
+[docs] +def decode_segmentation_string(hex_string: str) -> Optional[SegmentationNode]: + """ + Convenience function to decode a segmentation string. + + :param hex_string: The slic3rpe:mmu_segmentation attribute value + :return: Root node of the subdivision tree + """ + decoder = SegmentationDecoder() + return decoder.decode(hex_string)
+ + + +
+[docs] +def subdivide_triangle_with_segmentation( + vertices: List[Tuple[float, float, float]], + v0_idx: int, + v1_idx: int, + v2_idx: int, + hex_string: str, + source_triangle_index: int = -1, +) -> Tuple[List[Tuple[float, float, float]], List[SubdividedTriangle]]: + """ + Decode segmentation string and subdivide a triangle accordingly. + + :param vertices: List of all vertices (will be extended with new midpoints) + :param v0_idx, v1_idx, v2_idx: Indices into vertices for the triangle + :param hex_string: The slic3rpe:mmu_segmentation attribute value + :param source_triangle_index: Original triangle index for roundtrip tracking + :return: Tuple of (updated vertices list, list of resulting triangles) + """ + tree = decode_segmentation_string(hex_string) + if tree is None: + # No segmentation string means the triangle stays intact. + return vertices, [ + SubdividedTriangle( + v0=v0_idx, + v1=v1_idx, + v2=v2_idx, + state=TriangleState.DEFAULT, + source_triangle_index=source_triangle_index, + ) + ] + + v0 = vertices[v0_idx] + v1 = vertices[v1_idx] + v2 = vertices[v2_idx] + + subdivider = TriangleSubdivider() + new_verts, sub_tris = subdivider.subdivide(v0, v1, v2, tree, source_triangle_index) + + base_idx = len(vertices) + + for v in new_verts[3:]: + vertices.append(v) + + def remap_idx(local_idx: int) -> int: + if local_idx == 0: + return v0_idx + elif local_idx == 1: + return v1_idx + elif local_idx == 2: + return v2_idx + else: + return base_idx + (local_idx - 3) + + result_tris = [] + for tri in sub_tris: + result_tris.append( + SubdividedTriangle( + v0=remap_idx(tri.v0), + v1=remap_idx(tri.v1), + v2=remap_idx(tri.v2), + state=tri.state, + source_triangle_index=tri.source_triangle_index, + ) + ) + + return vertices, result_tris
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/types.html b/docs/site/_modules/io_mesh_3mf/common/types.html new file mode 100644 index 0000000..90ee55b --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/types.html @@ -0,0 +1,477 @@ + + + + + + + + io_mesh_3mf.common.types - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.types

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Data types for 3MF import/export.
+
+All structured data types (previously ``namedtuple`` definitions scattered in
+``import_3mf.py``) live here as ``@dataclass`` classes.  Both the import and
+export sides import from this single module.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Optional, List, Dict, Tuple
+
+__all__ = [
+    "ResourceObject",
+    "Component",
+    "ResourceMaterial",
+    "ResourceTexture",
+    "ResourceTextureGroup",
+    "ResourceComposite",
+    "ResourceMultiproperties",
+    "ResourcePBRTextureDisplay",
+    "ResourceColorgroup",
+    "ResourcePBRDisplayProps",
+]
+
+
+
+[docs] +@dataclass +class ResourceObject: + """A parsed ``<object>`` element from a 3MF model file.""" + + vertices: List[Tuple[float, float, float]] + triangles: list # List of (v1, v2, v3, material, ...) tuples + materials: dict # Dict mapping face index → ResourceMaterial + components: list # List of Component instances + metadata: object = None # Metadata instance + triangle_sets: Optional[dict] = None + triangle_uvs: Optional[dict] = None + segmentation_strings: Optional[dict] = None + seam_strings: Optional[dict] = None + support_strings: Optional[dict] = None + default_extruder: Optional[int] = None
+ + + +
+[docs] +@dataclass +class Component: + """A ``<component>`` reference inside an ``<object>``.""" + + resource_object: str # Object ID string + transformation: object = None # mathutils.Matrix + path: Optional[str] = None # Production Extension p:path
+ + + +
+[docs] +@dataclass +class ResourceMaterial: + """Material properties parsed from 3MF basematerials, colorgroups, or PBR extensions.""" + + name: Optional[str] = None + color: Optional[Tuple[float, ...]] = None # RGBA tuple (0-1 range) + + # PBR Metallic workflow + metallic: Optional[float] = None + roughness: Optional[float] = None + + # PBR Specular workflow + specular_color: Optional[Tuple[float, float, float]] = None + glossiness: Optional[float] = None + + # Translucent materials + ior: Optional[float] = None + attenuation: Optional[Tuple[float, float, float]] = None + transmission: Optional[float] = None + + # Texture support + texture_id: Optional[str] = None + + # Textured PBR support + metallic_texid: Optional[str] = None + roughness_texid: Optional[str] = None + specular_texid: Optional[str] = None + glossiness_texid: Optional[str] = None + basecolor_texid: Optional[str] = None + + # Multiproperties multi-texture support + extra_texture_ids: Optional[List[str]] = None + + def __hash__(self): + """Hash based on name and color for use as dict keys / set members.""" + return hash((self.name, self.color)) + + def __eq__(self, other): + if not isinstance(other, ResourceMaterial): + return NotImplemented + return (self.name, self.color) == (other.name, other.color)
+ + + +
+[docs] +@dataclass +class ResourceTexture: + """A ``<texture2d>`` element — texture image metadata.""" + + path: str # Path to texture file in archive + contenttype: str # MIME type + tilestyleu: str = "wrap" + tilestylev: str = "wrap" + filter: str = "auto" + blender_image: object = None # bpy.types.Image (set after extraction)
+ + + +
+[docs] +@dataclass +class ResourceTextureGroup: + """A ``<texture2dgroup>`` container for texture coordinates.""" + + texid: str # ID of referenced <texture2d> + tex2coords: List[Tuple[float, float]] = field(default_factory=list) + displaypropertiesid: Optional[str] = None
+ + + +
+[docs] +@dataclass +class ResourceComposite: + """Composite materials (Materials Extension) for round-trip support.""" + + matid: str + matindices: str = "" # Space-delimited material indices + displaypropertiesid: Optional[str] = None + composites: List[dict] = field(default_factory=list)
+ + + +
+[docs] +@dataclass +class ResourceMultiproperties: + """Multiproperties (Materials Extension) for round-trip support.""" + + pids: str # Space-delimited property group IDs + blendmethods: Optional[str] = None + multis: List[dict] = field(default_factory=list)
+ + + +
+[docs] +@dataclass +class ResourcePBRTextureDisplay: + """Textured PBR display properties (metallic/specular texture maps).""" + + type: str # "metallic" or "specular" + name: Optional[str] = None + primary_texid: Optional[str] = None + secondary_texid: Optional[str] = None + basecolor_texid: Optional[str] = None + factors: Dict[str, str] = field(default_factory=dict)
+ + + +
+[docs] +@dataclass +class ResourceColorgroup: + """Passthrough storage for ``<colorgroup>`` elements.""" + + colors: List[str] # Color strings in original format + displaypropertiesid: Optional[str] = None
+ + + +
+[docs] +@dataclass +class ResourcePBRDisplayProps: + """Passthrough storage for non-textured PBR display properties.""" + + type: str # "metallic", "specular", or "translucent" + properties: List[dict] = field(default_factory=list)
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/common/units.html b/docs/site/_modules/io_mesh_3mf/common/units.html new file mode 100644 index 0000000..165ca17 --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/common/units.html @@ -0,0 +1,384 @@ + + + + + + + + io_mesh_3mf.common.units - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.common.units

+# Blender add-on to import and export 3MF files.
+# Copyright (C) 2020 Ghostkeeper
+# Copyright (C) 2025 Jack (modernization for Blender 4.2+)
+# This add-on is free software; you can redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later
+# version.
+# This add-on is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
+# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+# You should have received a copy of the GNU General Public License along with this program; if not, write to the Free
+# Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""
+Unit conversions between Blender's units and 3MF's units.
+"""
+
+from typing import Dict
+
+import bpy.types  # For type hints in unit_scale functions
+
+from .constants import MODEL_DEFAULT_UNIT
+
+__all__ = [
+    "blender_to_metre",
+    "threemf_to_metre",
+    "import_unit_scale",
+    "export_unit_scale",
+]
+
+blender_to_metre: Dict[str, float] = {
+    # Scale of each of Blender's length units to a metre.
+    "THOU": 0.0000254,
+    "INCHES": 0.0254,
+    "FEET": 0.3048,
+    "YARDS": 0.9144,
+    "CHAINS": 20.1168,
+    "FURLONGS": 201.168,
+    "MILES": 1609.344,
+    "MICROMETERS": 0.000001,
+    "MILLIMETERS": 0.001,
+    "CENTIMETERS": 0.01,
+    "DECIMETERS": 0.1,
+    "METERS": 1,
+    "ADAPTIVE": 1,
+    "DEKAMETERS": 10,
+    "HECTOMETERS": 100,
+    "KILOMETERS": 1000,
+}
+
+threemf_to_metre: Dict[str, float] = {
+    # Scale of each of 3MF's length units to a metre.
+    "micron": 0.000001,
+    "millimeter": 0.001,
+    "centimeter": 0.01,
+    "inch": 0.0254,
+    "foot": 0.3048,
+    "meter": 1,
+}
+
+
+
+[docs] +def import_unit_scale( + context: bpy.types.Context, + root, + global_scale: float = 1.0, +) -> float: + """Compute the import scale factor from 3MF document units to Blender scene units. + + :param context: The Blender context (for scene unit settings). + :param root: The XML root element of the 3MF model (reads ``unit`` attribute). + :param global_scale: Additional user-specified scale multiplier. + :return: Combined scale factor to apply to coordinates. + """ + scale = global_scale + + blender_unit_to_metre = context.scene.unit_settings.scale_length + if blender_unit_to_metre == 0: # Fallback for special cases. + blender_unit = context.scene.unit_settings.length_unit + blender_unit_to_metre = blender_to_metre[blender_unit] + + threemf_unit = root.attrib.get("unit", MODEL_DEFAULT_UNIT) + threemf_unit_to_metre = threemf_to_metre[threemf_unit] + + scale *= threemf_unit_to_metre / blender_unit_to_metre + return scale
+ + + +
+[docs] +def export_unit_scale(context: bpy.types.Context, global_scale: float = 1.0) -> float: + """Compute the export scale factor from Blender scene units to 3MF millimeters. + + :param context: The Blender context (for scene unit settings). + :param global_scale: Additional user-specified scale multiplier. + :return: Scale factor to apply to coordinates during export. + """ + scale = global_scale + + blender_unit_to_metre = context.scene.unit_settings.scale_length + if blender_unit_to_metre == 0: + blender_unit = context.scene.unit_settings.length_unit + blender_unit_to_metre = blender_to_metre[blender_unit] + + threemf_unit_to_metre = threemf_to_metre[MODEL_DEFAULT_UNIT] # Always export as mm + + scale *= blender_unit_to_metre / threemf_unit_to_metre + return scale
+ +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_modules/io_mesh_3mf/threemf_discovery.html b/docs/site/_modules/io_mesh_3mf/threemf_discovery.html new file mode 100644 index 0000000..3413bd3 --- /dev/null +++ b/docs/site/_modules/io_mesh_3mf/threemf_discovery.html @@ -0,0 +1,452 @@ + + + + + + + + io_mesh_3mf.threemf_discovery - 3MF Format API — v1.0.0 + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + + + Back to top + +
+
+ +
+ +
+
+

Source code for io_mesh_3mf.threemf_discovery

+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2025 Jack
+"""
+3MF API Discovery Helper — Copy this into your addon
+
+This module provides utility functions to discover and use the 3MF Import/Export
+addon's public API from another Blender addon. Copy this file into your addon
+or inline the functions you need.
+
+The 3MF addon registers itself in bpy.app.driver_namespace["io_mesh_3mf"] when
+enabled, making it discoverable without parsing addon directories.
+
+Example usage::
+
+    from . import threemf_discovery  # or inline the functions
+
+    def my_operator_execute(self, context):
+        api = threemf_discovery.get_threemf_api()
+        if api is None:
+            self.report({'ERROR'}, "3MF Format addon not installed/enabled")
+            return {'CANCELLED'}
+
+        # Import a 3MF file
+        result = api.import_3mf("/path/to/model.3mf")
+        if result.status == "FINISHED":
+            self.report({'INFO'}, f"Imported {result.num_loaded} objects")
+
+        # Export selected objects
+        result = api.export_3mf(
+            "/path/to/output.3mf",
+            use_selection=True,
+            use_orca_format="PAINT",
+        )
+
+        # Inspect without importing
+        info = api.inspect_3mf("/path/to/model.3mf")
+        print(info.unit, info.num_objects)
+
+        return {'FINISHED'}
+"""
+
+from typing import TYPE_CHECKING, Optional, Tuple
+
+import bpy
+
+if TYPE_CHECKING:
+    # Type hints for IDE support — these are only used for static analysis,
+    # not at runtime, so no ImportError if 3MF addon isn't installed.
+    from io_mesh_3mf import api as ThreeMFAPI
+else:
+    ThreeMFAPI = None
+
+# Registry key used by the 3MF addon
+_REGISTRY_KEY = "io_mesh_3mf"
+
+
+
+[docs] +def is_threemf_available() -> bool: + """Check if the 3MF addon is installed, enabled, and its API is registered. + + :return: True if the 3MF API is available for use. + """ + return _REGISTRY_KEY in bpy.app.driver_namespace
+ + + +
+[docs] +def get_threemf_api() -> Optional["ThreeMFAPI"]: + """Get the 3MF API module if available. + + :return: The io_mesh_3mf.api module, or None if not available. + + Example:: + + api = get_threemf_api() + if api: + result = api.import_3mf("/model.3mf") + """ + return bpy.app.driver_namespace.get(_REGISTRY_KEY)
+ + + +
+[docs] +def get_threemf_version() -> Optional[Tuple[int, int, int]]: + """Get the 3MF API version tuple (major, minor, patch). + + :return: Version tuple like (1, 0, 0), or None if not available. + """ + api = get_threemf_api() + if api is not None: + return getattr(api, "API_VERSION", None) + return None
+ + + +
+[docs] +def check_threemf_version(minimum: Tuple[int, int, int]) -> bool: + """Check if the installed 3MF API meets a minimum version requirement. + + :param minimum: Tuple of (major, minor, patch) minimum version. + :return: True if the API version >= minimum, False otherwise. + + Example:: + + if check_threemf_version((1, 2, 0)): + # Safe to use features added in v1.2.0 + ... + """ + version = get_threemf_version() + if version is None: + return False + return version >= minimum
+ + + +
+[docs] +def has_threemf_capability(capability: str) -> bool: + """Check if a specific 3MF API capability is available. + + Use this for forward-compatible feature detection. Capabilities include: + - "import", "export", "inspect", "batch" + - "callbacks" (on_progress, on_warning, on_object_created) + - "target_collection", "orca_format", "prusa_format" + - "paint_mode", "project_template", "object_settings" + - "building_blocks" (colors, types, segmentation sub-namespaces) + + :param capability: Capability name string. + :return: True if the capability is supported. + """ + api = get_threemf_api() + if api is None: + return False + capabilities = getattr(api, "API_CAPABILITIES", frozenset()) + return capability in capabilities
+ + + +# ═══════════════════════════════════════════════════════════════════════════ +# Convenience wrappers (optional — you can call api.* directly instead) +# ═══════════════════════════════════════════════════════════════════════════ + +def import_3mf(filepath: str, **kwargs): + """Import a 3MF file. Returns ImportResult or None if API unavailable. + + See io_mesh_3mf.api.import_3mf for full parameter documentation. + """ + api = get_threemf_api() + if api is None: + return None + return api.import_3mf(filepath, **kwargs) + + +def export_3mf(filepath: str, **kwargs): + """Export to 3MF file. Returns ExportResult or None if API unavailable. + + See io_mesh_3mf.api.export_3mf for full parameter documentation. + """ + api = get_threemf_api() + if api is None: + return None + return api.export_3mf(filepath, **kwargs) + + +def inspect_3mf(filepath: str): + """Inspect a 3MF file without importing. Returns InspectResult or None. + + See io_mesh_3mf.api.inspect_3mf for full parameter documentation. + """ + api = get_threemf_api() + if api is None: + return None + return api.inspect_3mf(filepath) +
+
+
+
+ + +
+
+ + Made with Sphinx and @pradyunsg's + + Furo + +
+
+ +
+
+ +
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/docs/site/_sources/api.rst.txt b/docs/site/_sources/api.rst.txt new file mode 100644 index 0000000..900ff44 --- /dev/null +++ b/docs/site/_sources/api.rst.txt @@ -0,0 +1,73 @@ +Core API +======== + +The public API lives in :mod:`io_mesh_3mf.api`. All functions return +lightweight result dataclasses — they never raise exceptions for normal +failures (corrupt files, empty scenes, etc.). + +.. module:: io_mesh_3mf.api + :synopsis: Programmatic 3MF import, export, and inspection. + +Version & Capabilities +---------------------- + +.. autodata:: API_VERSION + :annotation: + +.. autodata:: API_VERSION_STRING + :annotation: + +.. autodata:: API_CAPABILITIES + :annotation: + +Result Dataclasses +------------------ + +.. autoclass:: ImportResult + :members: + :no-undoc-members: + +.. autoclass:: ExportResult + :members: + :no-undoc-members: + +.. autoclass:: InspectResult + :members: + :no-undoc-members: + +Import +------ + +.. autofunction:: import_3mf + +Export +------ + +.. autofunction:: export_3mf + +Inspect +------- + +.. autofunction:: inspect_3mf + +Batch Operations +---------------- + +.. autofunction:: batch_import + +.. autofunction:: batch_export + +Callback Types +-------------- + +The following type aliases describe the callback signatures accepted by +the import/export functions: + +``ProgressCallback`` + ``Callable[[int, str], None]`` — receives ``(percentage, message)``. + +``WarningCallback`` + ``Callable[[str], None]`` — receives a warning message string. + +``ObjectCreatedCallback`` + ``Callable[[Any, str], None]`` — receives ``(blender_object, resource_id)``. diff --git a/docs/site/_sources/building-blocks.rst.txt b/docs/site/_sources/building-blocks.rst.txt new file mode 100644 index 0000000..244851f --- /dev/null +++ b/docs/site/_sources/building-blocks.rst.txt @@ -0,0 +1,52 @@ +Building Blocks +=============== + +The API re-exports common modules for custom workflows. These are the +same modules used internally by the import/export pipeline. + +.. code-block:: python + + from io_mesh_3mf.api import colors, types, segmentation, units + +Colors +------ + +.. automodule:: io_mesh_3mf.common.colors + :members: + :undoc-members: + +Units +----- + +.. automodule:: io_mesh_3mf.common.units + :members: + :undoc-members: + +Types (Dataclasses) +------------------- + +.. automodule:: io_mesh_3mf.common.types + :members: + :undoc-members: + :show-inheritance: + +Segmentation Codec +------------------ + +.. automodule:: io_mesh_3mf.common.segmentation + :members: + :undoc-members: + +Extensions +---------- + +.. automodule:: io_mesh_3mf.common.extensions + :members: + :no-undoc-members: + +Metadata +-------- + +.. automodule:: io_mesh_3mf.common.metadata + :members: + :undoc-members: diff --git a/docs/site/_sources/discovery.rst.txt b/docs/site/_sources/discovery.rst.txt new file mode 100644 index 0000000..943e4ff --- /dev/null +++ b/docs/site/_sources/discovery.rst.txt @@ -0,0 +1,28 @@ +API Discovery +============= + +Other Blender addons can discover and feature-check the 3MF API without +hard-importing it. The addon registers itself in +``bpy.app.driver_namespace["io_mesh_3mf"]`` on startup. + +Direct Discovery (from ``api``) +------------------------------- + +.. autofunction:: io_mesh_3mf.api.is_available + +.. autofunction:: io_mesh_3mf.api.get_api + +.. autofunction:: io_mesh_3mf.api.has_capability + +.. autofunction:: io_mesh_3mf.api.check_version + +Standalone Discovery Helper +---------------------------- + +For addons that want **zero runtime dependency** on the 3MF addon, copy +``io_mesh_3mf/threemf_discovery.py`` into your addon. + +.. automodule:: io_mesh_3mf.threemf_discovery + :members: + :undoc-members: + :exclude-members: import_3mf, export_3mf, inspect_3mf diff --git a/docs/site/_sources/guide.rst.txt b/docs/site/_sources/guide.rst.txt new file mode 100644 index 0000000..c2132f3 --- /dev/null +++ b/docs/site/_sources/guide.rst.txt @@ -0,0 +1,170 @@ +Getting Started +=============== + +The public API in :mod:`io_mesh_3mf.api` provides headless/programmatic access +to the full 3MF pipeline. It runs the same code as the Blender operators but +skips UI-specific behaviour (progress bars, popups, camera zoom), making it +suitable for: + +- **CLI automation** — batch processing from Blender's ``--python`` mode +- **Addon integration** — other Blender addons importing/exporting 3MF +- **Headless pipelines** — render farms, CI/CD, asset processing +- **Custom workflows** — building on top of the low-level building blocks + + +Quick Start +----------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf, export_3mf, inspect_3mf + + # Import a 3MF file + result = import_3mf("/path/to/model.3mf") + print(result.status, result.num_loaded) + + # Export selected objects + result = export_3mf("/path/to/output.3mf", use_selection=True) + print(result.status, result.num_written) + + # Inspect without importing (no Blender objects created) + info = inspect_3mf("/path/to/model.3mf") + print(info.unit, info.num_objects, info.num_triangles_total) + +All functions return lightweight dataclasses — they never raise exceptions for +normal failures (corrupt files, empty scenes, etc.). Check ``result.status`` +instead. + + +Export Format Reference +----------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - ``use_orca_format`` + - ``mmu_slicer_format`` + - Output + * - ``"AUTO"`` + - — + - Chooses best format based on scene content + * - ``"STANDARD"`` + - — + - Spec-compliant single-model 3MF + * - ``"PAINT"`` + - ``"ORCA"`` + - Multi-file Orca/Bambu structure with ``paint_color`` attributes + * - ``"PAINT"`` + - ``"PRUSA"`` + - Single-file with ``slic3rpe:mmu_segmentation`` hash strings + +In **AUTO** mode the addon inspects your scene and picks the best path: + +- Objects with MMU paint textures → Orca exporter with segmentation +- Objects with material slots → Standard exporter with basematerials/colorgroups +- Geometry-only objects → Standard exporter, geometry only +- If *project_template* or *object_settings* is provided → Orca exporter + + +Callbacks +--------- + +All three callback types are optional and work the same way across +:func:`~io_mesh_3mf.api.import_3mf`, :func:`~io_mesh_3mf.api.export_3mf`, and +the batch helpers. + +.. code-block:: python + + def on_progress(percentage: int, message: str): + """Called with 0-100 percentage and a status message.""" + print(f"[{percentage:3d}%] {message}") + + def on_warning(message: str): + """Called for each warning (non-manifold geometry, missing data, etc.).""" + print(f"WARNING: {message}") + + def on_object_created(blender_object, resource_id: str): + """Called after each Blender object is built during import.""" + blender_object.color = (1, 0, 0, 1) # Tint red + + +Error Handling +-------------- + +All API functions return result dataclasses instead of raising exceptions. +Check ``result.status``: + +.. code-block:: python + + result = import_3mf("model.3mf") + if result.status == "FINISHED": + print(f"Success: {result.num_loaded} objects") + else: + print(f"Failed: {result.warnings}") + +- Archive-level errors (corrupt ZIP, missing model files) set + ``status = "CANCELLED"``. +- Per-object warnings (non-manifold geometry, missing textures) are collected in + ``warnings`` but don't prevent completion. +- :func:`~io_mesh_3mf.api.inspect_3mf` uses ``status = "OK"`` / ``"ERROR"`` + with a separate ``error_message`` field. + + +CLI Usage +--------- + +Run from the command line using Blender's ``--python`` flag: + +.. code-block:: bash + + # Inspect a file + blender --background --python-expr " + from io_mesh_3mf.api import inspect_3mf + info = inspect_3mf('model.3mf') + print(f'{info.num_objects} objects, {info.num_triangles_total} triangles') + " + + # Batch convert + blender --background --python my_script.py + +**Example script** (``convert_to_orca.py``): + +.. code-block:: python + + """Convert a standard 3MF to Orca Slicer format.""" + import sys + from io_mesh_3mf.api import import_3mf, export_3mf + + input_path = sys.argv[sys.argv.index("--") + 1] + output_path = input_path.replace(".3mf", "_orca.3mf") + + result = import_3mf(input_path, import_materials="MATERIALS") + if result.status == "FINISHED": + export_result = export_3mf( + output_path, + objects=result.objects, + use_orca_format="AUTO", + ) + print(f"Converted: {export_result.num_written} objects → {output_path}") + +.. code-block:: bash + + blender --background --python convert_to_orca.py -- input.3mf + + +Notes +----- + +- **Blender context required** — :func:`~io_mesh_3mf.api.import_3mf` and + :func:`~io_mesh_3mf.api.export_3mf` need ``bpy.context``. They work in + ``--background`` mode but not outside Blender entirely. +- **inspect_3mf is lightweight** — it only opens the ZIP and parses XML. + No Blender objects, materials, or images are created. +- **Thread safety** — Blender's Python API is not thread-safe. Don't call + these functions from background threads. +- **Batch isolation** — :func:`~io_mesh_3mf.api.batch_import` and + :func:`~io_mesh_3mf.api.batch_export` catch per-file exceptions so one + failure doesn't stop the batch. +- **API vs addon version** — ``API_VERSION`` tracks the API contract stability. + It increments independently of the addon release version. diff --git a/docs/site/_sources/index.rst.txt b/docs/site/_sources/index.rst.txt new file mode 100644 index 0000000..bfff680 --- /dev/null +++ b/docs/site/_sources/index.rst.txt @@ -0,0 +1,19 @@ +3MF Format — API Documentation +================================ + +Programmatic 3MF import, export, and inspection for Blender — without +``bpy.ops``. + +Start with the :doc:`guide` for an overview and quick-start examples, then see +the :doc:`api` for the full auto‑generated parameter reference. The +:doc:`recipes` page collects ready-to-use patterns for common workflows. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + guide + recipes + api + discovery + building-blocks diff --git a/docs/site/_sources/recipes.rst.txt b/docs/site/_sources/recipes.rst.txt new file mode 100644 index 0000000..82cacb6 --- /dev/null +++ b/docs/site/_sources/recipes.rst.txt @@ -0,0 +1,183 @@ +Recipes +======= + +Practical patterns for common workflows. + + +Import with Material Painting +----------------------------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf + + result = import_3mf( + "/models/multicolor.3mf", + import_materials="PAINT", + import_location="ORIGIN", + ) + + for obj in result.objects: + print(f" {obj.name}: {len(obj.data.vertices)} verts") + + +Import into a Specific Collection +---------------------------------- + +.. code-block:: python + + result = import_3mf( + "/models/part.3mf", + target_collection="Imported Parts", + reuse_materials=True, + ) + + +Export for Orca Slicer +---------------------- + +The export dispatch uses a three-way mode: ``AUTO``, ``STANDARD``, or ``PAINT``. + +- **AUTO** (default) — detects materials and paint data, choosing the best + exporter automatically. +- **STANDARD** — always uses the spec-compliant StandardExporter. +- **PAINT** — forces segmentation export for multi-material painting. + +.. code-block:: python + + from io_mesh_3mf.api import export_3mf + import bpy + + cubes = [o for o in bpy.data.objects if o.type == "MESH" and "Cube" in o.name] + + result = export_3mf( + "/output/cubes.3mf", + objects=cubes, + use_orca_format="AUTO", + ) + print(f"Exported {result.num_written} objects") + + +Export for PrusaSlicer with MMU Paint +------------------------------------- + +.. code-block:: python + + result = export_3mf( + "/output/painted.3mf", + use_orca_format="PAINT", + mmu_slicer_format="PRUSA", + use_selection=True, + ) + + +Custom Orca Project Template +----------------------------- + +Use a custom printer/filament profile extracted from Orca Slicer: + +.. code-block:: python + + result = export_3mf( + "/output/custom_printer.3mf", + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + project_template="/templates/bambu_x1c_asa.json", + object_settings={ + supports_obj: { + "layer_height": "0.12", + "wall_loops": "2", + "sparse_infill_density": "10%", + }, + detail_part: { + "layer_height": "0.08", + "outer_wall_speed": "50", + }, + }, + ) + +.. tip:: + + **Getting custom templates:** Export a project from Orca Slicer as ``.3mf``, + open the archive with a ZIP tool, and extract + ``Metadata/project_settings.config``. This JSON file contains all printer, + filament, and print settings. The addon patches ``filament_colour`` + automatically based on your painted objects. + + +Round-Trip Conversion +--------------------- + +.. code-block:: python + + from io_mesh_3mf.api import import_3mf, export_3mf + + # Import from one format, export to another + result = import_3mf("/input/prusa_model.3mf", import_materials="PAINT") + if result.status == "FINISHED": + export_3mf( + "/output/orca_model.3mf", + objects=result.objects, + use_orca_format="PAINT", + mmu_slicer_format="ORCA", + ) + + +Inspect Without Importing +-------------------------- + +.. code-block:: python + + from io_mesh_3mf.api import inspect_3mf + + info = inspect_3mf("/models/assembly.3mf") + + if info.status == "OK": + print(f"Unit: {info.unit}") + print(f"Objects: {info.num_objects}") + print(f"Total triangles: {info.num_triangles_total}") + print(f"Vendor: {info.vendor_format or 'standard'}") + print(f"Extensions: {info.extensions_used}") + + for obj in info.objects: + flags = [] + if obj["has_materials"]: + flags.append("materials") + if obj["has_segmentation"]: + flags.append("MMU paint") + print(f" {obj['name']}: {obj['num_triangles']} tris [{', '.join(flags)}]") + else: + print(f"Error: {info.error_message}") + + +Batch Operations +---------------- + +.. code-block:: python + + from io_mesh_3mf.api import batch_import, batch_export + import bpy + + # Import multiple files with per-file error isolation + results = batch_import( + ["part_a.3mf", "part_b.3mf", "part_c.3mf"], + import_materials="PAINT", + target_collection="Batch Import", + ) + + total = sum(r.num_loaded for r in results) + failed = [r for r in results if r.status != "FINISHED"] + print(f"Imported {total} objects, {len(failed)} failures") + + # Export multiple files + cubes = [o for o in bpy.data.objects if "Cube" in o.name] + spheres = [o for o in bpy.data.objects if "Sphere" in o.name] + + results = batch_export( + [ + ("cubes.3mf", cubes), + ("spheres.3mf", spheres), + ("everything.3mf", None), # None = all scene objects + ], + use_orca_format="AUTO", + ) diff --git a/docs/site/_static/base-stemmer.js b/docs/site/_static/base-stemmer.js new file mode 100644 index 0000000..e6fa0c4 --- /dev/null +++ b/docs/site/_static/base-stemmer.js @@ -0,0 +1,476 @@ +// @ts-check + +/**@constructor*/ +BaseStemmer = function() { + /** @protected */ + this.current = ''; + this.cursor = 0; + this.limit = 0; + this.limit_backward = 0; + this.bra = 0; + this.ket = 0; + + /** + * @param {string} value + */ + this.setCurrent = function(value) { + this.current = value; + this.cursor = 0; + this.limit = this.current.length; + this.limit_backward = 0; + this.bra = this.cursor; + this.ket = this.limit; + }; + + /** + * @return {string} + */ + this.getCurrent = function() { + return this.current; + }; + + /** + * @param {BaseStemmer} other + */ + this.copy_from = function(other) { + /** @protected */ + this.current = other.current; + this.cursor = other.cursor; + this.limit = other.limit; + this.limit_backward = other.limit_backward; + this.bra = other.bra; + this.ket = other.ket; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor++; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) + return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) + return true; + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.in_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return false; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return false; + this.cursor--; + return true; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_in_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) return true; + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) return true; + this.cursor--; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping = function(s, min, max) { + /** @protected */ + if (this.cursor >= this.limit) return false; + var ch = this.current.charCodeAt(this.cursor); + if (ch > max || ch < min) { + this.cursor++; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) == 0) { + this.cursor++; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping = function(s, min, max) { + /** @protected */ + while (this.cursor < this.limit) { + var ch = this.current.charCodeAt(this.cursor); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0X1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor++; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.out_grouping_b = function(s, min, max) { + /** @protected */ + if (this.cursor <= this.limit_backward) return false; + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch > max || ch < min) { + this.cursor--; + return true; + } + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) == 0) { + this.cursor--; + return true; + } + return false; + }; + + /** + * @param {number[]} s + * @param {number} min + * @param {number} max + * @return {boolean} + */ + this.go_out_grouping_b = function(s, min, max) { + /** @protected */ + while (this.cursor > this.limit_backward) { + var ch = this.current.charCodeAt(this.cursor - 1); + if (ch <= max && ch >= min) { + ch -= min; + if ((s[ch >>> 3] & (0x1 << (ch & 0x7))) != 0) { + return true; + } + } + this.cursor--; + } + return false; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s = function(s) + { + /** @protected */ + if (this.limit - this.cursor < s.length) return false; + if (this.current.slice(this.cursor, this.cursor + s.length) != s) + { + return false; + } + this.cursor += s.length; + return true; + }; + + /** + * @param {string} s + * @return {boolean} + */ + this.eq_s_b = function(s) + { + /** @protected */ + if (this.cursor - this.limit_backward < s.length) return false; + if (this.current.slice(this.cursor - s.length, this.cursor) != s) + { + return false; + } + this.cursor -= s.length; + return true; + }; + + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among = function(v) + { + /** @protected */ + var i = 0; + var j = v.length; + + var c = this.cursor; + var l = this.limit; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >>> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; // smaller + // w[0]: string, w[1]: substring_i, w[2]: result, w[3]: function (optional) + var w = v[k]; + var i2; + for (i2 = common; i2 < w[0].length; i2++) + { + if (c + common == l) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c + common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; // v->s has been inspected + if (j == i) break; // only one item in v + + // - but now we need to go round once more to get + // v->s inspected. This looks messy, but is actually + // the optimal approach. + + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c + w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c + w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + // find_among_b is for backwards processing. Same comments apply + /** + * @param {Among[]} v + * @return {number} + */ + this.find_among_b = function(v) + { + /** @protected */ + var i = 0; + var j = v.length + + var c = this.cursor; + var lb = this.limit_backward; + + var common_i = 0; + var common_j = 0; + + var first_key_inspected = false; + + while (true) + { + var k = i + ((j - i) >> 1); + var diff = 0; + var common = common_i < common_j ? common_i : common_j; + var w = v[k]; + var i2; + for (i2 = w[0].length - 1 - common; i2 >= 0; i2--) + { + if (c - common == lb) + { + diff = -1; + break; + } + diff = this.current.charCodeAt(c - 1 - common) - w[0].charCodeAt(i2); + if (diff != 0) break; + common++; + } + if (diff < 0) + { + j = k; + common_j = common; + } + else + { + i = k; + common_i = common; + } + if (j - i <= 1) + { + if (i > 0) break; + if (j == i) break; + if (first_key_inspected) break; + first_key_inspected = true; + } + } + do { + var w = v[i]; + if (common_i >= w[0].length) + { + this.cursor = c - w[0].length; + if (w.length < 4) return w[2]; + var res = w[3](this); + this.cursor = c - w[0].length; + if (res) return w[2]; + } + i = w[1]; + } while (i >= 0); + return 0; + }; + + /* to replace chars between c_bra and c_ket in this.current by the + * chars in s. + */ + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + * @return {number} + */ + this.replace_s = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = s.length - (c_ket - c_bra); + this.current = this.current.slice(0, c_bra) + s + this.current.slice(c_ket); + this.limit += adjustment; + if (this.cursor >= c_ket) this.cursor += adjustment; + else if (this.cursor > c_bra) this.cursor = c_bra; + return adjustment; + }; + + /** + * @return {boolean} + */ + this.slice_check = function() + { + /** @protected */ + if (this.bra < 0 || + this.bra > this.ket || + this.ket > this.limit || + this.limit > this.current.length) + { + return false; + } + return true; + }; + + /** + * @param {number} c_bra + * @return {boolean} + */ + this.slice_from = function(s) + { + /** @protected */ + var result = false; + if (this.slice_check()) + { + this.replace_s(this.bra, this.ket, s); + result = true; + } + return result; + }; + + /** + * @return {boolean} + */ + this.slice_del = function() + { + /** @protected */ + return this.slice_from(""); + }; + + /** + * @param {number} c_bra + * @param {number} c_ket + * @param {string} s + */ + this.insert = function(c_bra, c_ket, s) + { + /** @protected */ + var adjustment = this.replace_s(c_bra, c_ket, s); + if (c_bra <= this.bra) this.bra += adjustment; + if (c_bra <= this.ket) this.ket += adjustment; + }; + + /** + * @return {string} + */ + this.slice_to = function() + { + /** @protected */ + var result = ''; + if (this.slice_check()) + { + result = this.current.slice(this.bra, this.ket); + } + return result; + }; + + /** + * @return {string} + */ + this.assign_to = function() + { + /** @protected */ + return this.current.slice(0, this.limit); + }; +}; diff --git a/docs/site/_static/basic.css b/docs/site/_static/basic.css new file mode 100644 index 0000000..4738b2e --- /dev/null +++ b/docs/site/_static/basic.css @@ -0,0 +1,906 @@ +/* + * Sphinx stylesheet -- basic theme. + */ + +/* -- main layout ----------------------------------------------------------- */ + +div.clearer { + clear: both; +} + +div.section::after { + display: block; + content: ''; + clear: left; +} + +/* -- relbar ---------------------------------------------------------------- */ + +div.related { + width: 100%; + font-size: 90%; +} + +div.related h3 { + display: none; +} + +div.related ul { + margin: 0; + padding: 0 0 0 10px; + list-style: none; +} + +div.related li { + display: inline; +} + +div.related li.right { + float: right; + margin-right: 5px; +} + +/* -- sidebar --------------------------------------------------------------- */ + +div.sphinxsidebarwrapper { + padding: 10px 5px 0 10px; +} + +div.sphinxsidebar { + float: left; + width: 230px; + margin-left: -100%; + font-size: 90%; + word-wrap: break-word; + overflow-wrap : break-word; +} + +div.sphinxsidebar ul { + list-style: none; +} + +div.sphinxsidebar ul ul, +div.sphinxsidebar ul.want-points { + margin-left: 20px; + list-style: square; +} + +div.sphinxsidebar ul ul { + margin-top: 0; + margin-bottom: 0; +} + +div.sphinxsidebar form { + margin-top: 10px; +} + +div.sphinxsidebar input { + border: 1px solid #98dbcc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar #searchbox form.search { + overflow: hidden; +} + +div.sphinxsidebar #searchbox input[type="text"] { + float: left; + width: 80%; + padding: 0.25em; + box-sizing: border-box; +} + +div.sphinxsidebar #searchbox input[type="submit"] { + float: left; + width: 20%; + border-left: none; + padding: 0.25em; + box-sizing: border-box; +} + + +img { + border: 0; + max-width: 100%; +} + +/* -- search page ----------------------------------------------------------- */ + +ul.search { + margin-top: 10px; +} + +ul.search li { + padding: 5px 0; +} + +ul.search li a { + font-weight: bold; +} + +ul.search li p.context { + color: #888; + margin: 2px 0 0 30px; + text-align: left; +} + +ul.keywordmatches li.goodmatch a { + font-weight: bold; +} + +/* -- index page ------------------------------------------------------------ */ + +table.contentstable { + width: 90%; + margin-left: auto; + margin-right: auto; +} + +table.contentstable p.biglink { + line-height: 150%; +} + +a.biglink { + font-size: 1.3em; +} + +span.linkdescr { + font-style: italic; + padding-top: 5px; + font-size: 90%; +} + +/* -- general index --------------------------------------------------------- */ + +table.indextable { + width: 100%; +} + +table.indextable td { + text-align: left; + vertical-align: top; +} + +table.indextable ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} + +table.indextable > tbody > tr > td > ul { + padding-left: 0em; +} + +table.indextable tr.pcap { + height: 10px; +} + +table.indextable tr.cap { + margin-top: 10px; + background-color: #f2f2f2; +} + +img.toggler { + margin-right: 3px; + margin-top: 3px; + cursor: pointer; +} + +div.modindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +div.genindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +/* -- domain module index --------------------------------------------------- */ + +table.modindextable td { + padding: 2px; + border-collapse: collapse; +} + +/* -- general body styles --------------------------------------------------- */ + +div.body { + min-width: 360px; + max-width: 800px; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: auto; + -ms-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +a.headerlink { + visibility: hidden; +} + +a:visited { + color: #551A8B; +} + +h1:hover > a.headerlink, +h2:hover > a.headerlink, +h3:hover > a.headerlink, +h4:hover > a.headerlink, +h5:hover > a.headerlink, +h6:hover > a.headerlink, +dt:hover > a.headerlink, +caption:hover > a.headerlink, +p.caption:hover > a.headerlink, +div.code-block-caption:hover > a.headerlink { + visibility: visible; +} + +div.body p.caption { + text-align: inherit; +} + +div.body td { + text-align: left; +} + +.first { + margin-top: 0 !important; +} + +p.rubric { + margin-top: 30px; + font-weight: bold; +} + +img.align-left, figure.align-left, .figure.align-left, object.align-left { + clear: left; + float: left; + margin-right: 1em; +} + +img.align-right, figure.align-right, .figure.align-right, object.align-right { + clear: right; + float: right; + margin-left: 1em; +} + +img.align-center, figure.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +img.align-default, figure.align-default, .figure.align-default { + display: block; + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-default { + text-align: center; +} + +.align-right { + text-align: right; +} + +/* -- sidebars -------------------------------------------------------------- */ + +div.sidebar, +aside.sidebar { + margin: 0 0 0.5em 1em; + border: 1px solid #ddb; + padding: 7px; + background-color: #ffe; + width: 40%; + float: right; + clear: right; + overflow-x: auto; +} + +p.sidebar-title { + font-weight: bold; +} + +nav.contents, +aside.topic, +div.admonition, div.topic, blockquote { + clear: left; +} + +/* -- topics ---------------------------------------------------------------- */ + +nav.contents, +aside.topic, +div.topic { + border: 1px solid #ccc; + padding: 7px; + margin: 10px 0 10px 0; +} + +p.topic-title { + font-size: 1.1em; + font-weight: bold; + margin-top: 10px; +} + +/* -- admonitions ----------------------------------------------------------- */ + +div.admonition { + margin-top: 10px; + margin-bottom: 10px; + padding: 7px; +} + +div.admonition dt { + font-weight: bold; +} + +p.admonition-title { + margin: 0px 10px 5px 0px; + font-weight: bold; +} + +div.body p.centered { + text-align: center; + margin-top: 25px; +} + +/* -- content of sidebars/topics/admonitions -------------------------------- */ + +div.sidebar > :last-child, +aside.sidebar > :last-child, +nav.contents > :last-child, +aside.topic > :last-child, +div.topic > :last-child, +div.admonition > :last-child { + margin-bottom: 0; +} + +div.sidebar::after, +aside.sidebar::after, +nav.contents::after, +aside.topic::after, +div.topic::after, +div.admonition::after, +blockquote::after { + display: block; + content: ''; + clear: both; +} + +/* -- tables ---------------------------------------------------------------- */ + +table.docutils { + margin-top: 10px; + margin-bottom: 10px; + border: 0; + border-collapse: collapse; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +table.align-default { + margin-left: auto; + margin-right: auto; +} + +table caption span.caption-number { + font-style: italic; +} + +table caption span.caption-text { +} + +table.docutils td, table.docutils th { + padding: 1px 8px 1px 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #aaa; +} + +th { + text-align: left; + padding-right: 5px; +} + +table.citation { + border-left: solid 1px gray; + margin-left: 1px; +} + +table.citation td { + border-bottom: none; +} + +th > :first-child, +td > :first-child { + margin-top: 0px; +} + +th > :last-child, +td > :last-child { + margin-bottom: 0px; +} + +/* -- figures --------------------------------------------------------------- */ + +div.figure, figure { + margin: 0.5em; + padding: 0.5em; +} + +div.figure p.caption, figcaption { + padding: 0.3em; +} + +div.figure p.caption span.caption-number, +figcaption span.caption-number { + font-style: italic; +} + +div.figure p.caption span.caption-text, +figcaption span.caption-text { +} + +/* -- field list styles ----------------------------------------------------- */ + +table.field-list td, table.field-list th { + border: 0 !important; +} + +.field-list ul { + margin: 0; + padding-left: 1em; +} + +.field-list p { + margin: 0; +} + +.field-name { + -moz-hyphens: manual; + -ms-hyphens: manual; + -webkit-hyphens: manual; + hyphens: manual; +} + +/* -- hlist styles ---------------------------------------------------------- */ + +table.hlist { + margin: 1em 0; +} + +table.hlist td { + vertical-align: top; +} + +/* -- object description styles --------------------------------------------- */ + +.sig { + font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; +} + +.sig-name, code.descname { + background-color: transparent; + font-weight: bold; +} + +.sig-name { + font-size: 1.1em; +} + +code.descname { + font-size: 1.2em; +} + +.sig-prename, code.descclassname { + background-color: transparent; +} + +.optional { + font-size: 1.3em; +} + +.sig-paren { + font-size: larger; +} + +.sig-param.n { + font-style: italic; +} + +/* C++ specific styling */ + +.sig-inline.c-texpr, +.sig-inline.cpp-texpr { + font-family: unset; +} + +.sig.c .k, .sig.c .kt, +.sig.cpp .k, .sig.cpp .kt { + color: #0033B3; +} + +.sig.c .m, +.sig.cpp .m { + color: #1750EB; +} + +.sig.c .s, .sig.c .sc, +.sig.cpp .s, .sig.cpp .sc { + color: #067D17; +} + + +/* -- other body styles ----------------------------------------------------- */ + +ol.arabic { + list-style: decimal; +} + +ol.loweralpha { + list-style: lower-alpha; +} + +ol.upperalpha { + list-style: upper-alpha; +} + +ol.lowerroman { + list-style: lower-roman; +} + +ol.upperroman { + list-style: upper-roman; +} + +:not(li) > ol > li:first-child > :first-child, +:not(li) > ul > li:first-child > :first-child { + margin-top: 0px; +} + +:not(li) > ol > li:last-child > :last-child, +:not(li) > ul > li:last-child > :last-child { + margin-bottom: 0px; +} + +ol.simple ol p, +ol.simple ul p, +ul.simple ol p, +ul.simple ul p { + margin-top: 0; +} + +ol.simple > li:not(:first-child) > p, +ul.simple > li:not(:first-child) > p { + margin-top: 0; +} + +ol.simple p, +ul.simple p { + margin-bottom: 0; +} + +aside.footnote > span, +div.citation > span { + float: left; +} +aside.footnote > span:last-of-type, +div.citation > span:last-of-type { + padding-right: 0.5em; +} +aside.footnote > p { + margin-left: 2em; +} +div.citation > p { + margin-left: 4em; +} +aside.footnote > p:last-of-type, +div.citation > p:last-of-type { + margin-bottom: 0em; +} +aside.footnote > p:last-of-type:after, +div.citation > p:last-of-type:after { + content: ""; + clear: both; +} + +dl.field-list { + display: grid; + grid-template-columns: fit-content(30%) auto; +} + +dl.field-list > dt { + font-weight: bold; + word-break: break-word; + padding-left: 0.5em; + padding-right: 5px; +} + +dl.field-list > dd { + padding-left: 0.5em; + margin-top: 0em; + margin-left: 0em; + margin-bottom: 0em; +} + +dl { + margin-bottom: 15px; +} + +dd > :first-child { + margin-top: 0px; +} + +dd ul, dd table { + margin-bottom: 10px; +} + +dd { + margin-top: 3px; + margin-bottom: 10px; + margin-left: 30px; +} + +.sig dd { + margin-top: 0px; + margin-bottom: 0px; +} + +.sig dl { + margin-top: 0px; + margin-bottom: 0px; +} + +dl > dd:last-child, +dl > dd:last-child > :last-child { + margin-bottom: 0; +} + +dt:target, span.highlighted { + background-color: #fbe54e; +} + +rect.highlighted { + fill: #fbe54e; +} + +dl.glossary dt { + font-weight: bold; + font-size: 1.1em; +} + +.versionmodified { + font-style: italic; +} + +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} + +.footnote:target { + background-color: #ffa; +} + +.line-block { + display: block; + margin-top: 1em; + margin-bottom: 1em; +} + +.line-block .line-block { + margin-top: 0; + margin-bottom: 0; + margin-left: 1.5em; +} + +.guilabel, .menuselection { + font-family: sans-serif; +} + +.accelerator { + text-decoration: underline; +} + +.classifier { + font-style: oblique; +} + +.classifier:before { + font-style: normal; + margin: 0 0.5em; + content: ":"; + display: inline-block; +} + +abbr, acronym { + border-bottom: dotted 1px; + cursor: help; +} + +/* -- code displays --------------------------------------------------------- */ + +pre { + overflow: auto; + overflow-y: hidden; /* fixes display issues on Chrome browsers */ +} + +pre, div[class*="highlight-"] { + clear: both; +} + +span.pre { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; + white-space: nowrap; +} + +div[class*="highlight-"] { + margin: 1em 0; +} + +td.linenos pre { + border: 0; + background-color: transparent; + color: #aaa; +} + +table.highlighttable { + display: block; +} + +table.highlighttable tbody { + display: block; +} + +table.highlighttable tr { + display: flex; +} + +table.highlighttable td { + margin: 0; + padding: 0; +} + +table.highlighttable td.linenos { + padding-right: 0.5em; +} + +table.highlighttable td.code { + flex: 1; + overflow: hidden; +} + +.highlight .hll { + display: block; +} + +div.highlight pre, +table.highlighttable pre { + margin: 0; +} + +div.code-block-caption + div { + margin-top: 0; +} + +div.code-block-caption { + margin-top: 1em; + padding: 2px 5px; + font-size: small; +} + +div.code-block-caption code { + background-color: transparent; +} + +table.highlighttable td.linenos, +span.linenos, +div.highlight span.gp { /* gp: Generic.Prompt */ + user-select: none; + -webkit-user-select: text; /* Safari fallback only */ + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ +} + +div.code-block-caption span.caption-number { + padding: 0.1em 0.3em; + font-style: italic; +} + +div.code-block-caption span.caption-text { +} + +div.literal-block-wrapper { + margin: 1em 0; +} + +code.xref, a code { + background-color: transparent; + font-weight: bold; +} + +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + background-color: transparent; +} + +.viewcode-link { + float: right; +} + +.viewcode-back { + float: right; + font-family: sans-serif; +} + +div.viewcode-block:target { + margin: -1px -10px; + padding: 0 10px; +} + +/* -- math display ---------------------------------------------------------- */ + +img.math { + vertical-align: middle; +} + +div.body div.math p { + text-align: center; +} + +span.eqno { + float: right; +} + +span.eqno a.headerlink { + position: absolute; + z-index: 1; +} + +div.math:hover a.headerlink { + visibility: visible; +} + +/* -- printout stylesheet --------------------------------------------------- */ + +@media print { + div.document, + div.documentwrapper, + div.bodywrapper { + margin: 0 !important; + width: 100%; + } + + div.sphinxsidebar, + div.related, + div.footer, + #top-link { + display: none; + } +} \ No newline at end of file diff --git a/docs/site/_static/debug.css b/docs/site/_static/debug.css new file mode 100644 index 0000000..74d4aec --- /dev/null +++ b/docs/site/_static/debug.css @@ -0,0 +1,69 @@ +/* + This CSS file should be overridden by the theme authors. It's + meant for debugging and developing the skeleton that this theme provides. +*/ +body { + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + background: lavender; +} +.sb-announcement { + background: rgb(131, 131, 131); +} +.sb-announcement__inner { + background: black; + color: white; +} +.sb-header { + background: lightskyblue; +} +.sb-header__inner { + background: royalblue; + color: white; +} +.sb-header-secondary { + background: lightcyan; +} +.sb-header-secondary__inner { + background: cornflowerblue; + color: white; +} +.sb-sidebar-primary { + background: lightgreen; +} +.sb-main { + background: blanchedalmond; +} +.sb-main__inner { + background: antiquewhite; +} +.sb-header-article { + background: lightsteelblue; +} +.sb-article-container { + background: snow; +} +.sb-article-main { + background: white; +} +.sb-footer-article { + background: lightpink; +} +.sb-sidebar-secondary { + background: lightgoldenrodyellow; +} +.sb-footer-content { + background: plum; +} +.sb-footer-content__inner { + background: palevioletred; +} +.sb-footer { + background: pink; +} +.sb-footer__inner { + background: salmon; +} +.sb-article { + background: white; +} diff --git a/docs/site/_static/doctools.js b/docs/site/_static/doctools.js new file mode 100644 index 0000000..807cdb1 --- /dev/null +++ b/docs/site/_static/doctools.js @@ -0,0 +1,150 @@ +/* + * Base JavaScript utilities for all Sphinx HTML documentation. + */ +"use strict"; + +const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ + "TEXTAREA", + "INPUT", + "SELECT", + "BUTTON", +]); + +const _ready = (callback) => { + if (document.readyState !== "loading") { + callback(); + } else { + document.addEventListener("DOMContentLoaded", callback); + } +}; + +/** + * Small JavaScript module for the documentation. + */ +const Documentation = { + init: () => { + Documentation.initDomainIndexTable(); + Documentation.initOnKeyListeners(); + }, + + /** + * i18n support + */ + TRANSLATIONS: {}, + PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), + LOCALE: "unknown", + + // gettext and ngettext don't access this so that the functions + // can safely bound to a different name (_ = Documentation.gettext) + gettext: (string) => { + const translated = Documentation.TRANSLATIONS[string]; + switch (typeof translated) { + case "undefined": + return string; // no translation + case "string": + return translated; // translation exists + default: + return translated[0]; // (singular, plural) translation tuple exists + } + }, + + ngettext: (singular, plural, n) => { + const translated = Documentation.TRANSLATIONS[singular]; + if (typeof translated !== "undefined") + return translated[Documentation.PLURAL_EXPR(n)]; + return n === 1 ? singular : plural; + }, + + addTranslations: (catalog) => { + Object.assign(Documentation.TRANSLATIONS, catalog.messages); + Documentation.PLURAL_EXPR = new Function( + "n", + `return (${catalog.plural_expr})`, + ); + Documentation.LOCALE = catalog.locale; + }, + + /** + * helper function to focus on search bar + */ + focusSearchBar: () => { + document.querySelectorAll("input[name=q]")[0]?.focus(); + }, + + /** + * Initialise the domain index toggle buttons + */ + initDomainIndexTable: () => { + const toggler = (el) => { + const idNumber = el.id.substr(7); + const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); + if (el.src.substr(-9) === "minus.png") { + el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; + toggledRows.forEach((el) => (el.style.display = "none")); + } else { + el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; + toggledRows.forEach((el) => (el.style.display = "")); + } + }; + + const togglerElements = document.querySelectorAll("img.toggler"); + togglerElements.forEach((el) => + el.addEventListener("click", (event) => toggler(event.currentTarget)), + ); + togglerElements.forEach((el) => (el.style.display = "")); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); + }, + + initOnKeyListeners: () => { + // only install a listener if it is really needed + if ( + !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS + && !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + ) + return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (!event.shiftKey) { + switch (event.key) { + case "ArrowLeft": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const prevLink = document.querySelector('link[rel="prev"]'); + if (prevLink && prevLink.href) { + window.location.href = prevLink.href; + event.preventDefault(); + } + break; + case "ArrowRight": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const nextLink = document.querySelector('link[rel="next"]'); + if (nextLink && nextLink.href) { + window.location.href = nextLink.href; + event.preventDefault(); + } + break; + } + } + + // some keyboard layouts may need Shift to get / + switch (event.key) { + case "/": + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; + Documentation.focusSearchBar(); + event.preventDefault(); + } + }); + }, +}; + +// quick alias for translations +const _ = Documentation.gettext; + +_ready(Documentation.init); diff --git a/docs/site/_static/documentation_options.js b/docs/site/_static/documentation_options.js new file mode 100644 index 0000000..1675f3b --- /dev/null +++ b/docs/site/_static/documentation_options.js @@ -0,0 +1,13 @@ +const DOCUMENTATION_OPTIONS = { + VERSION: '1.0.0', + LANGUAGE: 'en', + COLLAPSE_INDEX: false, + BUILDER: 'html', + FILE_SUFFIX: '.html', + LINK_SUFFIX: '.html', + HAS_SOURCE: true, + SOURCELINK_SUFFIX: '.txt', + NAVIGATION_WITH_KEYS: true, + SHOW_SEARCH_SUMMARY: true, + ENABLE_SEARCH_SHORTCUTS: true, +}; \ No newline at end of file diff --git a/docs/site/_static/english-stemmer.js b/docs/site/_static/english-stemmer.js new file mode 100644 index 0000000..056760e --- /dev/null +++ b/docs/site/_static/english-stemmer.js @@ -0,0 +1,1066 @@ +// Generated from english.sbl by Snowball 3.0.1 - https://snowballstem.org/ + +/**@constructor*/ +var EnglishStemmer = function() { + var base = new BaseStemmer(); + + /** @const */ var a_0 = [ + ["arsen", -1, -1], + ["commun", -1, -1], + ["emerg", -1, -1], + ["gener", -1, -1], + ["later", -1, -1], + ["organ", -1, -1], + ["past", -1, -1], + ["univers", -1, -1] + ]; + + /** @const */ var a_1 = [ + ["'", -1, 1], + ["'s'", 0, 1], + ["'s", -1, 1] + ]; + + /** @const */ var a_2 = [ + ["ied", -1, 2], + ["s", -1, 3], + ["ies", 1, 2], + ["sses", 1, 1], + ["ss", 1, -1], + ["us", 1, -1] + ]; + + /** @const */ var a_3 = [ + ["succ", -1, 1], + ["proc", -1, 1], + ["exc", -1, 1] + ]; + + /** @const */ var a_4 = [ + ["even", -1, 2], + ["cann", -1, 2], + ["inn", -1, 2], + ["earr", -1, 2], + ["herr", -1, 2], + ["out", -1, 2], + ["y", -1, 1] + ]; + + /** @const */ var a_5 = [ + ["", -1, -1], + ["ed", 0, 2], + ["eed", 1, 1], + ["ing", 0, 3], + ["edly", 0, 2], + ["eedly", 4, 1], + ["ingly", 0, 2] + ]; + + /** @const */ var a_6 = [ + ["", -1, 3], + ["bb", 0, 2], + ["dd", 0, 2], + ["ff", 0, 2], + ["gg", 0, 2], + ["bl", 0, 1], + ["mm", 0, 2], + ["nn", 0, 2], + ["pp", 0, 2], + ["rr", 0, 2], + ["at", 0, 1], + ["tt", 0, 2], + ["iz", 0, 1] + ]; + + /** @const */ var a_7 = [ + ["anci", -1, 3], + ["enci", -1, 2], + ["ogi", -1, 14], + ["li", -1, 16], + ["bli", 3, 12], + ["abli", 4, 4], + ["alli", 3, 8], + ["fulli", 3, 9], + ["lessli", 3, 15], + ["ousli", 3, 10], + ["entli", 3, 5], + ["aliti", -1, 8], + ["biliti", -1, 12], + ["iviti", -1, 11], + ["tional", -1, 1], + ["ational", 14, 7], + ["alism", -1, 8], + ["ation", -1, 7], + ["ization", 17, 6], + ["izer", -1, 6], + ["ator", -1, 7], + ["iveness", -1, 11], + ["fulness", -1, 9], + ["ousness", -1, 10], + ["ogist", -1, 13] + ]; + + /** @const */ var a_8 = [ + ["icate", -1, 4], + ["ative", -1, 6], + ["alize", -1, 3], + ["iciti", -1, 4], + ["ical", -1, 4], + ["tional", -1, 1], + ["ational", 5, 2], + ["ful", -1, 5], + ["ness", -1, 5] + ]; + + /** @const */ var a_9 = [ + ["ic", -1, 1], + ["ance", -1, 1], + ["ence", -1, 1], + ["able", -1, 1], + ["ible", -1, 1], + ["ate", -1, 1], + ["ive", -1, 1], + ["ize", -1, 1], + ["iti", -1, 1], + ["al", -1, 1], + ["ism", -1, 1], + ["ion", -1, 2], + ["er", -1, 1], + ["ous", -1, 1], + ["ant", -1, 1], + ["ent", -1, 1], + ["ment", 15, 1], + ["ement", 16, 1] + ]; + + /** @const */ var a_10 = [ + ["e", -1, 1], + ["l", -1, 2] + ]; + + /** @const */ var a_11 = [ + ["andes", -1, -1], + ["atlas", -1, -1], + ["bias", -1, -1], + ["cosmos", -1, -1], + ["early", -1, 5], + ["gently", -1, 3], + ["howe", -1, -1], + ["idly", -1, 2], + ["news", -1, -1], + ["only", -1, 6], + ["singly", -1, 7], + ["skies", -1, 1], + ["sky", -1, -1], + ["ugly", -1, 4] + ]; + + /** @const */ var /** Array */ g_aeo = [17, 64]; + + /** @const */ var /** Array */ g_v = [17, 65, 16, 1]; + + /** @const */ var /** Array */ g_v_WXY = [1, 17, 65, 208, 1]; + + /** @const */ var /** Array */ g_valid_LI = [55, 141, 2]; + + var /** boolean */ B_Y_found = false; + var /** number */ I_p2 = 0; + var /** number */ I_p1 = 0; + + + /** @return {boolean} */ + function r_prelude() { + B_Y_found = false; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + base.bra = base.cursor; + if (!(base.eq_s("'"))) + { + break lab0; + } + base.ket = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.cursor = v_1; + /** @const */ var /** number */ v_2 = base.cursor; + lab1: { + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab1; + } + base.ket = base.cursor; + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + } + base.cursor = v_2; + /** @const */ var /** number */ v_3 = base.cursor; + lab2: { + while(true) + { + /** @const */ var /** number */ v_4 = base.cursor; + lab3: { + golab4: while(true) + { + /** @const */ var /** number */ v_5 = base.cursor; + lab5: { + if (!(base.in_grouping(g_v, 97, 121))) + { + break lab5; + } + base.bra = base.cursor; + if (!(base.eq_s("y"))) + { + break lab5; + } + base.ket = base.cursor; + base.cursor = v_5; + break golab4; + } + base.cursor = v_5; + if (base.cursor >= base.limit) + { + break lab3; + } + base.cursor++; + } + if (!base.slice_from("Y")) + { + return false; + } + B_Y_found = true; + continue; + } + base.cursor = v_4; + break; + } + } + base.cursor = v_3; + return true; + }; + + /** @return {boolean} */ + function r_mark_regions() { + I_p1 = base.limit; + I_p2 = base.limit; + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + lab1: { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + if (base.find_among(a_0) == 0) + { + break lab2; + } + break lab1; + } + base.cursor = v_2; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + } + I_p1 = base.cursor; + if (!base.go_out_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + if (!base.go_in_grouping(g_v, 97, 121)) + { + break lab0; + } + base.cursor++; + I_p2 = base.cursor; + } + base.cursor = v_1; + return true; + }; + + /** @return {boolean} */ + function r_shortv() { + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.out_grouping_b(g_v_WXY, 89, 121))) + { + break lab1; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + lab2: { + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (!(base.in_grouping_b(g_v, 97, 121))) + { + break lab2; + } + if (base.cursor > base.limit_backward) + { + break lab2; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("past"))) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_R1() { + return I_p1 <= base.cursor; + }; + + /** @return {boolean} */ + function r_R2() { + return I_p2 <= base.cursor; + }; + + /** @return {boolean} */ + function r_Step_1a() { + var /** number */ among_var; + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab0: { + base.ket = base.cursor; + if (base.find_among_b(a_1) == 0) + { + base.cursor = base.limit - v_1; + break lab0; + } + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + base.ket = base.cursor; + among_var = base.find_among_b(a_2); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + if (!base.slice_from("ss")) + { + return false; + } + break; + case 2: + lab1: { + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + { + /** @const */ var /** number */ c1 = base.cursor - 2; + if (c1 < base.limit_backward) + { + break lab2; + } + base.cursor = c1; + } + if (!base.slice_from("i")) + { + return false; + } + break lab1; + } + base.cursor = base.limit - v_2; + if (!base.slice_from("ie")) + { + return false; + } + } + break; + case 3: + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1b() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_5); + base.bra = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + switch (among_var) { + case 1: + /** @const */ var /** number */ v_2 = base.limit - base.cursor; + lab2: { + lab3: { + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + lab4: { + if (base.find_among_b(a_3) == 0) + { + break lab4; + } + if (base.cursor > base.limit_backward) + { + break lab4; + } + break lab3; + } + base.cursor = base.limit - v_3; + if (!r_R1()) + { + break lab2; + } + if (!base.slice_from("ee")) + { + return false; + } + } + } + base.cursor = base.limit - v_2; + break; + case 2: + break lab1; + case 3: + among_var = base.find_among_b(a_4); + if (among_var == 0) + { + break lab1; + } + switch (among_var) { + case 1: + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + break lab1; + } + if (base.cursor > base.limit_backward) + { + break lab1; + } + base.cursor = base.limit - v_4; + base.bra = base.cursor; + if (!base.slice_from("ie")) + { + return false; + } + break; + case 2: + if (base.cursor > base.limit_backward) + { + break lab1; + } + break; + } + break; + } + break lab0; + } + base.cursor = base.limit - v_1; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + if (!base.go_out_grouping_b(g_v, 97, 121)) + { + return false; + } + base.cursor--; + base.cursor = base.limit - v_5; + if (!base.slice_del()) + { + return false; + } + base.ket = base.cursor; + base.bra = base.cursor; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + among_var = base.find_among_b(a_6); + switch (among_var) { + case 1: + if (!base.slice_from("e")) + { + return false; + } + return false; + case 2: + { + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + lab5: { + if (!(base.in_grouping_b(g_aeo, 97, 111))) + { + break lab5; + } + if (base.cursor > base.limit_backward) + { + break lab5; + } + return false; + } + base.cursor = base.limit - v_7; + } + break; + case 3: + if (base.cursor != I_p1) + { + return false; + } + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + if (!r_shortv()) + { + return false; + } + base.cursor = base.limit - v_8; + if (!base.slice_from("e")) + { + return false; + } + return false; + } + base.cursor = base.limit - v_6; + base.ket = base.cursor; + if (base.cursor <= base.limit_backward) + { + return false; + } + base.cursor--; + base.bra = base.cursor; + if (!base.slice_del()) + { + return false; + } + } + return true; + }; + + /** @return {boolean} */ + function r_Step_1c() { + base.ket = base.cursor; + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("y"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("Y"))) + { + return false; + } + } + base.bra = base.cursor; + if (!(base.out_grouping_b(g_v, 97, 121))) + { + return false; + } + lab2: { + if (base.cursor > base.limit_backward) + { + break lab2; + } + return false; + } + if (!base.slice_from("i")) + { + return false; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_2() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_7); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ence")) + { + return false; + } + break; + case 3: + if (!base.slice_from("ance")) + { + return false; + } + break; + case 4: + if (!base.slice_from("able")) + { + return false; + } + break; + case 5: + if (!base.slice_from("ent")) + { + return false; + } + break; + case 6: + if (!base.slice_from("ize")) + { + return false; + } + break; + case 7: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 8: + if (!base.slice_from("al")) + { + return false; + } + break; + case 9: + if (!base.slice_from("ful")) + { + return false; + } + break; + case 10: + if (!base.slice_from("ous")) + { + return false; + } + break; + case 11: + if (!base.slice_from("ive")) + { + return false; + } + break; + case 12: + if (!base.slice_from("ble")) + { + return false; + } + break; + case 13: + if (!base.slice_from("og")) + { + return false; + } + break; + case 14: + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_from("og")) + { + return false; + } + break; + case 15: + if (!base.slice_from("less")) + { + return false; + } + break; + case 16: + if (!(base.in_grouping_b(g_valid_LI, 99, 116))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_3() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_8); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R1()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("tion")) + { + return false; + } + break; + case 2: + if (!base.slice_from("ate")) + { + return false; + } + break; + case 3: + if (!base.slice_from("al")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ic")) + { + return false; + } + break; + case 5: + if (!base.slice_del()) + { + return false; + } + break; + case 6: + if (!r_R2()) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_4() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_9); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + if (!r_R2()) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_del()) + { + return false; + } + break; + case 2: + lab0: { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab1: { + if (!(base.eq_s_b("s"))) + { + break lab1; + } + break lab0; + } + base.cursor = base.limit - v_1; + if (!(base.eq_s_b("t"))) + { + return false; + } + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_Step_5() { + var /** number */ among_var; + base.ket = base.cursor; + among_var = base.find_among_b(a_10); + if (among_var == 0) + { + return false; + } + base.bra = base.cursor; + switch (among_var) { + case 1: + lab0: { + lab1: { + if (!r_R2()) + { + break lab1; + } + break lab0; + } + if (!r_R1()) + { + return false; + } + { + /** @const */ var /** number */ v_1 = base.limit - base.cursor; + lab2: { + if (!r_shortv()) + { + break lab2; + } + return false; + } + base.cursor = base.limit - v_1; + } + } + if (!base.slice_del()) + { + return false; + } + break; + case 2: + if (!r_R2()) + { + return false; + } + if (!(base.eq_s_b("l"))) + { + return false; + } + if (!base.slice_del()) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_exception1() { + var /** number */ among_var; + base.bra = base.cursor; + among_var = base.find_among(a_11); + if (among_var == 0) + { + return false; + } + base.ket = base.cursor; + if (base.cursor < base.limit) + { + return false; + } + switch (among_var) { + case 1: + if (!base.slice_from("sky")) + { + return false; + } + break; + case 2: + if (!base.slice_from("idl")) + { + return false; + } + break; + case 3: + if (!base.slice_from("gentl")) + { + return false; + } + break; + case 4: + if (!base.slice_from("ugli")) + { + return false; + } + break; + case 5: + if (!base.slice_from("earli")) + { + return false; + } + break; + case 6: + if (!base.slice_from("onli")) + { + return false; + } + break; + case 7: + if (!base.slice_from("singl")) + { + return false; + } + break; + } + return true; + }; + + /** @return {boolean} */ + function r_postlude() { + if (!B_Y_found) + { + return false; + } + while(true) + { + /** @const */ var /** number */ v_1 = base.cursor; + lab0: { + golab1: while(true) + { + /** @const */ var /** number */ v_2 = base.cursor; + lab2: { + base.bra = base.cursor; + if (!(base.eq_s("Y"))) + { + break lab2; + } + base.ket = base.cursor; + base.cursor = v_2; + break golab1; + } + base.cursor = v_2; + if (base.cursor >= base.limit) + { + break lab0; + } + base.cursor++; + } + if (!base.slice_from("y")) + { + return false; + } + continue; + } + base.cursor = v_1; + break; + } + return true; + }; + + this.stem = /** @return {boolean} */ function() { + lab0: { + /** @const */ var /** number */ v_1 = base.cursor; + lab1: { + if (!r_exception1()) + { + break lab1; + } + break lab0; + } + base.cursor = v_1; + lab2: { + { + /** @const */ var /** number */ v_2 = base.cursor; + lab3: { + { + /** @const */ var /** number */ c1 = base.cursor + 3; + if (c1 > base.limit) + { + break lab3; + } + base.cursor = c1; + } + break lab2; + } + base.cursor = v_2; + } + break lab0; + } + base.cursor = v_1; + r_prelude(); + r_mark_regions(); + base.limit_backward = base.cursor; base.cursor = base.limit; + /** @const */ var /** number */ v_3 = base.limit - base.cursor; + r_Step_1a(); + base.cursor = base.limit - v_3; + /** @const */ var /** number */ v_4 = base.limit - base.cursor; + r_Step_1b(); + base.cursor = base.limit - v_4; + /** @const */ var /** number */ v_5 = base.limit - base.cursor; + r_Step_1c(); + base.cursor = base.limit - v_5; + /** @const */ var /** number */ v_6 = base.limit - base.cursor; + r_Step_2(); + base.cursor = base.limit - v_6; + /** @const */ var /** number */ v_7 = base.limit - base.cursor; + r_Step_3(); + base.cursor = base.limit - v_7; + /** @const */ var /** number */ v_8 = base.limit - base.cursor; + r_Step_4(); + base.cursor = base.limit - v_8; + /** @const */ var /** number */ v_9 = base.limit - base.cursor; + r_Step_5(); + base.cursor = base.limit - v_9; + base.cursor = base.limit_backward; + /** @const */ var /** number */ v_10 = base.cursor; + r_postlude(); + base.cursor = v_10; + } + return true; + }; + + /**@return{string}*/ + this['stemWord'] = function(/**string*/word) { + base.setCurrent(word); + this.stem(); + return base.getCurrent(); + }; +}; diff --git a/docs/site/_static/file.png b/docs/site/_static/file.png new file mode 100644 index 0000000..a858a41 Binary files /dev/null and b/docs/site/_static/file.png differ diff --git a/docs/site/_static/language_data.js b/docs/site/_static/language_data.js new file mode 100644 index 0000000..5776786 --- /dev/null +++ b/docs/site/_static/language_data.js @@ -0,0 +1,13 @@ +/* + * This script contains the language-specific data used by searchtools.js, + * namely the set of stopwords, stemmer, scorer and splitter. + */ + +const stopwords = new Set(["a", "about", "above", "after", "again", "against", "all", "am", "an", "and", "any", "are", "aren't", "as", "at", "be", "because", "been", "before", "being", "below", "between", "both", "but", "by", "can't", "cannot", "could", "couldn't", "did", "didn't", "do", "does", "doesn't", "doing", "don't", "down", "during", "each", "few", "for", "from", "further", "had", "hadn't", "has", "hasn't", "have", "haven't", "having", "he", "he'd", "he'll", "he's", "her", "here", "here's", "hers", "herself", "him", "himself", "his", "how", "how's", "i", "i'd", "i'll", "i'm", "i've", "if", "in", "into", "is", "isn't", "it", "it's", "its", "itself", "let's", "me", "more", "most", "mustn't", "my", "myself", "no", "nor", "not", "of", "off", "on", "once", "only", "or", "other", "ought", "our", "ours", "ourselves", "out", "over", "own", "same", "shan't", "she", "she'd", "she'll", "she's", "should", "shouldn't", "so", "some", "such", "than", "that", "that's", "the", "their", "theirs", "them", "themselves", "then", "there", "there's", "these", "they", "they'd", "they'll", "they're", "they've", "this", "those", "through", "to", "too", "under", "until", "up", "very", "was", "wasn't", "we", "we'd", "we'll", "we're", "we've", "were", "weren't", "what", "what's", "when", "when's", "where", "where's", "which", "while", "who", "who's", "whom", "why", "why's", "with", "won't", "would", "wouldn't", "you", "you'd", "you'll", "you're", "you've", "your", "yours", "yourself", "yourselves"]); +window.stopwords = stopwords; // Export to global scope + + +/* Non-minified versions are copied as separate JavaScript files, if available */ +BaseStemmer=function(){this.current="",this.cursor=0,this.limit=0,this.limit_backward=0,this.bra=0,this.ket=0,this.setCurrent=function(t){this.current=t,this.cursor=0,this.limit=this.current.length,this.limit_backward=0,this.bra=this.cursor,this.ket=this.limit},this.getCurrent=function(){return this.current},this.copy_from=function(t){this.current=t.current,this.cursor=t.cursor,this.limit=t.limit,this.limit_backward=t.limit_backward,this.bra=t.bra,this.ket=t.ket},this.in_grouping=function(t,r,i){return!(this.cursor>=this.limit||i<(i=this.current.charCodeAt(this.cursor))||i>>3]&1<<(7&i))||(this.cursor++,0))},this.go_in_grouping=function(t,r,i){for(;this.cursor>>3]&1<<(7&s)))return!0;this.cursor++}return!1},this.in_grouping_b=function(t,r,i){return!(this.cursor<=this.limit_backward||i<(i=this.current.charCodeAt(this.cursor-1))||i>>3]&1<<(7&i))||(this.cursor--,0))},this.go_in_grouping_b=function(t,r,i){for(;this.cursor>this.limit_backward;){var s=this.current.charCodeAt(this.cursor-1);if(i>>3]&1<<(7&s)))return!0;this.cursor--}return!1},this.out_grouping=function(t,r,i){return!(this.cursor>=this.limit)&&(i<(i=this.current.charCodeAt(this.cursor))||i>>3]&1<<(7&i)))&&(this.cursor++,!0)},this.go_out_grouping=function(t,r,i){for(;this.cursor>>3]&1<<(7&s)))return!0;this.cursor++}return!1},this.out_grouping_b=function(t,r,i){return!(this.cursor<=this.limit_backward)&&(i<(i=this.current.charCodeAt(this.cursor-1))||i>>3]&1<<(7&i)))&&(this.cursor--,!0)},this.go_out_grouping_b=function(t,r,i){for(;this.cursor>this.limit_backward;){var s=this.current.charCodeAt(this.cursor-1);if(s<=i&&r<=s&&0!=(t[(s-=r)>>>3]&1<<(7&s)))return!0;this.cursor--}return!1},this.eq_s=function(t){return!(this.limit-this.cursor>>1),o=0,a=e=(l=t[r])[0].length){if(this.cursor=s+l[0].length,l.length<4)return l[2];var g=l[3](this);if(this.cursor=s+l[0].length,g)return l[2]}}while(0<=(r=l[1]));return 0},this.find_among_b=function(t){for(var r=0,i=t.length,s=this.cursor,h=this.limit_backward,e=0,n=0,c=!1;;){for(var u,o=r+(i-r>>1),a=0,l=e=(u=t[r])[0].length){if(this.cursor=s-u[0].length,u.length<4)return u[2];var g=u[3](this);if(this.cursor=s-u[0].length,g)return u[2]}}while(0<=(r=u[1]));return 0},this.replace_s=function(t,r,i){var s=i.length-(r-t);return this.current=this.current.slice(0,t)+i+this.current.slice(r),this.limit+=s,this.cursor>=r?this.cursor+=s:this.cursor>t&&(this.cursor=t),s},this.slice_check=function(){return!(this.bra<0||this.bra>this.ket||this.ket>this.limit||this.limit>this.current.length)},this.slice_from=function(t){var r=!1;return this.slice_check()&&(this.replace_s(this.bra,this.ket,t),r=!0),r},this.slice_del=function(){return this.slice_from("")},this.insert=function(t,r,i){r=this.replace_s(t,r,i);t<=this.bra&&(this.bra+=r),t<=this.ket&&(this.ket+=r)},this.slice_to=function(){var t="";return t=this.slice_check()?this.current.slice(this.bra,this.ket):t},this.assign_to=function(){return this.current.slice(0,this.limit)}}; +var EnglishStemmer=function(){var a=new BaseStemmer,c=[["arsen",-1,-1],["commun",-1,-1],["emerg",-1,-1],["gener",-1,-1],["later",-1,-1],["organ",-1,-1],["past",-1,-1],["univers",-1,-1]],o=[["'",-1,1],["'s'",0,1],["'s",-1,1]],u=[["ied",-1,2],["s",-1,3],["ies",1,2],["sses",1,1],["ss",1,-1],["us",1,-1]],t=[["succ",-1,1],["proc",-1,1],["exc",-1,1]],l=[["even",-1,2],["cann",-1,2],["inn",-1,2],["earr",-1,2],["herr",-1,2],["out",-1,2],["y",-1,1]],n=[["",-1,-1],["ed",0,2],["eed",1,1],["ing",0,3],["edly",0,2],["eedly",4,1],["ingly",0,2]],f=[["",-1,3],["bb",0,2],["dd",0,2],["ff",0,2],["gg",0,2],["bl",0,1],["mm",0,2],["nn",0,2],["pp",0,2],["rr",0,2],["at",0,1],["tt",0,2],["iz",0,1]],_=[["anci",-1,3],["enci",-1,2],["ogi",-1,14],["li",-1,16],["bli",3,12],["abli",4,4],["alli",3,8],["fulli",3,9],["lessli",3,15],["ousli",3,10],["entli",3,5],["aliti",-1,8],["biliti",-1,12],["iviti",-1,11],["tional",-1,1],["ational",14,7],["alism",-1,8],["ation",-1,7],["ization",17,6],["izer",-1,6],["ator",-1,7],["iveness",-1,11],["fulness",-1,9],["ousness",-1,10],["ogist",-1,13]],m=[["icate",-1,4],["ative",-1,6],["alize",-1,3],["iciti",-1,4],["ical",-1,4],["tional",-1,1],["ational",5,2],["ful",-1,5],["ness",-1,5]],b=[["ic",-1,1],["ance",-1,1],["ence",-1,1],["able",-1,1],["ible",-1,1],["ate",-1,1],["ive",-1,1],["ize",-1,1],["iti",-1,1],["al",-1,1],["ism",-1,1],["ion",-1,2],["er",-1,1],["ous",-1,1],["ant",-1,1],["ent",-1,1],["ment",15,1],["ement",16,1]],k=[["e",-1,1],["l",-1,2]],g=[["andes",-1,-1],["atlas",-1,-1],["bias",-1,-1],["cosmos",-1,-1],["early",-1,5],["gently",-1,3],["howe",-1,-1],["idly",-1,2],["news",-1,-1],["only",-1,6],["singly",-1,7],["skies",-1,1],["sky",-1,-1],["ugly",-1,4]],d=[17,64],v=[17,65,16,1],i=[1,17,65,208,1],w=[55,141,2],p=!1,y=0,h=0;function q(){var r=a.limit-a.cursor;return!!(a.out_grouping_b(i,89,121)&&a.in_grouping_b(v,97,121)&&a.out_grouping_b(v,97,121)||(a.cursor=a.limit-r,a.out_grouping_b(v,97,121)&&a.in_grouping_b(v,97,121)&&!(a.cursor>a.limit_backward))||(a.cursor=a.limit-r,a.eq_s_b("past")))}function z(){return h<=a.cursor}function Y(){return y<=a.cursor}this.stem=function(){var r=a.cursor;if(!(()=>{var r;if(a.bra=a.cursor,0!=(r=a.find_among(g))&&(a.ket=a.cursor,!(a.cursora.limit)a.cursor=i;else{a.cursor=e,a.cursor=r,(()=>{p=!1;var r=a.cursor;if(a.bra=a.cursor,!a.eq_s("'")||(a.ket=a.cursor,a.slice_del())){a.cursor=r;r=a.cursor;if(a.bra=a.cursor,a.eq_s("y")){if(a.ket=a.cursor,!a.slice_from("Y"))return;p=!0}a.cursor=r;for(r=a.cursor;;){var i=a.cursor;r:{for(;;){var e=a.cursor;if(a.in_grouping(v,97,121)&&(a.bra=a.cursor,a.eq_s("y"))){a.ket=a.cursor,a.cursor=e;break}if(a.cursor=e,a.cursor>=a.limit)break r;a.cursor++}if(!a.slice_from("Y"))return;p=!0;continue}a.cursor=i;break}a.cursor=r}})(),h=a.limit,y=a.limit;i=a.cursor;r:{var s=a.cursor;if(0==a.find_among(c)){if(a.cursor=s,!a.go_out_grouping(v,97,121))break r;if(a.cursor++,!a.go_in_grouping(v,97,121))break r;a.cursor++}h=a.cursor,a.go_out_grouping(v,97,121)&&(a.cursor++,a.go_in_grouping(v,97,121))&&(a.cursor++,y=a.cursor)}a.cursor=i,a.limit_backward=a.cursor,a.cursor=a.limit;var e=a.limit-a.cursor,r=((()=>{var r=a.limit-a.cursor;if(a.ket=a.cursor,0==a.find_among_b(o))a.cursor=a.limit-r;else if(a.bra=a.cursor,!a.slice_del())return;if(a.ket=a.cursor,0!=(r=a.find_among_b(u)))switch(a.bra=a.cursor,r){case 1:if(a.slice_from("ss"))break;return;case 2:r:{var i=a.limit-a.cursor,e=a.cursor-2;if(!(e{a.ket=a.cursor,o=a.find_among_b(n),a.bra=a.cursor;r:{var r=a.limit-a.cursor;i:{switch(o){case 1:var i=a.limit-a.cursor;e:{var e=a.limit-a.cursor;if(0==a.find_among_b(t)||a.cursor>a.limit_backward){if(a.cursor=a.limit-e,!z())break e;if(!a.slice_from("ee"))return}}a.cursor=a.limit-i;break;case 2:break i;case 3:if(0==(o=a.find_among_b(l)))break i;switch(o){case 1:var s=a.limit-a.cursor;if(!a.out_grouping_b(v,97,121))break i;if(a.cursor>a.limit_backward)break i;if(a.cursor=a.limit-s,a.bra=a.cursor,a.slice_from("ie"))break;return;case 2:if(a.cursor>a.limit_backward)break i}}break r}a.cursor=a.limit-r;var c=a.limit-a.cursor;if(!a.go_out_grouping_b(v,97,121))return;if(a.cursor--,a.cursor=a.limit-c,!a.slice_del())return;a.ket=a.cursor,a.bra=a.cursor;var o,c=a.limit-a.cursor;switch(o=a.find_among_b(f)){case 1:return a.slice_from("e");case 2:var u=a.limit-a.cursor;if(a.in_grouping_b(d,97,111)&&!(a.cursor>a.limit_backward))return;a.cursor=a.limit-u;break;case 3:return a.cursor!=h||(u=a.limit-a.cursor,q()&&(a.cursor=a.limit-u,a.slice_from("e")))}if(a.cursor=a.limit-c,a.ket=a.cursor,a.cursor<=a.limit_backward)return;if(a.cursor--,a.bra=a.cursor,!a.slice_del())return}})(),a.cursor=a.limit-r,a.limit-a.cursor),r=(a.ket=a.cursor,e=a.limit-a.cursor,(a.eq_s_b("y")||(a.cursor=a.limit-e,a.eq_s_b("Y")))&&(a.bra=a.cursor,a.out_grouping_b(v,97,121))&&a.cursor>a.limit_backward&&a.slice_from("i"),a.cursor=a.limit-i,a.limit-a.cursor),e=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(_))&&(a.bra=a.cursor,z()))switch(r){case 1:if(a.slice_from("tion"))break;return;case 2:if(a.slice_from("ence"))break;return;case 3:if(a.slice_from("ance"))break;return;case 4:if(a.slice_from("able"))break;return;case 5:if(a.slice_from("ent"))break;return;case 6:if(a.slice_from("ize"))break;return;case 7:if(a.slice_from("ate"))break;return;case 8:if(a.slice_from("al"))break;return;case 9:if(a.slice_from("ful"))break;return;case 10:if(a.slice_from("ous"))break;return;case 11:if(a.slice_from("ive"))break;return;case 12:if(a.slice_from("ble"))break;return;case 13:if(a.slice_from("og"))break;return;case 14:if(!a.eq_s_b("l"))return;if(a.slice_from("og"))break;return;case 15:if(a.slice_from("less"))break;return;case 16:if(!a.in_grouping_b(w,99,116))return;if(a.slice_del())break}})(),a.cursor=a.limit-r,a.limit-a.cursor),i=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(m))&&(a.bra=a.cursor,z()))switch(r){case 1:if(a.slice_from("tion"))break;return;case 2:if(a.slice_from("ate"))break;return;case 3:if(a.slice_from("al"))break;return;case 4:if(a.slice_from("ic"))break;return;case 5:if(a.slice_del())break;return;case 6:if(!Y())return;if(a.slice_del())break}})(),a.cursor=a.limit-e,a.limit-a.cursor),r=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(b))&&(a.bra=a.cursor,Y()))switch(r){case 1:if(a.slice_del())break;return;case 2:var i=a.limit-a.cursor;if(!a.eq_s_b("s")&&(a.cursor=a.limit-i,!a.eq_s_b("t")))return;if(a.slice_del())break}})(),a.cursor=a.limit-i,a.limit-a.cursor),e=((()=>{var r;if(a.ket=a.cursor,0!=(r=a.find_among_b(k)))switch(a.bra=a.cursor,r){case 1:if(!Y()){if(!z())return;var i=a.limit-a.cursor;if(q())return;a.cursor=a.limit-i}if(a.slice_del())break;return;case 2:if(!Y())return;if(!a.eq_s_b("l"))return;if(a.slice_del())break}})(),a.cursor=a.limit-r,a.cursor=a.limit_backward,a.cursor);(()=>{if(p)for(;;){var r=a.cursor;r:{for(;;){var i=a.cursor;if(a.bra=a.cursor,a.eq_s("Y")){a.ket=a.cursor,a.cursor=i;break}if(a.cursor=i,a.cursor>=a.limit)break r;a.cursor++}if(a.slice_from("y"))continue;return}a.cursor=r;break}})(),a.cursor=e}}return!0},this.stemWord=function(r){return a.setCurrent(r),this.stem(),a.getCurrent()}}; +window.Stemmer = EnglishStemmer; diff --git a/docs/site/_static/minus.png b/docs/site/_static/minus.png new file mode 100644 index 0000000..d96755f Binary files /dev/null and b/docs/site/_static/minus.png differ diff --git a/docs/site/_static/plus.png b/docs/site/_static/plus.png new file mode 100644 index 0000000..7107cec Binary files /dev/null and b/docs/site/_static/plus.png differ diff --git a/docs/site/_static/pygments.css b/docs/site/_static/pygments.css new file mode 100644 index 0000000..9d1083b --- /dev/null +++ b/docs/site/_static/pygments.css @@ -0,0 +1,250 @@ +.highlight pre { line-height: 125%; } +.highlight td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +.highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.highlight .hll { background-color: #fdf2e2 } +.highlight { background: #f2f2f2; color: #1E1E1E } +.highlight .c { color: #515151 } /* Comment */ +.highlight .err { color: #D71835 } /* Error */ +.highlight .k { color: #8045E5 } /* Keyword */ +.highlight .l { color: #7F4707 } /* Literal */ +.highlight .n { color: #1E1E1E } /* Name */ +.highlight .o { color: #163 } /* Operator */ +.highlight .p { color: #1E1E1E } /* Punctuation */ +.highlight .ch { color: #515151 } /* Comment.Hashbang */ +.highlight .cm { color: #515151 } /* Comment.Multiline */ +.highlight .cp { color: #515151 } /* Comment.Preproc */ +.highlight .cpf { color: #515151 } /* Comment.PreprocFile */ +.highlight .c1 { color: #515151 } /* Comment.Single */ +.highlight .cs { color: #515151 } /* Comment.Special */ +.highlight .gd { color: #00749C } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .gh { color: #00749C } /* Generic.Heading */ +.highlight .gs { font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: #00749C } /* Generic.Subheading */ +.highlight .kc { color: #8045E5 } /* Keyword.Constant */ +.highlight .kd { color: #8045E5 } /* Keyword.Declaration */ +.highlight .kn { color: #8045E5 } /* Keyword.Namespace */ +.highlight .kp { color: #8045E5 } /* Keyword.Pseudo */ +.highlight .kr { color: #8045E5 } /* Keyword.Reserved */ +.highlight .kt { color: #7F4707 } /* Keyword.Type */ +.highlight .ld { color: #7F4707 } /* Literal.Date */ +.highlight .m { color: #7F4707 } /* Literal.Number */ +.highlight .s { color: #163 } /* Literal.String */ +.highlight .na { color: #7F4707 } /* Name.Attribute */ +.highlight .nb { color: #7F4707 } /* Name.Builtin */ +.highlight .nc { color: #00749C } /* Name.Class */ +.highlight .no { color: #00749C } /* Name.Constant */ +.highlight .nd { color: #7F4707 } /* Name.Decorator */ +.highlight .ni { color: #163 } /* Name.Entity */ +.highlight .ne { color: #8045E5 } /* Name.Exception */ +.highlight .nf { color: #00749C } /* Name.Function */ +.highlight .nl { color: #7F4707 } /* Name.Label */ +.highlight .nn { color: #1E1E1E } /* Name.Namespace */ +.highlight .nx { color: #1E1E1E } /* Name.Other */ +.highlight .py { color: #00749C } /* Name.Property */ +.highlight .nt { color: #00749C } /* Name.Tag */ +.highlight .nv { color: #D71835 } /* Name.Variable */ +.highlight .ow { color: #8045E5 } /* Operator.Word */ +.highlight .pm { color: #1E1E1E } /* Punctuation.Marker */ +.highlight .w { color: #1E1E1E } /* Text.Whitespace */ +.highlight .mb { color: #7F4707 } /* Literal.Number.Bin */ +.highlight .mf { color: #7F4707 } /* Literal.Number.Float */ +.highlight .mh { color: #7F4707 } /* Literal.Number.Hex */ +.highlight .mi { color: #7F4707 } /* Literal.Number.Integer */ +.highlight .mo { color: #7F4707 } /* Literal.Number.Oct */ +.highlight .sa { color: #163 } /* Literal.String.Affix */ +.highlight .sb { color: #163 } /* Literal.String.Backtick */ +.highlight .sc { color: #163 } /* Literal.String.Char */ +.highlight .dl { color: #163 } /* Literal.String.Delimiter */ +.highlight .sd { color: #163 } /* Literal.String.Doc */ +.highlight .s2 { color: #163 } /* Literal.String.Double */ +.highlight .se { color: #163 } /* Literal.String.Escape */ +.highlight .sh { color: #163 } /* Literal.String.Heredoc */ +.highlight .si { color: #163 } /* Literal.String.Interpol */ +.highlight .sx { color: #163 } /* Literal.String.Other */ +.highlight .sr { color: #D71835 } /* Literal.String.Regex */ +.highlight .s1 { color: #163 } /* Literal.String.Single */ +.highlight .ss { color: #00749C } /* Literal.String.Symbol */ +.highlight .bp { color: #7F4707 } /* Name.Builtin.Pseudo */ +.highlight .fm { color: #00749C } /* Name.Function.Magic */ +.highlight .vc { color: #D71835 } /* Name.Variable.Class */ +.highlight .vg { color: #D71835 } /* Name.Variable.Global */ +.highlight .vi { color: #D71835 } /* Name.Variable.Instance */ +.highlight .vm { color: #7F4707 } /* Name.Variable.Magic */ +.highlight .il { color: #7F4707 } /* Literal.Number.Integer.Long */ +@media not print { +body[data-theme="dark"] .highlight pre { line-height: 125%; } +body[data-theme="dark"] .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body[data-theme="dark"] .highlight .hll { background-color: #404040 } +body[data-theme="dark"] .highlight { background: #202020; color: #D0D0D0 } +body[data-theme="dark"] .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ +body[data-theme="dark"] .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ +body[data-theme="dark"] .highlight .esc { color: #D0D0D0 } /* Escape */ +body[data-theme="dark"] .highlight .g { color: #D0D0D0 } /* Generic */ +body[data-theme="dark"] .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ +body[data-theme="dark"] .highlight .l { color: #D0D0D0 } /* Literal */ +body[data-theme="dark"] .highlight .n { color: #D0D0D0 } /* Name */ +body[data-theme="dark"] .highlight .o { color: #D0D0D0 } /* Operator */ +body[data-theme="dark"] .highlight .x { color: #D0D0D0 } /* Other */ +body[data-theme="dark"] .highlight .p { color: #D0D0D0 } /* Punctuation */ +body[data-theme="dark"] .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ +body[data-theme="dark"] .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ +body[data-theme="dark"] .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ +body[data-theme="dark"] .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ +body[data-theme="dark"] .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ +body[data-theme="dark"] .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body[data-theme="dark"] .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ +body[data-theme="dark"] .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ +body[data-theme="dark"] .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +body[data-theme="dark"] .highlight .gr { color: #FF3A3A } /* Generic.Error */ +body[data-theme="dark"] .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ +body[data-theme="dark"] .highlight .gi { color: #589819 } /* Generic.Inserted */ +body[data-theme="dark"] .highlight .go { color: #CCC } /* Generic.Output */ +body[data-theme="dark"] .highlight .gp { color: #AAA } /* Generic.Prompt */ +body[data-theme="dark"] .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ +body[data-theme="dark"] .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ +body[data-theme="dark"] .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ +body[data-theme="dark"] .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ +body[data-theme="dark"] .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ +body[data-theme="dark"] .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ +body[data-theme="dark"] .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ +body[data-theme="dark"] .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ +body[data-theme="dark"] .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ +body[data-theme="dark"] .highlight .ld { color: #D0D0D0 } /* Literal.Date */ +body[data-theme="dark"] .highlight .m { color: #51B2FD } /* Literal.Number */ +body[data-theme="dark"] .highlight .s { color: #ED9D13 } /* Literal.String */ +body[data-theme="dark"] .highlight .na { color: #BBB } /* Name.Attribute */ +body[data-theme="dark"] .highlight .nb { color: #2FBCCD } /* Name.Builtin */ +body[data-theme="dark"] .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ +body[data-theme="dark"] .highlight .no { color: #40FFFF } /* Name.Constant */ +body[data-theme="dark"] .highlight .nd { color: #FFA500 } /* Name.Decorator */ +body[data-theme="dark"] .highlight .ni { color: #D0D0D0 } /* Name.Entity */ +body[data-theme="dark"] .highlight .ne { color: #BBB } /* Name.Exception */ +body[data-theme="dark"] .highlight .nf { color: #71ADFF } /* Name.Function */ +body[data-theme="dark"] .highlight .nl { color: #D0D0D0 } /* Name.Label */ +body[data-theme="dark"] .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ +body[data-theme="dark"] .highlight .nx { color: #D0D0D0 } /* Name.Other */ +body[data-theme="dark"] .highlight .py { color: #D0D0D0 } /* Name.Property */ +body[data-theme="dark"] .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ +body[data-theme="dark"] .highlight .nv { color: #40FFFF } /* Name.Variable */ +body[data-theme="dark"] .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ +body[data-theme="dark"] .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ +body[data-theme="dark"] .highlight .w { color: #666 } /* Text.Whitespace */ +body[data-theme="dark"] .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ +body[data-theme="dark"] .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ +body[data-theme="dark"] .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ +body[data-theme="dark"] .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ +body[data-theme="dark"] .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ +body[data-theme="dark"] .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ +body[data-theme="dark"] .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ +body[data-theme="dark"] .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ +body[data-theme="dark"] .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ +body[data-theme="dark"] .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ +body[data-theme="dark"] .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ +body[data-theme="dark"] .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ +body[data-theme="dark"] .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ +body[data-theme="dark"] .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ +body[data-theme="dark"] .highlight .sx { color: #FFA500 } /* Literal.String.Other */ +body[data-theme="dark"] .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ +body[data-theme="dark"] .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ +body[data-theme="dark"] .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ +body[data-theme="dark"] .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ +body[data-theme="dark"] .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ +body[data-theme="dark"] .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ +body[data-theme="dark"] .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ +body[data-theme="dark"] .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ +body[data-theme="dark"] .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ +body[data-theme="dark"] .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ +@media (prefers-color-scheme: dark) { +body:not([data-theme="light"]) .highlight pre { line-height: 125%; } +body:not([data-theme="light"]) .highlight td.linenos .normal { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos { color: #aaaaaa; background-color: transparent; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +body:not([data-theme="light"]) .highlight .hll { background-color: #404040 } +body:not([data-theme="light"]) .highlight { background: #202020; color: #D0D0D0 } +body:not([data-theme="light"]) .highlight .c { color: #ABABAB; font-style: italic } /* Comment */ +body:not([data-theme="light"]) .highlight .err { color: #A61717; background-color: #E3D2D2 } /* Error */ +body:not([data-theme="light"]) .highlight .esc { color: #D0D0D0 } /* Escape */ +body:not([data-theme="light"]) .highlight .g { color: #D0D0D0 } /* Generic */ +body:not([data-theme="light"]) .highlight .k { color: #6EBF26; font-weight: bold } /* Keyword */ +body:not([data-theme="light"]) .highlight .l { color: #D0D0D0 } /* Literal */ +body:not([data-theme="light"]) .highlight .n { color: #D0D0D0 } /* Name */ +body:not([data-theme="light"]) .highlight .o { color: #D0D0D0 } /* Operator */ +body:not([data-theme="light"]) .highlight .x { color: #D0D0D0 } /* Other */ +body:not([data-theme="light"]) .highlight .p { color: #D0D0D0 } /* Punctuation */ +body:not([data-theme="light"]) .highlight .ch { color: #ABABAB; font-style: italic } /* Comment.Hashbang */ +body:not([data-theme="light"]) .highlight .cm { color: #ABABAB; font-style: italic } /* Comment.Multiline */ +body:not([data-theme="light"]) .highlight .cp { color: #FF3A3A; font-weight: bold } /* Comment.Preproc */ +body:not([data-theme="light"]) .highlight .cpf { color: #ABABAB; font-style: italic } /* Comment.PreprocFile */ +body:not([data-theme="light"]) .highlight .c1 { color: #ABABAB; font-style: italic } /* Comment.Single */ +body:not([data-theme="light"]) .highlight .cs { color: #E50808; font-weight: bold; background-color: #520000 } /* Comment.Special */ +body:not([data-theme="light"]) .highlight .gd { color: #FF3A3A } /* Generic.Deleted */ +body:not([data-theme="light"]) .highlight .ge { color: #D0D0D0; font-style: italic } /* Generic.Emph */ +body:not([data-theme="light"]) .highlight .ges { color: #D0D0D0; font-weight: bold; font-style: italic } /* Generic.EmphStrong */ +body:not([data-theme="light"]) .highlight .gr { color: #FF3A3A } /* Generic.Error */ +body:not([data-theme="light"]) .highlight .gh { color: #FFF; font-weight: bold } /* Generic.Heading */ +body:not([data-theme="light"]) .highlight .gi { color: #589819 } /* Generic.Inserted */ +body:not([data-theme="light"]) .highlight .go { color: #CCC } /* Generic.Output */ +body:not([data-theme="light"]) .highlight .gp { color: #AAA } /* Generic.Prompt */ +body:not([data-theme="light"]) .highlight .gs { color: #D0D0D0; font-weight: bold } /* Generic.Strong */ +body:not([data-theme="light"]) .highlight .gu { color: #FFF; text-decoration: underline } /* Generic.Subheading */ +body:not([data-theme="light"]) .highlight .gt { color: #FF3A3A } /* Generic.Traceback */ +body:not([data-theme="light"]) .highlight .kc { color: #6EBF26; font-weight: bold } /* Keyword.Constant */ +body:not([data-theme="light"]) .highlight .kd { color: #6EBF26; font-weight: bold } /* Keyword.Declaration */ +body:not([data-theme="light"]) .highlight .kn { color: #6EBF26; font-weight: bold } /* Keyword.Namespace */ +body:not([data-theme="light"]) .highlight .kp { color: #6EBF26 } /* Keyword.Pseudo */ +body:not([data-theme="light"]) .highlight .kr { color: #6EBF26; font-weight: bold } /* Keyword.Reserved */ +body:not([data-theme="light"]) .highlight .kt { color: #6EBF26; font-weight: bold } /* Keyword.Type */ +body:not([data-theme="light"]) .highlight .ld { color: #D0D0D0 } /* Literal.Date */ +body:not([data-theme="light"]) .highlight .m { color: #51B2FD } /* Literal.Number */ +body:not([data-theme="light"]) .highlight .s { color: #ED9D13 } /* Literal.String */ +body:not([data-theme="light"]) .highlight .na { color: #BBB } /* Name.Attribute */ +body:not([data-theme="light"]) .highlight .nb { color: #2FBCCD } /* Name.Builtin */ +body:not([data-theme="light"]) .highlight .nc { color: #71ADFF; text-decoration: underline } /* Name.Class */ +body:not([data-theme="light"]) .highlight .no { color: #40FFFF } /* Name.Constant */ +body:not([data-theme="light"]) .highlight .nd { color: #FFA500 } /* Name.Decorator */ +body:not([data-theme="light"]) .highlight .ni { color: #D0D0D0 } /* Name.Entity */ +body:not([data-theme="light"]) .highlight .ne { color: #BBB } /* Name.Exception */ +body:not([data-theme="light"]) .highlight .nf { color: #71ADFF } /* Name.Function */ +body:not([data-theme="light"]) .highlight .nl { color: #D0D0D0 } /* Name.Label */ +body:not([data-theme="light"]) .highlight .nn { color: #71ADFF; text-decoration: underline } /* Name.Namespace */ +body:not([data-theme="light"]) .highlight .nx { color: #D0D0D0 } /* Name.Other */ +body:not([data-theme="light"]) .highlight .py { color: #D0D0D0 } /* Name.Property */ +body:not([data-theme="light"]) .highlight .nt { color: #6EBF26; font-weight: bold } /* Name.Tag */ +body:not([data-theme="light"]) .highlight .nv { color: #40FFFF } /* Name.Variable */ +body:not([data-theme="light"]) .highlight .ow { color: #6EBF26; font-weight: bold } /* Operator.Word */ +body:not([data-theme="light"]) .highlight .pm { color: #D0D0D0 } /* Punctuation.Marker */ +body:not([data-theme="light"]) .highlight .w { color: #666 } /* Text.Whitespace */ +body:not([data-theme="light"]) .highlight .mb { color: #51B2FD } /* Literal.Number.Bin */ +body:not([data-theme="light"]) .highlight .mf { color: #51B2FD } /* Literal.Number.Float */ +body:not([data-theme="light"]) .highlight .mh { color: #51B2FD } /* Literal.Number.Hex */ +body:not([data-theme="light"]) .highlight .mi { color: #51B2FD } /* Literal.Number.Integer */ +body:not([data-theme="light"]) .highlight .mo { color: #51B2FD } /* Literal.Number.Oct */ +body:not([data-theme="light"]) .highlight .sa { color: #ED9D13 } /* Literal.String.Affix */ +body:not([data-theme="light"]) .highlight .sb { color: #ED9D13 } /* Literal.String.Backtick */ +body:not([data-theme="light"]) .highlight .sc { color: #ED9D13 } /* Literal.String.Char */ +body:not([data-theme="light"]) .highlight .dl { color: #ED9D13 } /* Literal.String.Delimiter */ +body:not([data-theme="light"]) .highlight .sd { color: #ED9D13 } /* Literal.String.Doc */ +body:not([data-theme="light"]) .highlight .s2 { color: #ED9D13 } /* Literal.String.Double */ +body:not([data-theme="light"]) .highlight .se { color: #ED9D13 } /* Literal.String.Escape */ +body:not([data-theme="light"]) .highlight .sh { color: #ED9D13 } /* Literal.String.Heredoc */ +body:not([data-theme="light"]) .highlight .si { color: #ED9D13 } /* Literal.String.Interpol */ +body:not([data-theme="light"]) .highlight .sx { color: #FFA500 } /* Literal.String.Other */ +body:not([data-theme="light"]) .highlight .sr { color: #ED9D13 } /* Literal.String.Regex */ +body:not([data-theme="light"]) .highlight .s1 { color: #ED9D13 } /* Literal.String.Single */ +body:not([data-theme="light"]) .highlight .ss { color: #ED9D13 } /* Literal.String.Symbol */ +body:not([data-theme="light"]) .highlight .bp { color: #2FBCCD } /* Name.Builtin.Pseudo */ +body:not([data-theme="light"]) .highlight .fm { color: #71ADFF } /* Name.Function.Magic */ +body:not([data-theme="light"]) .highlight .vc { color: #40FFFF } /* Name.Variable.Class */ +body:not([data-theme="light"]) .highlight .vg { color: #40FFFF } /* Name.Variable.Global */ +body:not([data-theme="light"]) .highlight .vi { color: #40FFFF } /* Name.Variable.Instance */ +body:not([data-theme="light"]) .highlight .vm { color: #40FFFF } /* Name.Variable.Magic */ +body:not([data-theme="light"]) .highlight .il { color: #51B2FD } /* Literal.Number.Integer.Long */ +} +} \ No newline at end of file diff --git a/docs/site/_static/scripts/furo-extensions.js b/docs/site/_static/scripts/furo-extensions.js new file mode 100644 index 0000000..e69de29 diff --git a/docs/site/_static/scripts/furo.js b/docs/site/_static/scripts/furo.js new file mode 100644 index 0000000..87e1767 --- /dev/null +++ b/docs/site/_static/scripts/furo.js @@ -0,0 +1,3 @@ +/*! For license information please see furo.js.LICENSE.txt */ +(()=>{var t={856:function(t,e,n){var o,r;r=void 0!==n.g?n.g:"undefined"!=typeof window?window:this,o=function(){return function(t){"use strict";var e={navClass:"active",contentClass:"active",nested:!1,nestedClass:"active",offset:0,reflow:!1,events:!0},n=function(t,e,n){if(n.settings.events){var o=new CustomEvent(t,{bubbles:!0,cancelable:!0,detail:n});e.dispatchEvent(o)}},o=function(t){var e=0;if(t.offsetParent)for(;t;)e+=t.offsetTop,t=t.offsetParent;return e>=0?e:0},r=function(t){t&&t.sort(function(t,e){return o(t.content)=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)},l=function(t,e){var n=t[t.length-1];if(function(t,e){return!(!s()||!c(t.content,e,!0))}(n,e))return n;for(var o=t.length-1;o>=0;o--)if(c(t[o].content,e))return t[o]},a=function(t,e){if(e.nested&&t.parentNode){var n=t.parentNode.closest("li");n&&(n.classList.remove(e.nestedClass),a(n,e))}},i=function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.remove(e.navClass),t.content.classList.remove(e.contentClass),a(o,e),n("gumshoeDeactivate",o,{link:t.nav,content:t.content,settings:e}))}},u=function(t,e){if(e.nested){var n=t.parentNode.closest("li");n&&(n.classList.add(e.nestedClass),u(n,e))}};return function(o,c){var s,a,d,f,m,v={setup:function(){s=document.querySelectorAll(o),a=[],Array.prototype.forEach.call(s,function(t){var e=document.getElementById(decodeURIComponent(t.hash.substr(1)));e&&a.push({nav:t,content:e})}),r(a)},detect:function(){var t=l(a,m);t?d&&t.content===d.content||(i(d,m),function(t,e){if(t){var o=t.nav.closest("li");o&&(o.classList.add(e.navClass),t.content.classList.add(e.contentClass),u(o,e),n("gumshoeActivate",o,{link:t.nav,content:t.content,settings:e}))}}(t,m),d=t):d&&(i(d,m),d=null)}},h=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(v.detect)},g=function(e){f&&t.cancelAnimationFrame(f),f=t.requestAnimationFrame(function(){r(a),v.detect()})};return v.destroy=function(){d&&i(d,m),t.removeEventListener("scroll",h,!1),m.reflow&&t.removeEventListener("resize",g,!1),a=null,s=null,d=null,f=null,m=null},m=function(){var t={};return Array.prototype.forEach.call(arguments,function(e){for(var n in e){if(!e.hasOwnProperty(n))return;t[n]=e[n]}}),t}(e,c||{}),v.setup(),v.detect(),t.addEventListener("scroll",h,!1),m.reflow&&t.addEventListener("resize",g,!1),v}}(r)}.apply(e,[]),void 0===o||(t.exports=o)}},e={};function n(o){var r=e[o];if(void 0!==r)return r.exports;var c=e[o]={exports:{}};return t[o].call(c.exports,c,c.exports,n),c.exports}n.n=t=>{var e=t&&t.__esModule?()=>t.default:()=>t;return n.d(e,{a:e}),e},n.d=(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},n.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),n.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),(()=>{"use strict";var t=n(856),e=n.n(t),o=null,r=null,c=document.documentElement.scrollTop;function s(){const t=localStorage.getItem("theme")||"auto";var e;"light"!==(e=window.matchMedia("(prefers-color-scheme: dark)").matches?"auto"===t?"light":"light"==t?"dark":"auto":"auto"===t?"dark":"dark"==t?"light":"auto")&&"dark"!==e&&"auto"!==e&&(console.error(`Got invalid theme mode: ${e}. Resetting to auto.`),e="auto"),document.body.dataset.theme=e,localStorage.setItem("theme",e),console.log(`Changed to ${e} mode.`)}function l(){!function(){const t=document.getElementsByClassName("theme-toggle");Array.from(t).forEach(t=>{t.addEventListener("click",s)})}(),function(){let t=0,e=!1;window.addEventListener("scroll",function(n){t=window.scrollY,e||(window.requestAnimationFrame(function(){var n;(function(t){t>0?r.classList.add("scrolled"):r.classList.remove("scrolled")})(n=t),function(t){t<64?document.documentElement.classList.remove("show-back-to-top"):tc&&document.documentElement.classList.remove("show-back-to-top"),c=t}(n),function(t){null!==o&&(0==t?o.scrollTo(0,0):Math.ceil(t)>=Math.floor(document.documentElement.scrollHeight-window.innerHeight)?o.scrollTo(0,o.scrollHeight):document.querySelector(".scroll-current"))}(n),e=!1}),e=!0)}),window.scroll()}(),null!==o&&new(e())(".toc-tree a",{reflow:!0,recursive:!0,navClass:"scroll-current",offset:()=>{let t=parseFloat(getComputedStyle(document.documentElement).fontSize);const e=r.getBoundingClientRect();return e.top+e.height+2.5*t+1}})}document.addEventListener("DOMContentLoaded",function(){document.body.parentNode.classList.remove("no-js"),r=document.querySelector("header"),o=document.querySelector(".toc-scroll"),l()})})()})(); +//# sourceMappingURL=furo.js.map \ No newline at end of file diff --git a/docs/site/_static/scripts/furo.js.LICENSE.txt b/docs/site/_static/scripts/furo.js.LICENSE.txt new file mode 100644 index 0000000..1632189 --- /dev/null +++ b/docs/site/_static/scripts/furo.js.LICENSE.txt @@ -0,0 +1,7 @@ +/*! + * gumshoejs v5.1.2 (patched by @pradyunsg) + * A simple, framework-agnostic scrollspy script. + * (c) 2019 Chris Ferdinandi + * MIT License + * http://github.com/cferdinandi/gumshoe + */ diff --git a/docs/site/_static/scripts/furo.js.map b/docs/site/_static/scripts/furo.js.map new file mode 100644 index 0000000..3b316f3 --- /dev/null +++ b/docs/site/_static/scripts/furo.js.map @@ -0,0 +1 @@ +{"version":3,"file":"scripts/furo.js","mappings":";iCAAA,MAQWA,SAWS,IAAX,EAAAC,EACH,EAAAA,EACkB,oBAAXC,OACLA,OACAC,KAbO,EAAF,WACP,OAaJ,SAAUD,GACR,aAMA,IAAIE,EAAW,CAEbC,SAAU,SACVC,aAAc,SAGdC,QAAQ,EACRC,YAAa,SAGbC,OAAQ,EACRC,QAAQ,EAGRC,QAAQ,GA6BNC,EAAY,SAAUC,EAAMC,EAAMC,GAEpC,GAAKA,EAAOC,SAASL,OAArB,CAGA,IAAIM,EAAQ,IAAIC,YAAYL,EAAM,CAChCM,SAAS,EACTC,YAAY,EACZL,OAAQA,IAIVD,EAAKO,cAAcJ,EAVgB,CAWrC,EAOIK,EAAe,SAAUR,GAC3B,IAAIS,EAAW,EACf,GAAIT,EAAKU,aACP,KAAOV,GACLS,GAAYT,EAAKW,UACjBX,EAAOA,EAAKU,aAGhB,OAAOD,GAAY,EAAIA,EAAW,CACpC,EAMIG,EAAe,SAAUC,GACvBA,GACFA,EAASC,KAAK,SAAUC,EAAOC,GAG7B,OAFcR,EAAaO,EAAME,SACnBT,EAAaQ,EAAMC,UACF,EACxB,CACT,EAEJ,EAwCIC,EAAW,SAAUlB,EAAME,EAAUiB,GACvC,IAAIC,EAASpB,EAAKqB,wBACd1B,EAnCU,SAAUO,GAExB,MAA+B,mBAApBA,EAASP,OACX2B,WAAWpB,EAASP,UAItB2B,WAAWpB,EAASP,OAC7B,CA2Be4B,CAAUrB,GACvB,OAAIiB,EAEAK,SAASJ,EAAOD,OAAQ,KACvB/B,EAAOqC,aAAeC,SAASC,gBAAgBC,cAG7CJ,SAASJ,EAAOS,IAAK,KAAOlC,CACrC,EAMImC,EAAa,WACf,OACEC,KAAKC,KAAK5C,EAAOqC,YAAcrC,EAAO6C,cAnCjCF,KAAKG,IACVR,SAASS,KAAKC,aACdV,SAASC,gBAAgBS,aACzBV,SAASS,KAAKE,aACdX,SAASC,gBAAgBU,aACzBX,SAASS,KAAKP,aACdF,SAASC,gBAAgBC,aAkC7B,EAmBIU,EAAY,SAAUzB,EAAUX,GAClC,IAAIqC,EAAO1B,EAASA,EAAS2B,OAAS,GACtC,GAbgB,SAAUC,EAAMvC,GAChC,SAAI4B,MAAgBZ,EAASuB,EAAKxB,QAASf,GAAU,GAEvD,CAUMwC,CAAYH,EAAMrC,GAAW,OAAOqC,EACxC,IAAK,IAAII,EAAI9B,EAAS2B,OAAS,EAAGG,GAAK,EAAGA,IACxC,GAAIzB,EAASL,EAAS8B,GAAG1B,QAASf,GAAW,OAAOW,EAAS8B,EAEjE,EAOIC,EAAmB,SAAUC,EAAK3C,GAEpC,GAAKA,EAAST,QAAWoD,EAAIC,WAA7B,CAGA,IAAIC,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASR,aAG7BkD,EAAiBG,EAAI7C,GAV0B,CAWjD,EAOIiD,EAAa,SAAUC,EAAOlD,GAEhC,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUC,OAAOhD,EAASX,UAC7B6D,EAAMnC,QAAQgC,UAAUC,OAAOhD,EAASV,cAGxCoD,EAAiBG,EAAI7C,GAGrBJ,EAAU,oBAAqBiD,EAAI,CACjCM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,EAOIoD,EAAiB,SAAUT,EAAK3C,GAElC,GAAKA,EAAST,OAAd,CAGA,IAAIsD,EAAKF,EAAIC,WAAWE,QAAQ,MAC3BD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASR,aAG1B4D,EAAeP,EAAI7C,GAVS,CAW9B,EA6LA,OA1JkB,SAAUsD,EAAUC,GAKpC,IACIC,EAAU7C,EAAU8C,EAASC,EAAS1D,EADtC2D,EAAa,CAUjBA,MAAmB,WAEjBH,EAAWhC,SAASoC,iBAAiBN,GAGrC3C,EAAW,GAGXkD,MAAMC,UAAUC,QAAQC,KAAKR,EAAU,SAAUjB,GAE/C,IAAIxB,EAAUS,SAASyC,eACrBC,mBAAmB3B,EAAK4B,KAAKC,OAAO,KAEjCrD,GAGLJ,EAAS0D,KAAK,CACZ1B,IAAKJ,EACLxB,QAASA,GAEb,GAGAL,EAAaC,EACf,EAKAgD,OAAoB,WAElB,IAAIW,EAASlC,EAAUzB,EAAUX,GAG5BsE,EASDb,GAAWa,EAAOvD,UAAY0C,EAAQ1C,UAG1CkC,EAAWQ,EAASzD,GAzFT,SAAUkD,EAAOlD,GAE9B,GAAKkD,EAAL,CAGA,IAAIL,EAAKK,EAAMP,IAAIG,QAAQ,MACtBD,IAGLA,EAAGE,UAAUM,IAAIrD,EAASX,UAC1B6D,EAAMnC,QAAQgC,UAAUM,IAAIrD,EAASV,cAGrC8D,EAAeP,EAAI7C,GAGnBJ,EAAU,kBAAmBiD,EAAI,CAC/BM,KAAMD,EAAMP,IACZ5B,QAASmC,EAAMnC,QACff,SAAUA,IAjBM,CAmBpB,CAqEIuE,CAASD,EAAQtE,GAGjByD,EAAUa,GAfJb,IACFR,EAAWQ,EAASzD,GACpByD,EAAU,KAchB,GAMIe,EAAgB,SAAUvE,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsBf,EAAWgB,OACpD,EAMIC,EAAgB,SAAU3E,GAExByD,GACFxE,EAAOuF,qBAAqBf,GAI9BA,EAAUxE,EAAOwF,sBAAsB,WACrChE,EAAaC,GACbgD,EAAWgB,QACb,EACF,EAkDA,OA7CAhB,EAAWkB,QAAU,WAEfpB,GACFR,EAAWQ,EAASzD,GAItBd,EAAO4F,oBAAoB,SAAUN,GAAe,GAChDxE,EAASN,QACXR,EAAO4F,oBAAoB,SAAUF,GAAe,GAItDjE,EAAW,KACX6C,EAAW,KACXC,EAAU,KACVC,EAAU,KACV1D,EAAW,IACb,EAOEA,EA3XS,WACX,IAAI+E,EAAS,CAAC,EAOd,OANAlB,MAAMC,UAAUC,QAAQC,KAAKgB,UAAW,SAAUC,GAChD,IAAK,IAAIC,KAAOD,EAAK,CACnB,IAAKA,EAAIE,eAAeD,GAAM,OAC9BH,EAAOG,GAAOD,EAAIC,EACpB,CACF,GACOH,CACT,CAkXeK,CAAOhG,EAAUmE,GAAW,CAAC,GAGxCI,EAAW0B,QAGX1B,EAAWgB,SAGXzF,EAAOoG,iBAAiB,SAAUd,GAAe,GAC7CxE,EAASN,QACXR,EAAOoG,iBAAiB,SAAUV,GAAe,GAS9CjB,CACT,CAOF,CArcW4B,CAAQvG,EAChB,UAFM,SAEN,oB,GCXDwG,EAA2B,CAAC,EAGhC,SAASC,EAAoBC,GAE5B,IAAIC,EAAeH,EAAyBE,GAC5C,QAAqBE,IAAjBD,EACH,OAAOA,EAAaE,QAGrB,IAAIC,EAASN,EAAyBE,GAAY,CAGjDG,QAAS,CAAC,GAOX,OAHAE,EAAoBL,GAAU1B,KAAK8B,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAGpEK,EAAOD,OACf,CCrBAJ,EAAoBO,EAAKF,IACxB,IAAIG,EAASH,GAAUA,EAAOI,WAC7B,IAAOJ,EAAiB,QACxB,IAAM,EAEP,OADAL,EAAoBU,EAAEF,EAAQ,CAAEG,EAAGH,IAC5BA,GCLRR,EAAoBU,EAAI,CAACN,EAASQ,KACjC,IAAI,IAAInB,KAAOmB,EACXZ,EAAoBa,EAAED,EAAYnB,KAASO,EAAoBa,EAAET,EAASX,IAC5EqB,OAAOC,eAAeX,EAASX,EAAK,CAAEuB,YAAY,EAAMC,IAAKL,EAAWnB,MCJ3EO,EAAoBxG,EAAI,WACvB,GAA0B,iBAAf0H,WAAyB,OAAOA,WAC3C,IACC,OAAOxH,MAAQ,IAAIyH,SAAS,cAAb,EAChB,CAAE,MAAOC,GACR,GAAsB,iBAAX3H,OAAqB,OAAOA,MACxC,CACA,CAPuB,GCAxBuG,EAAoBa,EAAI,CAACrB,EAAK6B,IAAUP,OAAOzC,UAAUqB,eAAenB,KAAKiB,EAAK6B,G,yCCK9EC,EAAY,KACZC,EAAS,KACTC,EAAgBzF,SAASC,gBAAgByF,UA4E7C,SAASC,IACP,MAAMC,EAAeC,aAAaC,QAAQ,UAAY,OAZxD,IAAkBC,EACH,WADGA,EAaIrI,OAAOsI,WAAW,gCAAgCC,QAI/C,SAAjBL,EACO,QACgB,SAAhBA,EACA,OAEA,OAIU,SAAjBA,EACO,OACgB,QAAhBA,EACA,QAEA,SA9BoB,SAATG,GAA4B,SAATA,IACzCG,QAAQC,MAAM,2BAA2BJ,yBACzCA,EAAO,QAGT/F,SAASS,KAAK2F,QAAQC,MAAQN,EAC9BF,aAAaS,QAAQ,QAASP,GAC9BG,QAAQK,IAAI,cAAcR,UA0B5B,CAmDA,SAASlC,KART,WAEE,MAAM2C,EAAUxG,SAASyG,uBAAuB,gBAChDpE,MAAMqE,KAAKF,GAASjE,QAASoE,IAC3BA,EAAI7C,iBAAiB,QAAS6B,IAElC,CAGEiB,GA/CF,WAEE,IAAIC,EAA6B,EAC7BC,GAAU,EAEdpJ,OAAOoG,iBAAiB,SAAU,SAAUuB,GAC1CwB,EAA6BnJ,OAAOqJ,QAE/BD,IACHpJ,OAAOwF,sBAAsB,WAzDnC,IAAuB8D,GArDvB,SAAgCA,GAC1BA,EAAY,EACdxB,EAAOjE,UAAUM,IAAI,YAErB2D,EAAOjE,UAAUC,OAAO,WAE5B,EAgDEyF,CADqBD,EA0DDH,GAvGtB,SAAmCG,GAC7BA,EAXmB,GAYrBhH,SAASC,gBAAgBsB,UAAUC,OAAO,oBAEtCwF,EAAYvB,EACdzF,SAASC,gBAAgBsB,UAAUM,IAAI,oBAC9BmF,EAAYvB,GACrBzF,SAASC,gBAAgBsB,UAAUC,OAAO,oBAG9CiE,EAAgBuB,CAClB,CAoCEE,CAA0BF,GAlC5B,SAA6BA,GACT,OAAdzB,IAKa,GAAbyB,EACFzB,EAAU4B,SAAS,EAAG,GAGtB9G,KAAKC,KAAK0G,IACV3G,KAAK+G,MAAMpH,SAASC,gBAAgBS,aAAehD,OAAOqC,aAE1DwF,EAAU4B,SAAS,EAAG5B,EAAU7E,cAGhBV,SAASqH,cAAc,mBAc3C,CAKEC,CAAoBN,GAwDdF,GAAU,CACZ,GAEAA,GAAU,EAEd,GACApJ,OAAO6J,QACT,CA8BEC,GA3BkB,OAAdjC,GAKJ,IAAI,IAAJ,CAAY,cAAe,CACzBrH,QAAQ,EACRuJ,WAAW,EACX5J,SAAU,iBACVI,OAAQ,KACN,IAAIyJ,EAAM9H,WAAW+H,iBAAiB3H,SAASC,iBAAiB2H,UAChE,MAAMC,EAAarC,EAAO7F,wBAC1B,OAAOkI,EAAW1H,IAAM0H,EAAWC,OAAS,IAAMJ,EAAM,IAiB9D,CAcA1H,SAAS8D,iBAAiB,mBAT1B,WACE9D,SAASS,KAAKW,WAAWG,UAAUC,OAAO,SAE1CgE,EAASxF,SAASqH,cAAc,UAChC9B,EAAYvF,SAASqH,cAAc,eAEnCxD,GACF,E","sources":["webpack:///./src/furo/assets/scripts/gumshoe-patched.js","webpack:///webpack/bootstrap","webpack:///webpack/runtime/compat get default export","webpack:///webpack/runtime/define property getters","webpack:///webpack/runtime/global","webpack:///webpack/runtime/hasOwnProperty shorthand","webpack:///./src/furo/assets/scripts/furo.js"],"sourcesContent":["/*!\n * gumshoejs v5.1.2 (patched by @pradyunsg)\n * A simple, framework-agnostic scrollspy script.\n * (c) 2019 Chris Ferdinandi\n * MIT License\n * http://github.com/cferdinandi/gumshoe\n */\n\n(function (root, factory) {\n if (typeof define === \"function\" && define.amd) {\n define([], function () {\n return factory(root);\n });\n } else if (typeof exports === \"object\") {\n module.exports = factory(root);\n } else {\n root.Gumshoe = factory(root);\n }\n})(\n typeof global !== \"undefined\"\n ? global\n : typeof window !== \"undefined\"\n ? window\n : this,\n function (window) {\n \"use strict\";\n\n //\n // Defaults\n //\n\n var defaults = {\n // Active classes\n navClass: \"active\",\n contentClass: \"active\",\n\n // Nested navigation\n nested: false,\n nestedClass: \"active\",\n\n // Offset & reflow\n offset: 0,\n reflow: false,\n\n // Event support\n events: true,\n };\n\n //\n // Methods\n //\n\n /**\n * Merge two or more objects together.\n * @param {Object} objects The objects to merge together\n * @returns {Object} Merged values of defaults and options\n */\n var extend = function () {\n var merged = {};\n Array.prototype.forEach.call(arguments, function (obj) {\n for (var key in obj) {\n if (!obj.hasOwnProperty(key)) return;\n merged[key] = obj[key];\n }\n });\n return merged;\n };\n\n /**\n * Emit a custom event\n * @param {String} type The event type\n * @param {Node} elem The element to attach the event to\n * @param {Object} detail Any details to pass along with the event\n */\n var emitEvent = function (type, elem, detail) {\n // Make sure events are enabled\n if (!detail.settings.events) return;\n\n // Create a new event\n var event = new CustomEvent(type, {\n bubbles: true,\n cancelable: true,\n detail: detail,\n });\n\n // Dispatch the event\n elem.dispatchEvent(event);\n };\n\n /**\n * Get an element's distance from the top of the Document.\n * @param {Node} elem The element\n * @return {Number} Distance from the top in pixels\n */\n var getOffsetTop = function (elem) {\n var location = 0;\n if (elem.offsetParent) {\n while (elem) {\n location += elem.offsetTop;\n elem = elem.offsetParent;\n }\n }\n return location >= 0 ? location : 0;\n };\n\n /**\n * Sort content from first to last in the DOM\n * @param {Array} contents The content areas\n */\n var sortContents = function (contents) {\n if (contents) {\n contents.sort(function (item1, item2) {\n var offset1 = getOffsetTop(item1.content);\n var offset2 = getOffsetTop(item2.content);\n if (offset1 < offset2) return -1;\n return 1;\n });\n }\n };\n\n /**\n * Get the offset to use for calculating position\n * @param {Object} settings The settings for this instantiation\n * @return {Float} The number of pixels to offset the calculations\n */\n var getOffset = function (settings) {\n // if the offset is a function run it\n if (typeof settings.offset === \"function\") {\n return parseFloat(settings.offset());\n }\n\n // Otherwise, return it as-is\n return parseFloat(settings.offset);\n };\n\n /**\n * Get the document element's height\n * @private\n * @returns {Number}\n */\n var getDocumentHeight = function () {\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight,\n );\n };\n\n /**\n * Determine if an element is in view\n * @param {Node} elem The element\n * @param {Object} settings The settings for this instantiation\n * @param {Boolean} bottom If true, check if element is above bottom of viewport instead\n * @return {Boolean} Returns true if element is in the viewport\n */\n var isInView = function (elem, settings, bottom) {\n var bounds = elem.getBoundingClientRect();\n var offset = getOffset(settings);\n if (bottom) {\n return (\n parseInt(bounds.bottom, 10) <\n (window.innerHeight || document.documentElement.clientHeight)\n );\n }\n return parseInt(bounds.top, 10) <= offset;\n };\n\n /**\n * Check if at the bottom of the viewport\n * @return {Boolean} If true, page is at the bottom of the viewport\n */\n var isAtBottom = function () {\n if (\n Math.ceil(window.innerHeight + window.pageYOffset) >=\n getDocumentHeight()\n )\n return true;\n return false;\n };\n\n /**\n * Check if the last item should be used (even if not at the top of the page)\n * @param {Object} item The last item\n * @param {Object} settings The settings for this instantiation\n * @return {Boolean} If true, use the last item\n */\n var useLastItem = function (item, settings) {\n if (isAtBottom() && isInView(item.content, settings, true)) return true;\n return false;\n };\n\n /**\n * Get the active content\n * @param {Array} contents The content areas\n * @param {Object} settings The settings for this instantiation\n * @return {Object} The content area and matching navigation link\n */\n var getActive = function (contents, settings) {\n var last = contents[contents.length - 1];\n if (useLastItem(last, settings)) return last;\n for (var i = contents.length - 1; i >= 0; i--) {\n if (isInView(contents[i].content, settings)) return contents[i];\n }\n };\n\n /**\n * Deactivate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var deactivateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested || !nav.parentNode) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Remove the active class\n li.classList.remove(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n deactivateNested(li, settings);\n };\n\n /**\n * Deactivate a nav and content area\n * @param {Object} items The nav item and content to deactivate\n * @param {Object} settings The settings for this instantiation\n */\n var deactivate = function (items, settings) {\n // Make sure there are items to deactivate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Remove the active class from the nav and content\n li.classList.remove(settings.navClass);\n items.content.classList.remove(settings.contentClass);\n\n // Deactivate any parent navs in a nested navigation\n deactivateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeDeactivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Activate parent navs in a nested navigation\n * @param {Node} nav The starting navigation element\n * @param {Object} settings The settings for this instantiation\n */\n var activateNested = function (nav, settings) {\n // If nesting isn't activated, bail\n if (!settings.nested) return;\n\n // Get the parent navigation\n var li = nav.parentNode.closest(\"li\");\n if (!li) return;\n\n // Add the active class\n li.classList.add(settings.nestedClass);\n\n // Apply recursively to any parent navigation elements\n activateNested(li, settings);\n };\n\n /**\n * Activate a nav and content area\n * @param {Object} items The nav item and content to activate\n * @param {Object} settings The settings for this instantiation\n */\n var activate = function (items, settings) {\n // Make sure there are items to activate\n if (!items) return;\n\n // Get the parent list item\n var li = items.nav.closest(\"li\");\n if (!li) return;\n\n // Add the active class to the nav and content\n li.classList.add(settings.navClass);\n items.content.classList.add(settings.contentClass);\n\n // Activate any parent navs in a nested navigation\n activateNested(li, settings);\n\n // Emit a custom event\n emitEvent(\"gumshoeActivate\", li, {\n link: items.nav,\n content: items.content,\n settings: settings,\n });\n };\n\n /**\n * Create the Constructor object\n * @param {String} selector The selector to use for navigation items\n * @param {Object} options User options and settings\n */\n var Constructor = function (selector, options) {\n //\n // Variables\n //\n\n var publicAPIs = {};\n var navItems, contents, current, timeout, settings;\n\n //\n // Methods\n //\n\n /**\n * Set variables from DOM elements\n */\n publicAPIs.setup = function () {\n // Get all nav items\n navItems = document.querySelectorAll(selector);\n\n // Create contents array\n contents = [];\n\n // Loop through each item, get it's matching content, and push to the array\n Array.prototype.forEach.call(navItems, function (item) {\n // Get the content for the nav item\n var content = document.getElementById(\n decodeURIComponent(item.hash.substr(1)),\n );\n if (!content) return;\n\n // Push to the contents array\n contents.push({\n nav: item,\n content: content,\n });\n });\n\n // Sort contents by the order they appear in the DOM\n sortContents(contents);\n };\n\n /**\n * Detect which content is currently active\n */\n publicAPIs.detect = function () {\n // Get the active content\n var active = getActive(contents, settings);\n\n // if there's no active content, deactivate and bail\n if (!active) {\n if (current) {\n deactivate(current, settings);\n current = null;\n }\n return;\n }\n\n // If the active content is the one currently active, do nothing\n if (current && active.content === current.content) return;\n\n // Deactivate the current content and activate the new content\n deactivate(current, settings);\n activate(active, settings);\n\n // Update the currently active content\n current = active;\n };\n\n /**\n * Detect the active content on scroll\n * Debounced for performance\n */\n var scrollHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(publicAPIs.detect);\n };\n\n /**\n * Update content sorting on resize\n * Debounced for performance\n */\n var resizeHandler = function (event) {\n // If there's a timer, cancel it\n if (timeout) {\n window.cancelAnimationFrame(timeout);\n }\n\n // Setup debounce callback\n timeout = window.requestAnimationFrame(function () {\n sortContents(contents);\n publicAPIs.detect();\n });\n };\n\n /**\n * Destroy the current instantiation\n */\n publicAPIs.destroy = function () {\n // Undo DOM changes\n if (current) {\n deactivate(current, settings);\n }\n\n // Remove event listeners\n window.removeEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.removeEventListener(\"resize\", resizeHandler, false);\n }\n\n // Reset variables\n contents = null;\n navItems = null;\n current = null;\n timeout = null;\n settings = null;\n };\n\n /**\n * Initialize the current instantiation\n */\n var init = function () {\n // Merge user options into defaults\n settings = extend(defaults, options || {});\n\n // Setup variables based on the current DOM\n publicAPIs.setup();\n\n // Find the currently active content\n publicAPIs.detect();\n\n // Setup event listeners\n window.addEventListener(\"scroll\", scrollHandler, false);\n if (settings.reflow) {\n window.addEventListener(\"resize\", resizeHandler, false);\n }\n };\n\n //\n // Initialize and return the public APIs\n //\n\n init();\n return publicAPIs;\n };\n\n //\n // Return the Constructor\n //\n\n return Constructor;\n },\n);\n","// The module cache\nvar __webpack_module_cache__ = {};\n\n// The require function\nfunction __webpack_require__(moduleId) {\n\t// Check if module is in cache\n\tvar cachedModule = __webpack_module_cache__[moduleId];\n\tif (cachedModule !== undefined) {\n\t\treturn cachedModule.exports;\n\t}\n\t// Create a new module (and put it into the cache)\n\tvar module = __webpack_module_cache__[moduleId] = {\n\t\t// no module.id needed\n\t\t// no module.loaded needed\n\t\texports: {}\n\t};\n\n\t// Execute the module function\n\t__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n\t// Return the exports of the module\n\treturn module.exports;\n}\n\n","// getDefaultExport function for compatibility with non-harmony modules\n__webpack_require__.n = (module) => {\n\tvar getter = module && module.__esModule ?\n\t\t() => (module['default']) :\n\t\t() => (module);\n\t__webpack_require__.d(getter, { a: getter });\n\treturn getter;\n};","// define getter functions for harmony exports\n__webpack_require__.d = (exports, definition) => {\n\tfor(var key in definition) {\n\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n\t\t}\n\t}\n};","__webpack_require__.g = (function() {\n\tif (typeof globalThis === 'object') return globalThis;\n\ttry {\n\t\treturn this || new Function('return this')();\n\t} catch (e) {\n\t\tif (typeof window === 'object') return window;\n\t}\n})();","__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))","import Gumshoe from \"./gumshoe-patched.js\";\n\n////////////////////////////////////////////////////////////////////////////////\n// Scroll Handling\n////////////////////////////////////////////////////////////////////////////////\nvar tocScroll = null;\nvar header = null;\nvar lastScrollTop = document.documentElement.scrollTop;\nconst GO_TO_TOP_OFFSET = 64;\n\nfunction scrollHandlerForHeader(positionY) {\n if (positionY > 0) {\n header.classList.add(\"scrolled\");\n } else {\n header.classList.remove(\"scrolled\");\n }\n}\n\nfunction scrollHandlerForBackToTop(positionY) {\n if (positionY < GO_TO_TOP_OFFSET) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n } else {\n if (positionY < lastScrollTop) {\n document.documentElement.classList.add(\"show-back-to-top\");\n } else if (positionY > lastScrollTop) {\n document.documentElement.classList.remove(\"show-back-to-top\");\n }\n }\n lastScrollTop = positionY;\n}\n\nfunction scrollHandlerForTOC(positionY) {\n if (tocScroll === null) {\n return;\n }\n\n // top of page.\n if (positionY == 0) {\n tocScroll.scrollTo(0, 0);\n } else if (\n // bottom of page.\n Math.ceil(positionY) >=\n Math.floor(document.documentElement.scrollHeight - window.innerHeight)\n ) {\n tocScroll.scrollTo(0, tocScroll.scrollHeight);\n } else {\n // somewhere in the middle.\n const current = document.querySelector(\".scroll-current\");\n if (current == null) {\n return;\n }\n\n // https://github.com/pypa/pip/issues/9159 This breaks scroll behaviours.\n // // scroll the currently \"active\" heading in toc, into view.\n // const rect = current.getBoundingClientRect();\n // if (0 > rect.top) {\n // current.scrollIntoView(true); // the argument is \"alignTop\"\n // } else if (rect.bottom > window.innerHeight) {\n // current.scrollIntoView(false);\n // }\n }\n}\n\nfunction scrollHandler(positionY) {\n scrollHandlerForHeader(positionY);\n scrollHandlerForBackToTop(positionY);\n scrollHandlerForTOC(positionY);\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Theme Toggle\n////////////////////////////////////////////////////////////////////////////////\nfunction setTheme(mode) {\n if (mode !== \"light\" && mode !== \"dark\" && mode !== \"auto\") {\n console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`);\n mode = \"auto\";\n }\n\n document.body.dataset.theme = mode;\n localStorage.setItem(\"theme\", mode);\n console.log(`Changed to ${mode} mode.`);\n}\n\nfunction cycleThemeOnce() {\n const currentTheme = localStorage.getItem(\"theme\") || \"auto\";\n const prefersDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n\n if (prefersDark) {\n // Auto (dark) -> Light -> Dark\n if (currentTheme === \"auto\") {\n setTheme(\"light\");\n } else if (currentTheme == \"light\") {\n setTheme(\"dark\");\n } else {\n setTheme(\"auto\");\n }\n } else {\n // Auto (light) -> Dark -> Light\n if (currentTheme === \"auto\") {\n setTheme(\"dark\");\n } else if (currentTheme == \"dark\") {\n setTheme(\"light\");\n } else {\n setTheme(\"auto\");\n }\n }\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Setup\n////////////////////////////////////////////////////////////////////////////////\nfunction setupScrollHandler() {\n // Taken from https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event\n let last_known_scroll_position = 0;\n let ticking = false;\n\n window.addEventListener(\"scroll\", function (e) {\n last_known_scroll_position = window.scrollY;\n\n if (!ticking) {\n window.requestAnimationFrame(function () {\n scrollHandler(last_known_scroll_position);\n ticking = false;\n });\n\n ticking = true;\n }\n });\n window.scroll();\n}\n\nfunction setupScrollSpy() {\n if (tocScroll === null) {\n return;\n }\n\n // Scrollspy -- highlight table on contents, based on scroll\n new Gumshoe(\".toc-tree a\", {\n reflow: true,\n recursive: true,\n navClass: \"scroll-current\",\n offset: () => {\n let rem = parseFloat(getComputedStyle(document.documentElement).fontSize);\n const headerRect = header.getBoundingClientRect();\n return headerRect.top + headerRect.height + 2.5 * rem + 1;\n },\n });\n}\n\nfunction setupTheme() {\n // Attach event handlers for toggling themes\n const buttons = document.getElementsByClassName(\"theme-toggle\");\n Array.from(buttons).forEach((btn) => {\n btn.addEventListener(\"click\", cycleThemeOnce);\n });\n}\n\nfunction setup() {\n setupTheme();\n setupScrollHandler();\n setupScrollSpy();\n}\n\n////////////////////////////////////////////////////////////////////////////////\n// Main entrypoint\n////////////////////////////////////////////////////////////////////////////////\nfunction main() {\n document.body.parentNode.classList.remove(\"no-js\");\n\n header = document.querySelector(\"header\");\n tocScroll = document.querySelector(\".toc-scroll\");\n\n setup();\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", main);\n"],"names":["root","g","window","this","defaults","navClass","contentClass","nested","nestedClass","offset","reflow","events","emitEvent","type","elem","detail","settings","event","CustomEvent","bubbles","cancelable","dispatchEvent","getOffsetTop","location","offsetParent","offsetTop","sortContents","contents","sort","item1","item2","content","isInView","bottom","bounds","getBoundingClientRect","parseFloat","getOffset","parseInt","innerHeight","document","documentElement","clientHeight","top","isAtBottom","Math","ceil","pageYOffset","max","body","scrollHeight","offsetHeight","getActive","last","length","item","useLastItem","i","deactivateNested","nav","parentNode","li","closest","classList","remove","deactivate","items","link","activateNested","add","selector","options","navItems","current","timeout","publicAPIs","querySelectorAll","Array","prototype","forEach","call","getElementById","decodeURIComponent","hash","substr","push","active","activate","scrollHandler","cancelAnimationFrame","requestAnimationFrame","detect","resizeHandler","destroy","removeEventListener","merged","arguments","obj","key","hasOwnProperty","extend","setup","addEventListener","factory","__webpack_module_cache__","__webpack_require__","moduleId","cachedModule","undefined","exports","module","__webpack_modules__","n","getter","__esModule","d","a","definition","o","Object","defineProperty","enumerable","get","globalThis","Function","e","prop","tocScroll","header","lastScrollTop","scrollTop","cycleThemeOnce","currentTheme","localStorage","getItem","mode","matchMedia","matches","console","error","dataset","theme","setItem","log","buttons","getElementsByClassName","from","btn","setupTheme","last_known_scroll_position","ticking","scrollY","positionY","scrollHandlerForHeader","scrollHandlerForBackToTop","scrollTo","floor","querySelector","scrollHandlerForTOC","scroll","setupScrollHandler","recursive","rem","getComputedStyle","fontSize","headerRect","height"],"sourceRoot":""} \ No newline at end of file diff --git a/docs/site/_static/searchtools.js b/docs/site/_static/searchtools.js new file mode 100644 index 0000000..e29b1c7 --- /dev/null +++ b/docs/site/_static/searchtools.js @@ -0,0 +1,693 @@ +/* + * Sphinx JavaScript utilities for the full-text search. + */ +"use strict"; + +/** + * Simple result scoring code. + */ +if (typeof Scorer === "undefined") { + var Scorer = { + // Implement the following function to further tweak the score for each result + // The function takes a result array [docname, title, anchor, descr, score, filename] + // and returns the new score. + /* + score: result => { + const [docname, title, anchor, descr, score, filename, kind] = result + return score + }, + */ + + // query matches the full name of an object + objNameMatch: 11, + // or matches in the last dotted part of the object name + objPartialMatch: 6, + // Additive scores depending on the priority of the object + objPrio: { + 0: 15, // used to be importantResults + 1: 5, // used to be objectResults + 2: -5, // used to be unimportantResults + }, + // Used when the priority is not in the mapping. + objPrioDefault: 0, + + // query found in title + title: 15, + partialTitle: 7, + // query found in terms + term: 5, + partialTerm: 2, + }; +} + +// Global search result kind enum, used by themes to style search results. +// prettier-ignore +class SearchResultKind { + static get index() { return "index"; } + static get object() { return "object"; } + static get text() { return "text"; } + static get title() { return "title"; } +} + +const _removeChildren = (element) => { + while (element && element.lastChild) element.removeChild(element.lastChild); +}; + +/** + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + */ +const _escapeRegExp = (string) => + string.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string + +const _escapeHTML = (text) => { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}; + +const _displayItem = (item, searchTerms, highlightTerms) => { + const docBuilder = DOCUMENTATION_OPTIONS.BUILDER; + const docFileSuffix = DOCUMENTATION_OPTIONS.FILE_SUFFIX; + const docLinkSuffix = DOCUMENTATION_OPTIONS.LINK_SUFFIX; + const showSearchSummary = DOCUMENTATION_OPTIONS.SHOW_SEARCH_SUMMARY; + const contentRoot = document.documentElement.dataset.content_root; + + const [docName, title, anchor, descr, score, _filename, kind] = item; + + let listItem = document.createElement("li"); + // Add a class representing the item's type: + // can be used by a theme's CSS selector for styling + // See SearchResultKind for the class names. + listItem.classList.add(`kind-${kind}`); + let requestUrl; + let linkUrl; + if (docBuilder === "dirhtml") { + // dirhtml builder + let dirname = docName + "/"; + if (dirname.match(/\/index\/$/)) + dirname = dirname.substring(0, dirname.length - 6); + else if (dirname === "index/") dirname = ""; + requestUrl = contentRoot + dirname; + linkUrl = requestUrl; + } else { + // normal html builders + requestUrl = contentRoot + docName + docFileSuffix; + linkUrl = docName + docLinkSuffix; + } + let linkEl = listItem.appendChild(document.createElement("a")); + linkEl.href = linkUrl + anchor; + linkEl.dataset.score = score; + linkEl.innerHTML = _escapeHTML(title); + if (descr) { + listItem.appendChild(document.createElement("span")).innerHTML = + ` (${_escapeHTML(descr)})`; + // highlight search terms in the description + if (SPHINX_HIGHLIGHT_ENABLED) + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + highlightTerms.forEach((term) => + _highlightText(listItem, term, "highlighted"), + ); + } else if (showSearchSummary) + fetch(requestUrl) + .then((responseData) => responseData.text()) + .then((data) => { + if (data) + listItem.appendChild( + Search.makeSearchSummary(data, searchTerms, anchor), + ); + // highlight search terms in the summary + if (SPHINX_HIGHLIGHT_ENABLED) + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + highlightTerms.forEach((term) => + _highlightText(listItem, term, "highlighted"), + ); + }); + Search.output.appendChild(listItem); +}; +const _finishSearch = (resultCount) => { + Search.stopPulse(); + Search.title.innerText = _("Search Results"); + if (!resultCount) + Search.status.innerText = Documentation.gettext( + "Your search did not match any documents. Please make sure that all words are spelled correctly and that you've selected enough categories.", + ); + else + Search.status.innerText = Documentation.ngettext( + "Search finished, found one page matching the search query.", + "Search finished, found ${resultCount} pages matching the search query.", + resultCount, + ).replace("${resultCount}", resultCount); +}; +const _displayNextItem = ( + results, + resultCount, + searchTerms, + highlightTerms, +) => { + // results left, load the summary and display it + // this is intended to be dynamic (don't sub resultsCount) + if (results.length) { + _displayItem(results.pop(), searchTerms, highlightTerms); + setTimeout( + () => _displayNextItem(results, resultCount, searchTerms, highlightTerms), + 5, + ); + } + // search finished, update title and status message + else _finishSearch(resultCount); +}; +// Helper function used by query() to order search results. +// Each input is an array of [docname, title, anchor, descr, score, filename, kind]. +// Order the results by score (in opposite order of appearance, since the +// `_displayNextItem` function uses pop() to retrieve items) and then alphabetically. +const _orderResultsByScoreThenName = (a, b) => { + const leftScore = a[4]; + const rightScore = b[4]; + if (leftScore === rightScore) { + // same score: sort alphabetically + const leftTitle = a[1].toLowerCase(); + const rightTitle = b[1].toLowerCase(); + if (leftTitle === rightTitle) return 0; + return leftTitle > rightTitle ? -1 : 1; // inverted is intentional + } + return leftScore > rightScore ? 1 : -1; +}; + +/** + * Default splitQuery function. Can be overridden in ``sphinx.search`` with a + * custom function per language. + * + * The regular expression works by splitting the string on consecutive characters + * that are not Unicode letters, numbers, underscores, or emoji characters. + * This is the same as ``\W+`` in Python, preserving the surrogate pair area. + */ +if (typeof splitQuery === "undefined") { + var splitQuery = (query) => + query + .split(/[^\p{Letter}\p{Number}_\p{Emoji_Presentation}]+/gu) + .filter((term) => term); // remove remaining empty strings +} + +/** + * Search Module + */ +const Search = { + _index: null, + _queued_query: null, + _pulse_status: -1, + + htmlToText: (htmlString, anchor) => { + const htmlElement = new DOMParser().parseFromString( + htmlString, + "text/html", + ); + for (const removalQuery of [".headerlink", "script", "style"]) { + htmlElement.querySelectorAll(removalQuery).forEach((el) => { + el.remove(); + }); + } + if (anchor) { + const anchorContent = htmlElement.querySelector( + `[role="main"] ${anchor}`, + ); + if (anchorContent) return anchorContent.textContent; + + console.warn( + `Anchored content block not found. Sphinx search tries to obtain it via DOM query '[role=main] ${anchor}'. Check your theme or template.`, + ); + } + + // if anchor not specified or not found, fall back to main content + const docContent = htmlElement.querySelector('[role="main"]'); + if (docContent) return docContent.textContent; + + console.warn( + "Content block not found. Sphinx search tries to obtain it via DOM query '[role=main]'. Check your theme or template.", + ); + return ""; + }, + + init: () => { + const query = new URLSearchParams(window.location.search).get("q"); + document + .querySelectorAll('input[name="q"]') + .forEach((el) => (el.value = query)); + if (query) Search.performSearch(query); + }, + + loadIndex: (url) => + (document.body.appendChild(document.createElement("script")).src = url), + + setIndex: (index) => { + Search._index = index; + if (Search._queued_query !== null) { + const query = Search._queued_query; + Search._queued_query = null; + Search.query(query); + } + }, + + hasIndex: () => Search._index !== null, + + deferQuery: (query) => (Search._queued_query = query), + + stopPulse: () => (Search._pulse_status = -1), + + startPulse: () => { + if (Search._pulse_status >= 0) return; + + const pulse = () => { + Search._pulse_status = (Search._pulse_status + 1) % 4; + Search.dots.innerText = ".".repeat(Search._pulse_status); + if (Search._pulse_status >= 0) window.setTimeout(pulse, 500); + }; + pulse(); + }, + + /** + * perform a search for something (or wait until index is loaded) + */ + performSearch: (query) => { + // create the required interface elements + const searchText = document.createElement("h2"); + searchText.textContent = _("Searching"); + const searchSummary = document.createElement("p"); + searchSummary.classList.add("search-summary"); + searchSummary.innerText = ""; + const searchList = document.createElement("ul"); + searchList.setAttribute("role", "list"); + searchList.classList.add("search"); + + const out = document.getElementById("search-results"); + Search.title = out.appendChild(searchText); + Search.dots = Search.title.appendChild(document.createElement("span")); + Search.status = out.appendChild(searchSummary); + Search.output = out.appendChild(searchList); + + const searchProgress = document.getElementById("search-progress"); + // Some themes don't use the search progress node + if (searchProgress) { + searchProgress.innerText = _("Preparing search..."); + } + Search.startPulse(); + + // index already loaded, the browser was quick! + if (Search.hasIndex()) Search.query(query); + else Search.deferQuery(query); + }, + + _parseQuery: (query) => { + // stem the search terms and add them to the correct list + const stemmer = new Stemmer(); + const searchTerms = new Set(); + const excludedTerms = new Set(); + const highlightTerms = new Set(); + const objectTerms = new Set(splitQuery(query.toLowerCase().trim())); + splitQuery(query.trim()).forEach((queryTerm) => { + const queryTermLower = queryTerm.toLowerCase(); + + // maybe skip this "word" + // stopwords set is from language_data.js + if (stopwords.has(queryTermLower) || queryTerm.match(/^\d+$/)) return; + + // stem the word + let word = stemmer.stemWord(queryTermLower); + // select the correct list + if (word[0] === "-") excludedTerms.add(word.substr(1)); + else { + searchTerms.add(word); + highlightTerms.add(queryTermLower); + } + }); + + if (SPHINX_HIGHLIGHT_ENABLED) { + // SPHINX_HIGHLIGHT_ENABLED is set in sphinx_highlight.js + localStorage.setItem( + "sphinx_highlight_terms", + [...highlightTerms].join(" "), + ); + } + + // console.debug("SEARCH: searching for:"); + // console.info("required: ", [...searchTerms]); + // console.info("excluded: ", [...excludedTerms]); + + return [query, searchTerms, excludedTerms, highlightTerms, objectTerms]; + }, + + /** + * execute search (requires search index to be loaded) + */ + _performSearch: ( + query, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + const allTitles = Search._index.alltitles; + const indexEntries = Search._index.indexentries; + + // Collect multiple result groups to be sorted separately and then ordered. + // Each is an array of [docname, title, anchor, descr, score, filename, kind]. + const normalResults = []; + const nonMainIndexResults = []; + + _removeChildren(document.getElementById("search-progress")); + + const queryLower = query.toLowerCase().trim(); + for (const [title, foundTitles] of Object.entries(allTitles)) { + if ( + title.toLowerCase().trim().includes(queryLower) + && queryLower.length >= title.length / 2 + ) { + for (const [file, id] of foundTitles) { + const score = Math.round( + (Scorer.title * queryLower.length) / title.length, + ); + const boost = titles[file] === title ? 1 : 0; // add a boost for document titles + normalResults.push([ + docNames[file], + titles[file] !== title ? `${titles[file]} > ${title}` : title, + id !== null ? "#" + id : "", + null, + score + boost, + filenames[file], + SearchResultKind.title, + ]); + } + } + } + + // search for explicit entries in index directives + for (const [entry, foundEntries] of Object.entries(indexEntries)) { + if (entry.includes(queryLower) && queryLower.length >= entry.length / 2) { + for (const [file, id, isMain] of foundEntries) { + const score = Math.round((100 * queryLower.length) / entry.length); + const result = [ + docNames[file], + titles[file], + id ? "#" + id : "", + null, + score, + filenames[file], + SearchResultKind.index, + ]; + if (isMain) { + normalResults.push(result); + } else { + nonMainIndexResults.push(result); + } + } + } + } + + // lookup as object + objectTerms.forEach((term) => + normalResults.push(...Search.performObjectSearch(term, objectTerms)), + ); + + // lookup as search terms in fulltext + normalResults.push( + ...Search.performTermsSearch(searchTerms, excludedTerms), + ); + + // let the scorer override scores with a custom scoring function + if (Scorer.score) { + normalResults.forEach((item) => (item[4] = Scorer.score(item))); + nonMainIndexResults.forEach((item) => (item[4] = Scorer.score(item))); + } + + // Sort each group of results by score and then alphabetically by name. + normalResults.sort(_orderResultsByScoreThenName); + nonMainIndexResults.sort(_orderResultsByScoreThenName); + + // Combine the result groups in (reverse) order. + // Non-main index entries are typically arbitrary cross-references, + // so display them after other results. + let results = [...nonMainIndexResults, ...normalResults]; + + // remove duplicate search results + // note the reversing of results, so that in the case of duplicates, the highest-scoring entry is kept + let seen = new Set(); + results = results.reverse().reduce((acc, result) => { + let resultStr = result + .slice(0, 4) + .concat([result[5]]) + .map((v) => String(v)) + .join(","); + if (!seen.has(resultStr)) { + acc.push(result); + seen.add(resultStr); + } + return acc; + }, []); + + return results.reverse(); + }, + + query: (query) => { + const [ + searchQuery, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ] = Search._parseQuery(query); + const results = Search._performSearch( + searchQuery, + searchTerms, + excludedTerms, + highlightTerms, + objectTerms, + ); + + // for debugging + //Search.lastresults = results.slice(); // a copy + // console.info("search results:", Search.lastresults); + + // print the results + _displayNextItem(results, results.length, searchTerms, highlightTerms); + }, + + /** + * search for object names + */ + performObjectSearch: (object, objectTerms) => { + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const objects = Search._index.objects; + const objNames = Search._index.objnames; + const titles = Search._index.titles; + + const results = []; + + const objectSearchCallback = (prefix, match) => { + const name = match[4]; + const fullname = (prefix ? prefix + "." : "") + name; + const fullnameLower = fullname.toLowerCase(); + if (fullnameLower.indexOf(object) < 0) return; + + let score = 0; + const parts = fullnameLower.split("."); + + // check for different match types: exact matches of full name or + // "last name" (i.e. last dotted part) + if (fullnameLower === object || parts.slice(-1)[0] === object) + score += Scorer.objNameMatch; + else if (parts.slice(-1)[0].indexOf(object) > -1) + score += Scorer.objPartialMatch; // matches in last name + + const objName = objNames[match[1]][2]; + const title = titles[match[0]]; + + // If more than one term searched for, we require other words to be + // found in the name/title/description + const otherTerms = new Set(objectTerms); + otherTerms.delete(object); + if (otherTerms.size > 0) { + const haystack = `${prefix} ${name} ${objName} ${title}`.toLowerCase(); + if ( + [...otherTerms].some((otherTerm) => haystack.indexOf(otherTerm) < 0) + ) + return; + } + + let anchor = match[3]; + if (anchor === "") anchor = fullname; + else if (anchor === "-") anchor = objNames[match[1]][1] + "-" + fullname; + + const descr = objName + _(", in ") + title; + + // add custom score for some objects according to scorer + if (Scorer.objPrio.hasOwnProperty(match[2])) + score += Scorer.objPrio[match[2]]; + else score += Scorer.objPrioDefault; + + results.push([ + docNames[match[0]], + fullname, + "#" + anchor, + descr, + score, + filenames[match[0]], + SearchResultKind.object, + ]); + }; + Object.keys(objects).forEach((prefix) => + objects[prefix].forEach((array) => objectSearchCallback(prefix, array)), + ); + return results; + }, + + /** + * search for full-text terms in the index + */ + performTermsSearch: (searchTerms, excludedTerms) => { + // prepare search + const terms = Search._index.terms; + const titleTerms = Search._index.titleterms; + const filenames = Search._index.filenames; + const docNames = Search._index.docnames; + const titles = Search._index.titles; + + const scoreMap = new Map(); + const fileMap = new Map(); + + // perform the search on the required terms + searchTerms.forEach((word) => { + const files = []; + // find documents, if any, containing the query word in their text/title term indices + // use Object.hasOwnProperty to avoid mismatching against prototype properties + const arr = [ + { + files: terms.hasOwnProperty(word) ? terms[word] : undefined, + score: Scorer.term, + }, + { + files: titleTerms.hasOwnProperty(word) ? titleTerms[word] : undefined, + score: Scorer.title, + }, + ]; + // add support for partial matches + if (word.length > 2) { + const escapedWord = _escapeRegExp(word); + if (!terms.hasOwnProperty(word)) { + Object.keys(terms).forEach((term) => { + if (term.match(escapedWord)) + arr.push({ files: terms[term], score: Scorer.partialTerm }); + }); + } + if (!titleTerms.hasOwnProperty(word)) { + Object.keys(titleTerms).forEach((term) => { + if (term.match(escapedWord)) + arr.push({ files: titleTerms[term], score: Scorer.partialTitle }); + }); + } + } + + // no match but word was a required one + if (arr.every((record) => record.files === undefined)) return; + + // found search word in contents + arr.forEach((record) => { + if (record.files === undefined) return; + + let recordFiles = record.files; + if (recordFiles.length === undefined) recordFiles = [recordFiles]; + files.push(...recordFiles); + + // set score for the word in each file + recordFiles.forEach((file) => { + if (!scoreMap.has(file)) scoreMap.set(file, new Map()); + const fileScores = scoreMap.get(file); + fileScores.set(word, record.score); + }); + }); + + // create the mapping + files.forEach((file) => { + if (!fileMap.has(file)) fileMap.set(file, [word]); + else if (fileMap.get(file).indexOf(word) === -1) + fileMap.get(file).push(word); + }); + }); + + // now check if the files don't contain excluded terms + const results = []; + for (const [file, wordList] of fileMap) { + // check if all requirements are matched + + // as search terms with length < 3 are discarded + const filteredTermCount = [...searchTerms].filter( + (term) => term.length > 2, + ).length; + if ( + wordList.length !== searchTerms.size + && wordList.length !== filteredTermCount + ) + continue; + + // ensure that none of the excluded terms is in the search result + if ( + [...excludedTerms].some( + (term) => + terms[term] === file + || titleTerms[term] === file + || (terms[term] || []).includes(file) + || (titleTerms[term] || []).includes(file), + ) + ) + break; + + // select one (max) score for the file. + const score = Math.max(...wordList.map((w) => scoreMap.get(file).get(w))); + // add result to the result list + results.push([ + docNames[file], + titles[file], + "", + null, + score, + filenames[file], + SearchResultKind.text, + ]); + } + return results; + }, + + /** + * helper function to return a node containing the + * search summary for a given text. keywords is a list + * of stemmed words. + */ + makeSearchSummary: (htmlText, keywords, anchor) => { + const text = Search.htmlToText(htmlText, anchor); + if (text === "") return null; + + const textLower = text.toLowerCase(); + const actualStartPosition = [...keywords] + .map((k) => textLower.indexOf(k.toLowerCase())) + .filter((i) => i > -1) + .slice(-1)[0]; + const startWithContext = Math.max(actualStartPosition - 120, 0); + + const top = startWithContext === 0 ? "" : "..."; + const tail = startWithContext + 240 < text.length ? "..." : ""; + + let summary = document.createElement("p"); + summary.classList.add("context"); + summary.textContent = + top + text.substr(startWithContext, 240).trim() + tail; + + return summary; + }, +}; + +_ready(Search.init); diff --git a/docs/site/_static/skeleton.css b/docs/site/_static/skeleton.css new file mode 100644 index 0000000..467c878 --- /dev/null +++ b/docs/site/_static/skeleton.css @@ -0,0 +1,296 @@ +/* Some sane resets. */ +html { + height: 100%; +} + +body { + margin: 0; + min-height: 100%; +} + +/* All the flexbox magic! */ +body, +.sb-announcement, +.sb-content, +.sb-main, +.sb-container, +.sb-container__inner, +.sb-article-container, +.sb-footer-content, +.sb-header, +.sb-header-secondary, +.sb-footer { + display: flex; +} + +/* These order things vertically */ +body, +.sb-main, +.sb-article-container { + flex-direction: column; +} + +/* Put elements in the center */ +.sb-header, +.sb-header-secondary, +.sb-container, +.sb-content, +.sb-footer, +.sb-footer-content { + justify-content: center; +} +/* Put elements at the ends */ +.sb-article-container { + justify-content: space-between; +} + +/* These elements grow. */ +.sb-main, +.sb-content, +.sb-container, +article { + flex-grow: 1; +} + +/* Because padding making this wider is not fun */ +article { + box-sizing: border-box; +} + +/* The announcements element should never be wider than the page. */ +.sb-announcement { + max-width: 100%; +} + +.sb-sidebar-primary, +.sb-sidebar-secondary { + flex-shrink: 0; + width: 17rem; +} + +.sb-announcement__inner { + justify-content: center; + + box-sizing: border-box; + height: 3rem; + + overflow-x: auto; + white-space: nowrap; +} + +/* Sidebars, with checkbox-based toggle */ +.sb-sidebar-primary, +.sb-sidebar-secondary { + position: fixed; + height: 100%; + top: 0; +} + +.sb-sidebar-primary { + left: -17rem; + transition: left 250ms ease-in-out; +} +.sb-sidebar-secondary { + right: -17rem; + transition: right 250ms ease-in-out; +} + +.sb-sidebar-toggle { + display: none; +} +.sb-sidebar-overlay { + position: fixed; + top: 0; + width: 0; + height: 0; + + transition: width 0ms ease 250ms, height 0ms ease 250ms, opacity 250ms ease; + + opacity: 0; + background-color: rgba(0, 0, 0, 0.54); +} + +#sb-sidebar-toggle--primary:checked + ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--primary"], +#sb-sidebar-toggle--secondary:checked + ~ .sb-sidebar-overlay[for="sb-sidebar-toggle--secondary"] { + width: 100%; + height: 100%; + opacity: 1; + transition: width 0ms ease, height 0ms ease, opacity 250ms ease; +} + +#sb-sidebar-toggle--primary:checked ~ .sb-container .sb-sidebar-primary { + left: 0; +} +#sb-sidebar-toggle--secondary:checked ~ .sb-container .sb-sidebar-secondary { + right: 0; +} + +/* Full-width mode */ +.drop-secondary-sidebar-for-full-width-content + .hide-when-secondary-sidebar-shown { + display: none !important; +} +.drop-secondary-sidebar-for-full-width-content .sb-sidebar-secondary { + display: none !important; +} + +/* Mobile views */ +.sb-page-width { + width: 100%; +} + +.sb-article-container, +.sb-footer-content__inner, +.drop-secondary-sidebar-for-full-width-content .sb-article, +.drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 100vw; +} + +.sb-article, +.match-content-width { + padding: 0 1rem; + box-sizing: border-box; +} + +@media (min-width: 32rem) { + .sb-article, + .match-content-width { + padding: 0 2rem; + } +} + +/* Tablet views */ +@media (min-width: 42rem) { + .sb-article-container { + width: auto; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 42rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} +@media (min-width: 46rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 46rem; + } + .sb-article, + .match-content-width { + width: 46rem; + } +} +@media (min-width: 50rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 50rem; + } + .sb-article, + .match-content-width { + width: 50rem; + } +} + +/* Tablet views */ +@media (min-width: 59rem) { + .sb-sidebar-secondary { + position: static; + } + .hide-when-secondary-sidebar-shown { + display: none !important; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 59rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} +@media (min-width: 63rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 63rem; + } + .sb-article, + .match-content-width { + width: 46rem; + } +} +@media (min-width: 67rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } + .sb-article, + .match-content-width { + width: 50rem; + } +} + +/* Desktop views */ +@media (min-width: 76rem) { + .sb-sidebar-primary { + position: static; + } + .hide-when-primary-sidebar-shown { + display: none !important; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 59rem; + } + .sb-article, + .match-content-width { + width: 42rem; + } +} + +/* Full desktop views */ +@media (min-width: 80rem) { + .sb-article, + .match-content-width { + width: 46rem; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 63rem; + } +} + +@media (min-width: 84rem) { + .sb-article, + .match-content-width { + width: 50rem; + } + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } +} + +@media (min-width: 88rem) { + .sb-footer-content__inner, + .drop-secondary-sidebar-for-full-width-content .sb-article, + .drop-secondary-sidebar-for-full-width-content .match-content-width { + width: 67rem; + } + .sb-page-width { + width: 88rem; + } +} diff --git a/docs/site/_static/sphinx_highlight.js b/docs/site/_static/sphinx_highlight.js new file mode 100644 index 0000000..a74e103 --- /dev/null +++ b/docs/site/_static/sphinx_highlight.js @@ -0,0 +1,159 @@ +/* Highlighting utilities for Sphinx HTML documentation. */ +"use strict"; + +const SPHINX_HIGHLIGHT_ENABLED = true; + +/** + * highlight a given string on a node by wrapping it in + * span elements with the given class name. + */ +const _highlight = (node, addItems, text, className) => { + if (node.nodeType === Node.TEXT_NODE) { + const val = node.nodeValue; + const parent = node.parentNode; + const pos = val.toLowerCase().indexOf(text); + if ( + pos >= 0 + && !parent.classList.contains(className) + && !parent.classList.contains("nohighlight") + ) { + let span; + + const closestNode = parent.closest("body, svg, foreignObject"); + const isInSVG = closestNode && closestNode.matches("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.classList.add(className); + } + + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + const rest = document.createTextNode(val.substr(pos + text.length)); + parent.insertBefore(span, parent.insertBefore(rest, node.nextSibling)); + node.nodeValue = val.substr(0, pos); + /* There may be more occurrences of search term in this node. So call this + * function recursively on the remaining fragment. + */ + _highlight(rest, addItems, text, className); + + if (isInSVG) { + const rect = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + const bbox = parent.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute("class", className); + addItems.push({ parent: parent, target: rect }); + } + } + } else if (node.matches && !node.matches("button, select, textarea")) { + node.childNodes.forEach((el) => _highlight(el, addItems, text, className)); + } +}; +const _highlightText = (thisNode, text, className) => { + let addItems = []; + _highlight(thisNode, addItems, text, className); + addItems.forEach((obj) => + obj.parent.insertAdjacentElement("beforebegin", obj.target), + ); +}; + +/** + * Small JavaScript module for the documentation. + */ +const SphinxHighlight = { + /** + * highlight the search words provided in localstorage in the text + */ + highlightSearchWords: () => { + if (!SPHINX_HIGHLIGHT_ENABLED) return; // bail if no highlight + + // get and clear terms from localstorage + const url = new URL(window.location); + const highlight = + localStorage.getItem("sphinx_highlight_terms") + || url.searchParams.get("highlight") + || ""; + localStorage.removeItem("sphinx_highlight_terms"); + // Update history only if '?highlight' is present; otherwise it + // clears text fragments (not set in window.location by the browser) + if (url.searchParams.has("highlight")) { + url.searchParams.delete("highlight"); + window.history.replaceState({}, "", url); + } + + // get individual terms from highlight string + const terms = highlight + .toLowerCase() + .split(/\s+/) + .filter((x) => x); + if (terms.length === 0) return; // nothing to do + + // There should never be more than one element matching "div.body" + const divBody = document.querySelectorAll("div.body"); + const body = divBody.length ? divBody[0] : document.querySelector("body"); + window.setTimeout(() => { + terms.forEach((term) => _highlightText(body, term, "highlighted")); + }, 10); + + const searchBox = document.getElementById("searchbox"); + if (searchBox === null) return; + searchBox.appendChild( + document + .createRange() + .createContextualFragment( + '", + ), + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms"); + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) + return; + if ( + DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + && event.key === "Escape" + ) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/docs/site/_static/styles/furo-extensions.css b/docs/site/_static/styles/furo-extensions.css new file mode 100644 index 0000000..2d74267 --- /dev/null +++ b/docs/site/_static/styles/furo-extensions.css @@ -0,0 +1,2 @@ +#furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0s}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} +/*# sourceMappingURL=furo-extensions.css.map*/ \ No newline at end of file diff --git a/docs/site/_static/styles/furo-extensions.css.map b/docs/site/_static/styles/furo-extensions.css.map new file mode 100644 index 0000000..68fb7fd --- /dev/null +++ b/docs/site/_static/styles/furo-extensions.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo-extensions.css","mappings":"AAGA,2BACE,oFACA,4CAKE,6CAHA,YACA,eAEA,CACA,kDACE,yCAEF,8CACE,sCAEJ,8CACE,kDAEJ,2BAGE,uBACA,cAHA,gBACA,UAEA,CAGA,yCACE,mBAEF,gDAEE,gDADA,YACA,CACA,sDACE,gDACF,yDACE,sCAEJ,+CACE,UACA,qDACE,UAGF,mDACE,eAEJ,yEAEE,4DAEA,mHASE,mBAPA,kBAEA,YADA,oBAGA,aADA,gBAIA,CAEA,qIAEE,WADA,UACA,CAEJ,uGACE,aAEF,iUAGE,cAEF,mHACE,aC1EJ,gCACE,mCAEF,0BAEE,mBAUA,8CACA,YAFA,mCAKA,eAZA,cAIA,YADA,YAYA,iCAdA,YAcA,CAEA,gCAEE,8CADA,gCACA,CAEF,gCAGE,6BADA,mCADA,YAEA,CAEF,kCAEE,cADA,mBACA,CACA,wCACE,cAEJ,8BACE,UCzCN,KAEE,6CAA8C,CAC9C,uDAAwD,CACxD,uDAAwD,CAGxD,iCAAsC,CAGtC,+CAAgD,CAChD,uDAAwD,CACxD,uDAAwD,CACxD,oDAAqD,CACrD,6DAA8D,CAC9D,6DAA8D,CAG9D,uDAAwD,CACxD,yDAA0D,CAC1D,4DAA6D,CAC7D,2DAA4D,CAC5D,8DAA+D,CAC/D,iEAAkE,CAClE,uDAAwD,CACxD,wDAAyD,CAG3D,gBACE,qFAGF,SACE,6EAEF,cACE,uFAEF,cACE,uFAEF,cACE,uFAGF,qBACE,eAEF,mBACE,WACA,eChDF,KACE,gDAAiD,CACjD,uDAAwD,CACxD,qDAAsD,CACtD,4DAA6D,CAC7D,oCAAqC,CACrC,2CAA4C,CAC5C,4CAA6C,CAC7C,mDAAoD,CACpD,wBAAyB,CACzB,oBAAqB,CACrB,6CAA8C,CAC9C,gCAAiC,CACjC,yDAA0D,CAC1D,uDAAwD,CACxD,8DAA+D,CCbjE,uBACE,eACA,eACA,gBAGF,iBACE,YACA,+EAGF,iBACE,mDACA","sources":["webpack:///./src/furo/assets/styles/extensions/_readthedocs.sass","webpack:///./src/furo/assets/styles/extensions/_copybutton.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-design.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-inline-tabs.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-panels.sass"],"sourcesContent":["// This file contains the styles used for tweaking how ReadTheDoc's embedded\n// contents would show up inside the theme.\n\n#furo-sidebar-ad-placement\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n .ethical-sidebar\n // Remove the border and box-shadow.\n border: none\n box-shadow: none\n // Manage the background colors.\n background: var(--color-background-secondary)\n &:hover\n background: var(--color-background-hover)\n // Ensure the text is legible.\n a\n color: var(--color-foreground-primary)\n\n .ethical-callout a\n color: var(--color-foreground-secondary) !important\n\n#furo-readthedocs-versions\n position: static\n width: 100%\n background: transparent\n display: block\n\n // Make the background color fit with the theme's aesthetic.\n .rst-versions\n background: rgb(26, 28, 30)\n\n .rst-current-version\n cursor: unset\n background: var(--color-sidebar-item-background)\n &:hover\n background: var(--color-sidebar-item-background)\n .fa-book\n color: var(--color-foreground-primary)\n\n > .rst-other-versions\n padding: 0\n small\n opacity: 1\n\n .injected\n .rst-versions\n position: unset\n\n &:hover,\n &:focus-within\n box-shadow: 0 0 0 1px var(--color-sidebar-background-border)\n\n .rst-current-version\n // Undo the tweaks done in RTD's CSS\n font-size: inherit\n line-height: inherit\n height: auto\n text-align: right\n padding: 12px\n\n // Match the rest of the body\n background: #1a1c1e\n\n .fa-book\n float: left\n color: white\n\n .fa-caret-down\n display: none\n\n .rst-current-version,\n .rst-other-versions,\n .injected\n display: block\n\n > .rst-current-version\n display: none\n",".highlight\n &:hover button.copybtn\n color: var(--color-code-foreground)\n\n button.copybtn\n // Align things correctly\n align-items: center\n\n height: 1.25em\n width: 1.25em\n\n top: 0.625rem // $code-spacing-vertical\n right: 0.5rem\n\n // Make it look better\n color: var(--color-background-item)\n background-color: var(--color-code-background)\n border: none\n\n // Change to cursor to make it obvious that you can click on it\n cursor: pointer\n\n // Transition smoothly, for aesthetics\n transition: color 300ms, opacity 300ms\n\n &:hover\n color: var(--color-brand-content)\n background-color: var(--color-code-background)\n\n &::after\n display: none\n color: var(--color-code-foreground)\n background-color: transparent\n\n &.success\n transition: color 0ms\n color: #22863a\n &::after\n display: block\n\n svg\n padding: 0\n","body\n // Colors\n --sd-color-primary: var(--color-brand-primary)\n --sd-color-primary-highlight: var(--color-brand-content)\n --sd-color-primary-text: var(--color-background-primary)\n\n // Shadows\n --sd-color-shadow: rgba(0, 0, 0, 0.05)\n\n // Cards\n --sd-color-card-border: var(--color-card-border)\n --sd-color-card-border-hover: var(--color-brand-content)\n --sd-color-card-background: var(--color-card-background)\n --sd-color-card-text: var(--color-foreground-primary)\n --sd-color-card-header: var(--color-card-marginals-background)\n --sd-color-card-footer: var(--color-card-marginals-background)\n\n // Tabs\n --sd-color-tabs-label-active: var(--color-brand-content)\n --sd-color-tabs-label-hover: var(--color-foreground-muted)\n --sd-color-tabs-label-inactive: var(--color-foreground-muted)\n --sd-color-tabs-underline-active: var(--color-brand-content)\n --sd-color-tabs-underline-hover: var(--color-foreground-border)\n --sd-color-tabs-underline-inactive: var(--color-background-border)\n --sd-color-tabs-overline: var(--color-background-border)\n --sd-color-tabs-underline: var(--color-background-border)\n\n// Tabs\n.sd-tab-content\n box-shadow: 0 -2px var(--sd-color-tabs-overline), 0 1px var(--sd-color-tabs-underline)\n\n// Shadows\n.sd-card // Have a shadow by default\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n.sd-shadow-sm\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-md\n box-shadow: 0 0.3rem 0.75rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-lg\n box-shadow: 0 0.6rem 1.5rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Cards\n.sd-card-hover:hover // Don't change scale on hover\n transform: none\n\n.sd-cards-carousel // Have a bit of gap in the carousel by default\n gap: 0.25rem\n padding: 0.25rem\n","// This file contains styles to tweak sphinx-inline-tabs to work well with Furo.\n\nbody\n --tabs--label-text: var(--color-foreground-muted)\n --tabs--label-text--hover: var(--color-foreground-muted)\n --tabs--label-text--active: var(--color-brand-content)\n --tabs--label-text--active--hover: var(--color-brand-content)\n --tabs--label-background: transparent\n --tabs--label-background--hover: transparent\n --tabs--label-background--active: transparent\n --tabs--label-background--active--hover: transparent\n --tabs--padding-x: 0.25em\n --tabs--margin-x: 1em\n --tabs--border: var(--color-background-border)\n --tabs--label-border: transparent\n --tabs--label-border--hover: var(--color-foreground-muted)\n --tabs--label-border--active: var(--color-brand-content)\n --tabs--label-border--active--hover: var(--color-brand-content)\n","// This file contains styles to tweak sphinx-panels to work well with Furo.\n\n// sphinx-panels includes Bootstrap 4, which uses .container which can conflict\n// with docutils' `.. container::` directive.\n[role=\"main\"] .container\n max-width: initial\n padding-left: initial\n padding-right: initial\n\n// Make the panels look nicer!\n.shadow.docutils\n border: none\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Make panel colors respond to dark mode\n.sphinx-bs .card\n background-color: var(--color-background-secondary)\n color: var(--color-foreground)\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/docs/site/_static/styles/furo.css b/docs/site/_static/styles/furo.css new file mode 100644 index 0000000..a5b614d --- /dev/null +++ b/docs/site/_static/styles/furo.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}a,blockquote,dl,ol,p,pre,table,ul{page-break-inside:avoid}caption,figure,h1,h2,h3,h4,h5,h6,img{page-break-after:avoid;page-break-inside:avoid}dl,ol,ul{page-break-before:avoid}}.visually-hidden{height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;clip:rect(0,0,0,0)!important;background:var(--color-background-primary);border:0!important;color:var(--color-foreground-primary);white-space:nowrap!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-stack--headings:var(--font-stack);--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,');--icon-pencil:url('data:image/svg+xml;charset=utf-8,');--icon-abstract:url('data:image/svg+xml;charset=utf-8,');--icon-info:url('data:image/svg+xml;charset=utf-8,');--icon-flame:url('data:image/svg+xml;charset=utf-8,');--icon-question:url('data:image/svg+xml;charset=utf-8,');--icon-warning:url('data:image/svg+xml;charset=utf-8,');--icon-failure:url('data:image/svg+xml;charset=utf-8,');--icon-spark:url('data:image/svg+xml;charset=utf-8,');--color-admonition-title--caution:#ff9100;--color-admonition-title-background--caution:rgba(255,145,0,.2);--color-admonition-title--warning:#ff9100;--color-admonition-title-background--warning:rgba(255,145,0,.2);--color-admonition-title--danger:#ff5252;--color-admonition-title-background--danger:rgba(255,82,82,.2);--color-admonition-title--attention:#ff5252;--color-admonition-title-background--attention:rgba(255,82,82,.2);--color-admonition-title--error:#ff5252;--color-admonition-title-background--error:rgba(255,82,82,.2);--color-admonition-title--hint:#00c852;--color-admonition-title-background--hint:rgba(0,200,82,.2);--color-admonition-title--tip:#00c852;--color-admonition-title-background--tip:rgba(0,200,82,.2);--color-admonition-title--important:#00bfa5;--color-admonition-title-background--important:rgba(0,191,165,.2);--color-admonition-title--note:#00b0ff;--color-admonition-title-background--note:rgba(0,176,255,.2);--color-admonition-title--seealso:#448aff;--color-admonition-title-background--seealso:rgba(68,138,255,.2);--color-admonition-title--admonition-todo:grey;--color-admonition-title-background--admonition-todo:hsla(0,0%,50%,.2);--color-admonition-title:#651fff;--color-admonition-title-background:rgba(101,31,255,.2);--icon-admonition-default:var(--icon-abstract);--color-topic-title:#14b8a6;--color-topic-title-background:rgba(20,184,166,.2);--icon-topic-default:var(--icon-pencil);--color-problematic:#b30000;--color-foreground-primary:#000;--color-foreground-secondary:#5a5c63;--color-foreground-muted:#6b6f76;--color-foreground-border:#878787;--color-background-primary:#fff;--color-background-secondary:#f8f9fb;--color-background-hover:#efeff4;--color-background-hover--transparent:#efeff400;--color-background-border:#eeebee;--color-background-item:#ccc;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#0a4bff;--color-brand-content:#2757dd;--color-brand-visited:#872ee0;--color-api-background:var(--color-background-hover--transparent);--color-api-background-hover:var(--color-background-hover);--color-api-overall:var(--color-foreground-secondary);--color-api-name:var(--color-problematic);--color-api-pre-name:var(--color-problematic);--color-api-paren:var(--color-foreground-secondary);--color-api-keyword:var(--color-foreground-primary);--color-api-added:#21632c;--color-api-added-border:#38a84d;--color-api-changed:#046172;--color-api-changed-border:#06a1bc;--color-api-deprecated:#605706;--color-api-deprecated-border:#f0d90f;--color-api-removed:#b30000;--color-api-removed-border:#ff5c5c;--color-highlight-on-target:#ffc;--color-inline-code-background:var(--color-background-secondary);--color-highlighted-background:#def;--color-highlighted-text:var(--color-foreground-primary);--color-guilabel-background:#ddeeff80;--color-guilabel-border:#bedaf580;--color-guilabel-text:var(--color-foreground-primary);--color-admonition-background:transparent;--color-table-header-background:var(--color-background-secondary);--color-table-border:var(--color-background-border);--color-card-border:var(--color-background-secondary);--color-card-background:transparent;--color-card-marginals-background:var(--color-background-secondary);--color-header-background:var(--color-background-primary);--color-header-border:var(--color-background-border);--color-header-text:var(--color-foreground-primary);--color-sidebar-background:var(--color-background-secondary);--color-sidebar-background-border:var(--color-background-border);--color-sidebar-brand-text:var(--color-foreground-primary);--color-sidebar-caption-text:var(--color-foreground-muted);--color-sidebar-link-text:var(--color-foreground-secondary);--color-sidebar-link-text--top-level:var(--color-brand-primary);--color-sidebar-item-background:var(--color-sidebar-background);--color-sidebar-item-background--current:var( --color-sidebar-item-background );--color-sidebar-item-background--hover:linear-gradient(90deg,var(--color-background-hover--transparent) 0%,var(--color-background-hover) var(--sidebar-item-spacing-horizontal),var(--color-background-hover) 100%);--color-sidebar-item-expander-background:transparent;--color-sidebar-item-expander-background--hover:var( --color-background-hover );--color-sidebar-search-text:var(--color-foreground-primary);--color-sidebar-search-background:var(--color-background-secondary);--color-sidebar-search-background--focus:var(--color-background-primary);--color-sidebar-search-border:var(--color-background-border);--color-sidebar-search-icon:var(--color-foreground-muted);--color-toc-background:var(--color-background-primary);--color-toc-title-text:var(--color-foreground-muted);--color-toc-item-text:var(--color-foreground-secondary);--color-toc-item-text--hover:var(--color-foreground-primary);--color-toc-item-text--active:var(--color-brand-primary);--color-content-foreground:var(--color-foreground-primary);--color-content-background:transparent;--color-link:var(--color-brand-content);--color-link-underline:var(--color-background-border);--color-link--hover:var(--color-brand-content);--color-link-underline--hover:var(--color-foreground-border);--color-link--visited:var(--color-brand-visited);--color-link-underline--visited:var(--color-background-border);--color-link--visited--hover:var(--color-brand-visited);--color-link-underline--visited--hover:var(--color-foreground-border)}.only-light{display:block!important}html body .only-dark{display:none!important}@media not print{body[data-theme=dark]{--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body[data-theme=dark] .only-light{display:none!important}body[data-theme=dark] .only-dark{display:block!important}@media(prefers-color-scheme:dark){body:not([data-theme=light]){--color-problematic:#ee5151;--color-foreground-primary:#cfd0d0;--color-foreground-secondary:#9ca0a5;--color-foreground-muted:#81868d;--color-foreground-border:#666;--color-background-primary:#131416;--color-background-secondary:#1a1c1e;--color-background-hover:#1e2124;--color-background-hover--transparent:#1e212400;--color-background-border:#303335;--color-background-item:#444;--color-announcement-background:#000000dd;--color-announcement-text:#eeebee;--color-brand-primary:#3d94ff;--color-brand-content:#5ca5ff;--color-brand-visited:#b27aeb;--color-highlighted-background:#083563;--color-guilabel-background:#08356380;--color-guilabel-border:#13395f80;--color-api-keyword:var(--color-foreground-secondary);--color-highlight-on-target:#330;--color-api-added:#3db854;--color-api-added-border:#267334;--color-api-changed:#09b0ce;--color-api-changed-border:#056d80;--color-api-deprecated:#b1a10b;--color-api-deprecated-border:#6e6407;--color-api-removed:#ff7575;--color-api-removed-border:#b03b3b;--color-admonition-background:#18181a;--color-card-border:var(--color-background-secondary);--color-card-background:#18181a;--color-card-marginals-background:var(--color-background-hover)}html body:not([data-theme=light]) .only-light{display:none!important}body:not([data-theme=light]) .only-dark{display:block!important}}}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:block}@media(prefers-color-scheme:dark){body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-dark{display:block}body[data-theme=auto] .theme-toggle svg.theme-icon-when-auto-light{display:none}}body[data-theme=dark] .theme-toggle svg.theme-icon-when-dark,body[data-theme=light] .theme-toggle svg.theme-icon-when-light{display:block}body{font-family:var(--font-stack)}code,kbd,pre,samp{font-family:var(--font-stack--monospace)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}article{line-height:1.5}h1,h2,h3,h4,h5,h6{border-radius:.5rem;font-family:var(--font-stack--headings);font-weight:700;line-height:1.25;margin:.5rem -.5rem;padding-left:.5rem;padding-right:.5rem}h1+p,h2+p,h3+p,h4+p,h5+p,h6+p{margin-top:0}h1{font-size:2.5em;margin-bottom:1rem}h1,h2{margin-top:1.75rem}h2{font-size:2em}h3{font-size:1.5em}h4{font-size:1.25em}h5{font-size:1.125em}h6{font-size:1em}small{font-size:80%;opacity:75%}p{margin-bottom:.75rem;margin-top:.5rem}hr.docutils{background-color:var(--color-background-border);border:0;height:1px;margin:2rem 0;padding:0}.centered{text-align:center}a{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}a:visited{color:var(--color-link--visited);text-decoration-color:var(--color-link-underline--visited)}a:visited:hover{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}a:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link{color:inherit}a.muted-link:hover{color:var(--color-link--hover);text-decoration-color:var(--color-link-underline--hover)}a.muted-link:hover:visited{color:var(--color-link--visited--hover);text-decoration-color:var(--color-link-underline--visited--hover)}html{overflow-x:hidden;overflow-y:scroll;scroll-behavior:smooth}.sidebar-scroll,.toc-scroll,article[role=main] *{scrollbar-color:var(--color-foreground-border) transparent;scrollbar-width:thin}body,html{height:100%}.skip-to-content,body,html{background:var(--color-background-primary);color:var(--color-foreground-primary)}.skip-to-content{border-radius:1rem;left:.25rem;padding:1rem;position:fixed;top:.25rem;transform:translateY(-200%);transition:transform .3s ease-in-out;z-index:40}.skip-to-content:focus-within{transform:translateY(0)}article{background:var(--color-content-background);color:var(--color-content-foreground);overflow-wrap:break-word}.page{display:flex;min-height:100%}.mobile-header{background-color:var(--color-header-background);border-bottom:1px solid var(--color-header-border);color:var(--color-header-text);display:none;height:var(--header-height);width:100%;z-index:10}.mobile-header.scrolled{border-bottom:none;box-shadow:0 0 .2rem rgba(0,0,0,.1),0 .2rem .4rem rgba(0,0,0,.2)}.mobile-header .header-center a{color:var(--color-header-text);text-decoration:none}.main{display:flex;flex:1}.sidebar-drawer{background:var(--color-sidebar-background);border-right:1px solid var(--color-sidebar-background-border);box-sizing:border-box;display:flex;justify-content:flex-end;min-width:15em;width:calc(50% - 26em)}.sidebar-container,.toc-drawer{box-sizing:border-box;width:15em}.toc-drawer{background:var(--color-toc-background);padding-right:1rem}.sidebar-sticky,.toc-sticky{display:flex;flex-direction:column;height:min(100%,100vh);height:100vh;position:sticky;top:0}.sidebar-scroll,.toc-scroll{flex-grow:1;flex-shrink:1;overflow:auto;scroll-behavior:smooth}.content{display:flex;flex-direction:column;justify-content:space-between;padding:0 3em;width:46em}.icon{display:inline-block;height:1rem;width:1rem}.icon svg{height:100%;width:100%}.announcement{align-items:center;background-color:var(--color-announcement-background);color:var(--color-announcement-text);display:flex;height:var(--header-height);overflow-x:auto}.announcement+.page{min-height:calc(100% - var(--header-height))}.announcement-content{box-sizing:border-box;min-width:100%;padding:.5rem;text-align:center;white-space:nowrap}.announcement-content a{color:var(--color-announcement-text);text-decoration-color:var(--color-announcement-text)}.announcement-content a:hover{color:var(--color-announcement-text);text-decoration-color:var(--color-link--hover)}.no-js .theme-toggle-container{display:none}.theme-toggle-container{display:flex}.theme-toggle{background:transparent;border:none;cursor:pointer;display:flex;padding:0}.theme-toggle svg{color:var(--color-foreground-primary);display:none;height:1.25rem;width:1.25rem}.theme-toggle-header{align-items:center;display:flex;justify-content:center}.nav-overlay-icon,.toc-overlay-icon{cursor:pointer;display:none}.nav-overlay-icon .icon,.toc-overlay-icon .icon{color:var(--color-foreground-secondary);height:1.5rem;width:1.5rem}.nav-overlay-icon,.toc-header-icon{align-items:center;justify-content:center}.toc-content-icon{height:1.5rem;width:1.5rem}.content-icon-container{display:flex;float:right;gap:.5rem;margin-bottom:1rem;margin-left:1rem;margin-top:1.5rem}.content-icon-container .edit-this-page svg,.content-icon-container .view-this-page svg{color:inherit;height:1.25rem;width:1.25rem}.sidebar-toggle{display:none;position:absolute}.sidebar-toggle[name=__toc]{left:20px}.sidebar-toggle:checked{left:40px}.overlay{background-color:rgba(0,0,0,.54);height:0;opacity:0;position:fixed;top:0;transition:width 0s,height 0s,opacity .25s ease-out;width:0}.sidebar-overlay{z-index:20}.toc-overlay{z-index:40}.sidebar-drawer{transition:left .25s ease-in-out;z-index:30}.toc-drawer{transition:right .25s ease-in-out;z-index:50}#__navigation:checked~.sidebar-overlay{height:100%;opacity:1;width:100%}#__navigation:checked~.page .sidebar-drawer{left:0;top:0}#__toc:checked~.toc-overlay{height:100%;opacity:1;width:100%}#__toc:checked~.page .toc-drawer{right:0;top:0}.back-to-top{background:var(--color-background-primary);border-radius:1rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 1px 0 hsla(220,9%,46%,.502);display:none;font-size:.8125rem;left:0;margin-left:50%;padding:.5rem .75rem .5rem .5rem;position:fixed;text-decoration:none;top:1rem;transform:translateX(-50%);z-index:10}.back-to-top svg{height:1rem;width:1rem;fill:currentColor;display:inline-block}.back-to-top span{margin-left:.25rem}.show-back-to-top .back-to-top{align-items:center;display:flex}@media(min-width:97em){html{font-size:110%}}@media(max-width:82em){.toc-content-icon{display:flex}.toc-drawer{border-left:1px solid var(--color-background-muted);height:100vh;position:fixed;right:-15em;top:0}.toc-tree{border-left:none;font-size:var(--toc-font-size--mobile)}.sidebar-drawer{width:calc(50% - 18.5em)}}@media(max-width:67em){.content{margin-left:auto;margin-right:auto;padding:0 1em}}@media(max-width:63em){.nav-overlay-icon{display:flex}.sidebar-drawer{height:100vh;left:-15em;position:fixed;top:0;width:15em}.theme-toggle-header,.toc-header-icon{display:flex}.theme-toggle-content,.toc-content-icon{display:none}.mobile-header{align-items:center;display:flex;justify-content:space-between;position:sticky;top:0}.mobile-header .header-left,.mobile-header .header-right{display:flex;height:var(--header-height);padding:0 var(--header-padding)}.mobile-header .header-left label,.mobile-header .header-right label{height:100%;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:100%}.nav-overlay-icon .icon,.theme-toggle svg{height:1.5rem;width:1.5rem}:target{scroll-margin-top:calc(var(--header-height) + 2.5rem)}.back-to-top{top:calc(var(--header-height) + .5rem)}.page{flex-direction:column;justify-content:center}}@media(max-width:48em){.content{overflow-x:auto;width:100%}}@media(max-width:46em){article[role=main] aside.sidebar{float:none;margin:1rem 0;width:100%}}.admonition,.topic{background:var(--color-admonition-background);border-radius:.2rem;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1);font-size:var(--admonition-font-size);margin:1rem auto;overflow:hidden;padding:0 .5rem .5rem;page-break-inside:avoid}.admonition>:nth-child(2),.topic>:nth-child(2){margin-top:0}.admonition>:last-child,.topic>:last-child{margin-bottom:0}.admonition p.admonition-title,p.topic-title{font-size:var(--admonition-title-font-size);font-weight:500;line-height:1.3;margin:0 -.5rem .5rem;padding:.4rem .5rem .4rem 2rem;position:relative}.admonition p.admonition-title:before,p.topic-title:before{content:"";height:1rem;left:.5rem;position:absolute;width:1rem}p.admonition-title{background-color:var(--color-admonition-title-background)}p.admonition-title:before{background-color:var(--color-admonition-title);-webkit-mask-image:var(--icon-admonition-default);mask-image:var(--icon-admonition-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}p.topic-title{background-color:var(--color-topic-title-background)}p.topic-title:before{background-color:var(--color-topic-title);-webkit-mask-image:var(--icon-topic-default);mask-image:var(--icon-topic-default);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.admonition{border-left:.2rem solid var(--color-admonition-title)}.admonition.caution{border-left-color:var(--color-admonition-title--caution)}.admonition.caution>.admonition-title{background-color:var(--color-admonition-title-background--caution)}.admonition.caution>.admonition-title:before{background-color:var(--color-admonition-title--caution);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.warning{border-left-color:var(--color-admonition-title--warning)}.admonition.warning>.admonition-title{background-color:var(--color-admonition-title-background--warning)}.admonition.warning>.admonition-title:before{background-color:var(--color-admonition-title--warning);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.danger{border-left-color:var(--color-admonition-title--danger)}.admonition.danger>.admonition-title{background-color:var(--color-admonition-title-background--danger)}.admonition.danger>.admonition-title:before{background-color:var(--color-admonition-title--danger);-webkit-mask-image:var(--icon-spark);mask-image:var(--icon-spark)}.admonition.attention{border-left-color:var(--color-admonition-title--attention)}.admonition.attention>.admonition-title{background-color:var(--color-admonition-title-background--attention)}.admonition.attention>.admonition-title:before{background-color:var(--color-admonition-title--attention);-webkit-mask-image:var(--icon-warning);mask-image:var(--icon-warning)}.admonition.error{border-left-color:var(--color-admonition-title--error)}.admonition.error>.admonition-title{background-color:var(--color-admonition-title-background--error)}.admonition.error>.admonition-title:before{background-color:var(--color-admonition-title--error);-webkit-mask-image:var(--icon-failure);mask-image:var(--icon-failure)}.admonition.hint{border-left-color:var(--color-admonition-title--hint)}.admonition.hint>.admonition-title{background-color:var(--color-admonition-title-background--hint)}.admonition.hint>.admonition-title:before{background-color:var(--color-admonition-title--hint);-webkit-mask-image:var(--icon-question);mask-image:var(--icon-question)}.admonition.tip{border-left-color:var(--color-admonition-title--tip)}.admonition.tip>.admonition-title{background-color:var(--color-admonition-title-background--tip)}.admonition.tip>.admonition-title:before{background-color:var(--color-admonition-title--tip);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.important{border-left-color:var(--color-admonition-title--important)}.admonition.important>.admonition-title{background-color:var(--color-admonition-title-background--important)}.admonition.important>.admonition-title:before{background-color:var(--color-admonition-title--important);-webkit-mask-image:var(--icon-flame);mask-image:var(--icon-flame)}.admonition.note{border-left-color:var(--color-admonition-title--note)}.admonition.note>.admonition-title{background-color:var(--color-admonition-title-background--note)}.admonition.note>.admonition-title:before{background-color:var(--color-admonition-title--note);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition.seealso{border-left-color:var(--color-admonition-title--seealso)}.admonition.seealso>.admonition-title{background-color:var(--color-admonition-title-background--seealso)}.admonition.seealso>.admonition-title:before{background-color:var(--color-admonition-title--seealso);-webkit-mask-image:var(--icon-info);mask-image:var(--icon-info)}.admonition.admonition-todo{border-left-color:var(--color-admonition-title--admonition-todo)}.admonition.admonition-todo>.admonition-title{background-color:var(--color-admonition-title-background--admonition-todo)}.admonition.admonition-todo>.admonition-title:before{background-color:var(--color-admonition-title--admonition-todo);-webkit-mask-image:var(--icon-pencil);mask-image:var(--icon-pencil)}.admonition-todo>.admonition-title{text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd{margin-left:2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:first-child{margin-top:.125rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list,dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) dd>:last-child{margin-bottom:.75rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list>dt{font-size:var(--font-size--small);text-transform:uppercase}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd:empty{margin-bottom:.5rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul{margin-left:-1.2rem}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p:nth-child(2){margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple) .field-list dd>ul>li>p+p:last-child:empty{margin-bottom:0;margin-top:0}dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)>dt{color:var(--color-api-overall)}.sig:not(.sig-inline){background:var(--color-api-background);border-radius:.25rem;font-family:var(--font-stack--monospace);font-size:var(--api-font-size);font-weight:700;margin-left:-.25rem;margin-right:-.25rem;padding:.25rem .5rem .25rem 3em;text-indent:-2.5em;transition:background .1s ease-out}.sig:not(.sig-inline):hover{background:var(--color-api-background-hover)}.sig:not(.sig-inline) a.reference .viewcode-link{font-weight:400;width:4.25rem}em.property,span.property{font-style:normal}em.property:first-child,span.property:first-child{color:var(--color-api-keyword)}.sig-name{color:var(--color-api-name)}.sig-prename{color:var(--color-api-pre-name);font-weight:400}.sig-paren{color:var(--color-api-paren)}.sig-param{font-style:normal}div.deprecated,div.versionadded,div.versionchanged,div.versionremoved{border-left:.1875rem solid;border-radius:.125rem;padding-left:.75rem}div.deprecated p,div.versionadded p,div.versionchanged p,div.versionremoved p{margin-bottom:.125rem;margin-top:.125rem}div.versionadded{border-color:var(--color-api-added-border)}div.versionadded .versionmodified{color:var(--color-api-added)}div.versionchanged{border-color:var(--color-api-changed-border)}div.versionchanged .versionmodified{color:var(--color-api-changed)}div.deprecated{border-color:var(--color-api-deprecated-border)}div.deprecated .versionmodified{color:var(--color-api-deprecated)}div.versionremoved{border-color:var(--color-api-removed-border)}div.versionremoved .versionmodified{color:var(--color-api-removed)}.viewcode-back,.viewcode-link{float:right;text-align:right}.line-block{margin-bottom:.75rem;margin-top:.5rem}.line-block .line-block{margin-bottom:0;margin-top:0;padding-left:1rem}.code-block-caption,article p.caption,table>caption{font-size:var(--font-size--small);text-align:center}.toctree-wrapper.compound .caption,.toctree-wrapper.compound :not(.caption)>.caption-text{font-size:var(--font-size--small);margin-bottom:0;text-align:initial;text-transform:uppercase}.toctree-wrapper.compound>ul{margin-bottom:0;margin-top:0}.sig-inline,code.literal{background:var(--color-inline-code-background);border-radius:.2em;font-size:var(--font-size--small--2);padding:.1em .2em}pre.literal-block .sig-inline,pre.literal-block code.literal{font-size:inherit;padding:0}p .sig-inline,p code.literal{border:1px solid var(--color-background-border)}.sig-inline{font-family:var(--font-stack--monospace)}div[class*=" highlight-"],div[class^=highlight-]{display:flex;margin:1em 0}div[class*=" highlight-"] .table-wrapper,div[class^=highlight-] .table-wrapper,pre{margin:0;padding:0}pre{overflow:auto}article[role=main] .highlight pre{line-height:1.5}.highlight pre,pre.literal-block{font-size:var(--code-font-size);padding:.625rem .875rem}pre.literal-block{background-color:var(--color-code-background);border-radius:.2rem;color:var(--color-code-foreground);margin-bottom:1rem;margin-top:1rem}.highlight{border-radius:.2rem;width:100%}.highlight .gp,.highlight span.linenos{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.highlight .hll{display:block;margin-left:-.875rem;margin-right:-.875rem;padding-left:.875rem;padding-right:.875rem}.code-block-caption{background-color:var(--color-code-background);border-bottom:1px solid;border-radius:.25rem;border-bottom-left-radius:0;border-bottom-right-radius:0;border-color:var(--color-background-border);color:var(--color-code-foreground);display:flex;font-weight:300;padding:.625rem .875rem}.code-block-caption+div[class]{margin-top:0}.code-block-caption+div[class]>.highlight{border-top-left-radius:0;border-top-right-radius:0}.highlighttable{display:block;width:100%}.highlighttable tbody{display:block}.highlighttable tr{display:flex}.highlighttable td.linenos{background-color:var(--color-code-background);border-bottom-left-radius:.2rem;border-top-left-radius:.2rem;color:var(--color-code-foreground);padding:.625rem 0 .625rem .875rem}.highlighttable .linenodiv{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;font-size:var(--code-font-size);padding-right:.875rem}.highlighttable td.code{display:block;flex:1;overflow:hidden;padding:0}.highlighttable td.code .highlight{border-bottom-left-radius:0;border-top-left-radius:0}.highlight span.linenos{box-shadow:-.0625rem 0 var(--color-foreground-border) inset;display:inline-block;margin-right:.875rem;padding-left:0;padding-right:.875rem}.footnote-reference{font-size:var(--font-size--small--4);vertical-align:super}dl.footnote.brackets{color:var(--color-foreground-secondary);display:grid;font-size:var(--font-size--small);grid-template-columns:max-content auto}dl.footnote.brackets dt{margin:0}dl.footnote.brackets dt>.fn-backref{margin-left:.25rem}dl.footnote.brackets dt:after{content:":"}dl.footnote.brackets dt .brackets:before{content:"["}dl.footnote.brackets dt .brackets:after{content:"]"}dl.footnote.brackets dd{margin:0;padding:0 1rem}aside.footnote{color:var(--color-foreground-secondary);font-size:var(--font-size--small)}aside.footnote>span,div.citation>span{float:left;font-weight:500;padding-right:.25rem}aside.footnote>:not(span),div.citation>p{margin-left:2rem}img{box-sizing:border-box;height:auto;max-width:100%}article .figure,article figure{border-radius:.2rem;margin:0}article .figure :last-child,article figure :last-child{margin-bottom:0}article .align-left{clear:left;float:left;margin:0 1rem 1rem}article .align-right{clear:right;float:right;margin:0 1rem 1rem}article .align-center,article .align-default{display:block;margin-left:auto;margin-right:auto;text-align:center}article table.align-default{display:table;text-align:initial}.domainindex-jumpbox,.genindex-jumpbox{border-bottom:1px solid var(--color-background-border);border-top:1px solid var(--color-background-border);padding:.25rem}.domainindex-section h2,.genindex-section h2{margin-bottom:.5rem;margin-top:.75rem}.domainindex-section ul,.genindex-section ul{margin-bottom:0;margin-top:0}ol,ul{margin-bottom:1rem;margin-top:1rem;padding-left:1.2rem}ol li>p:first-child,ul li>p:first-child{margin-bottom:.25rem;margin-top:.25rem}ol li>p:last-child,ul li>p:last-child{margin-top:.25rem}ol li>ol,ol li>ul,ul li>ol,ul li>ul{margin-bottom:.5rem;margin-top:.5rem}ol.arabic{list-style:decimal}ol.loweralpha{list-style:lower-alpha}ol.upperalpha{list-style:upper-alpha}ol.lowerroman{list-style:lower-roman}ol.upperroman{list-style:upper-roman}.simple li>ol,.simple li>ul,.toctree-wrapper li>ol,.toctree-wrapper li>ul{margin-bottom:0;margin-top:0}.field-list dt,.option-list dt,dl.footnote dt,dl.glossary dt,dl.simple dt,dl:not([class]) dt{font-weight:500;margin-top:.25rem}.field-list dt+dt,.option-list dt+dt,dl.footnote dt+dt,dl.glossary dt+dt,dl.simple dt+dt,dl:not([class]) dt+dt{margin-top:0}.field-list dt .classifier:before,.option-list dt .classifier:before,dl.footnote dt .classifier:before,dl.glossary dt .classifier:before,dl.simple dt .classifier:before,dl:not([class]) dt .classifier:before{content:":";margin-left:.2rem;margin-right:.2rem}.field-list dd ul,.field-list dd>p:first-child,.option-list dd ul,.option-list dd>p:first-child,dl.footnote dd ul,dl.footnote dd>p:first-child,dl.glossary dd ul,dl.glossary dd>p:first-child,dl.simple dd ul,dl.simple dd>p:first-child,dl:not([class]) dd ul,dl:not([class]) dd>p:first-child{margin-top:.125rem}.field-list dd ul,.option-list dd ul,dl.footnote dd ul,dl.glossary dd ul,dl.simple dd ul,dl:not([class]) dd ul{margin-bottom:.125rem}.math-wrapper{overflow-x:auto;width:100%}div.math{position:relative;text-align:center}div.math .headerlink,div.math:focus .headerlink{display:none}div.math:hover .headerlink{display:inline-block}div.math span.eqno{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);z-index:1}abbr[title]{cursor:help}.problematic{color:var(--color-problematic)}kbd:not(.compound){background-color:var(--color-background-secondary);border:1px solid var(--color-foreground-border);border-radius:.2rem;box-shadow:0 .0625rem 0 rgba(0,0,0,.2),inset 0 0 0 .125rem var(--color-background-primary);color:var(--color-foreground-primary);display:inline-block;font-size:var(--font-size--small--3);margin:0 .2rem;padding:0 .2rem;vertical-align:text-bottom}blockquote{background:var(--color-background-secondary);border-left:4px solid var(--color-background-border);margin-left:0;margin-right:0;padding:.5rem 1rem}blockquote .attribution{font-weight:600;text-align:right}blockquote.highlights,blockquote.pull-quote{font-size:1.25em}blockquote.epigraph,blockquote.pull-quote{border-left-width:0;border-radius:.5rem}blockquote.highlights{background:transparent;border-left-width:0}p .reference img{vertical-align:middle}p.rubric{font-size:1.125em;font-weight:700;line-height:1.25}dd p.rubric{font-size:var(--font-size--small);font-weight:inherit;line-height:inherit;text-transform:uppercase}article .sidebar{background-color:var(--color-background-secondary);border:1px solid var(--color-background-border);border-radius:.2rem;clear:right;float:right;margin-left:1rem;margin-right:0;width:30%}article .sidebar>*{padding-left:1rem;padding-right:1rem}article .sidebar>ol,article .sidebar>ul{padding-left:2.2rem}article .sidebar .sidebar-title{border-bottom:1px solid var(--color-background-border);font-weight:500;margin:0;padding:.5rem 1rem}[role=main] .table-wrapper.container{margin-bottom:.5rem;margin-top:1rem;overflow-x:auto;padding:.2rem .2rem .75rem;width:100%}table.docutils{border-collapse:collapse;border-radius:.2rem;border-spacing:0;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)}table.docutils th{background:var(--color-table-header-background)}table.docutils td,table.docutils th{border-bottom:1px solid var(--color-table-border);border-left:1px solid var(--color-table-border);border-right:1px solid var(--color-table-border);padding:0 .25rem}table.docutils td p,table.docutils th p{margin:.25rem}table.docutils td:first-child,table.docutils th:first-child{border-left:none}table.docutils td:last-child,table.docutils th:last-child{border-right:none}table.docutils td.text-left,table.docutils th.text-left{text-align:left}table.docutils td.text-right,table.docutils th.text-right{text-align:right}table.docutils td.text-center,table.docutils th.text-center{text-align:center}:target{scroll-margin-top:2.5rem}@media(max-width:67em){:target{scroll-margin-top:calc(2.5rem + var(--header-height))}section>span:target{scroll-margin-top:calc(2.8rem + var(--header-height))}}.headerlink{font-weight:100;-webkit-user-select:none;-moz-user-select:none;user-select:none}.code-block-caption>.headerlink,dl dt>.headerlink,figcaption p>.headerlink,h1>.headerlink,h2>.headerlink,h3>.headerlink,h4>.headerlink,h5>.headerlink,h6>.headerlink,p.caption>.headerlink,table>caption>.headerlink{margin-left:.5rem;visibility:hidden}.code-block-caption:hover>.headerlink,dl dt:hover>.headerlink,figcaption p:hover>.headerlink,h1:hover>.headerlink,h2:hover>.headerlink,h3:hover>.headerlink,h4:hover>.headerlink,h5:hover>.headerlink,h6:hover>.headerlink,p.caption:hover>.headerlink,table>caption:hover>.headerlink{visibility:visible}.code-block-caption>.toc-backref,dl dt>.toc-backref,figcaption p>.toc-backref,h1>.toc-backref,h2>.toc-backref,h3>.toc-backref,h4>.toc-backref,h5>.toc-backref,h6>.toc-backref,p.caption>.toc-backref,table>caption>.toc-backref{color:inherit;text-decoration-line:none}figure:hover>figcaption>p>.headerlink,table:hover>caption>.headerlink{visibility:visible}:target>h1:first-of-type,:target>h2:first-of-type,:target>h3:first-of-type,:target>h4:first-of-type,:target>h5:first-of-type,:target>h6:first-of-type,span:target~h1:first-of-type,span:target~h2:first-of-type,span:target~h3:first-of-type,span:target~h4:first-of-type,span:target~h5:first-of-type,span:target~h6:first-of-type{background-color:var(--color-highlight-on-target)}:target>h1:first-of-type code.literal,:target>h2:first-of-type code.literal,:target>h3:first-of-type code.literal,:target>h4:first-of-type code.literal,:target>h5:first-of-type code.literal,:target>h6:first-of-type code.literal,span:target~h1:first-of-type code.literal,span:target~h2:first-of-type code.literal,span:target~h3:first-of-type code.literal,span:target~h4:first-of-type code.literal,span:target~h5:first-of-type code.literal,span:target~h6:first-of-type code.literal{background-color:transparent}.literal-block-wrapper:target .code-block-caption,.this-will-duplicate-information-and-it-is-still-useful-here li :target,figure:target,table:target>caption{background-color:var(--color-highlight-on-target)}dt:target{background-color:var(--color-highlight-on-target)!important}.footnote-reference:target,.footnote>dt:target+dd{background-color:var(--color-highlight-on-target)}.guilabel{background-color:var(--color-guilabel-background);border:1px solid var(--color-guilabel-border);border-radius:.5em;color:var(--color-guilabel-text);font-size:.9em;padding:0 .3em}footer{display:flex;flex-direction:column;font-size:var(--font-size--small);margin-top:2rem}.bottom-of-page{align-items:center;border-top:1px solid var(--color-background-border);color:var(--color-foreground-secondary);display:flex;justify-content:space-between;line-height:1.5;margin-top:1rem;padding-bottom:1rem;padding-top:1rem}@media(max-width:46em){.bottom-of-page{flex-direction:column-reverse;gap:.25rem;text-align:center}}.bottom-of-page .left-details{font-size:var(--font-size--small)}.bottom-of-page .right-details{display:flex;flex-direction:column;gap:.25rem;text-align:right}.bottom-of-page .icons{display:flex;font-size:1rem;gap:.25rem;justify-content:flex-end}.bottom-of-page .icons a{text-decoration:none}.bottom-of-page .icons img,.bottom-of-page .icons svg{font-size:1.125rem;height:1em;width:1em}.related-pages a{align-items:center;display:flex;text-decoration:none}.related-pages a:hover .page-info .title{color:var(--color-link);text-decoration:underline;text-decoration-color:var(--color-link-underline)}.related-pages a svg.furo-related-icon,.related-pages a svg.furo-related-icon>use{color:var(--color-foreground-border);flex-shrink:0;height:.75rem;margin:0 .5rem;width:.75rem}.related-pages a.next-page{clear:right;float:right;max-width:50%;text-align:right}.related-pages a.prev-page{clear:left;float:left;max-width:50%}.related-pages a.prev-page svg{transform:rotate(180deg)}.page-info{display:flex;flex-direction:column;overflow-wrap:anywhere}.next-page .page-info{align-items:flex-end}.page-info .context{align-items:center;color:var(--color-foreground-muted);display:flex;font-size:var(--font-size--small);padding-bottom:.1rem;text-decoration:none}ul.search{list-style:none;padding-left:0}ul.search li{border-bottom:1px solid var(--color-background-border);padding:1rem 0}[role=main] .highlighted{background-color:var(--color-highlighted-background);color:var(--color-highlighted-text)}.sidebar-brand{display:flex;flex-direction:column;flex-shrink:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none}.sidebar-brand-text{color:var(--color-sidebar-brand-text);font-size:1.5rem;overflow-wrap:break-word}.sidebar-brand-text,.sidebar-logo-container{margin:var(--sidebar-item-spacing-vertical) 0}.sidebar-logo{display:block;margin:0 auto;max-width:100%}.sidebar-search-container{align-items:center;background:var(--color-sidebar-search-background);display:flex;margin-top:var(--sidebar-search-space-above);position:relative}.sidebar-search-container:focus-within,.sidebar-search-container:hover{background:var(--color-sidebar-search-background--focus)}.sidebar-search-container:before{background-color:var(--color-sidebar-search-icon);content:"";height:var(--sidebar-search-icon-size);left:var(--sidebar-item-spacing-horizontal);-webkit-mask-image:var(--icon-search);mask-image:var(--icon-search);position:absolute;width:var(--sidebar-search-icon-size)}.sidebar-search{background:transparent;border:none;border-bottom:1px solid var(--color-sidebar-search-border);border-top:1px solid var(--color-sidebar-search-border);box-sizing:border-box;color:var(--color-sidebar-search-foreground);padding:var(--sidebar-search-input-spacing-vertical) var(--sidebar-search-input-spacing-horizontal) var(--sidebar-search-input-spacing-vertical) calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size));width:100%;z-index:10}.sidebar-search:focus{outline:none}.sidebar-search::-moz-placeholder{font-size:var(--sidebar-search-input-font-size)}.sidebar-search::placeholder{font-size:var(--sidebar-search-input-font-size)}#searchbox .highlight-link{margin:0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0;text-align:center}#searchbox .highlight-link a{color:var(--color-sidebar-search-icon);font-size:var(--font-size--small--2)}.sidebar-tree{font-size:var(--sidebar-item-font-size);margin-bottom:var(--sidebar-item-spacing-vertical);margin-top:var(--sidebar-tree-space-above)}.sidebar-tree ul{display:flex;flex-direction:column;list-style:none;margin-bottom:0;margin-top:0;padding:0}.sidebar-tree li{margin:0;position:relative}.sidebar-tree li>ul{margin-left:var(--sidebar-item-spacing-horizontal)}.sidebar-tree .icon,.sidebar-tree .reference{color:var(--color-sidebar-link-text)}.sidebar-tree .reference{box-sizing:border-box;display:inline-block;height:100%;line-height:var(--sidebar-item-line-height);overflow-wrap:anywhere;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-decoration:none;width:100%}.sidebar-tree .reference:hover{background:var(--color-sidebar-item-background--hover);color:var(--color-sidebar-link-text)}.sidebar-tree .reference.external:after{color:var(--color-sidebar-link-text);content:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23607d8b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' viewBox='0 0 24 24'%3E%3Cpath stroke='none' d='M0 0h24v24H0z'/%3E%3Cpath d='M11 7H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5M10 14 20 4M15 4h5v5'/%3E%3C/svg%3E");margin:0 .25rem;vertical-align:middle}.sidebar-tree .current-page>.reference{font-weight:700}.sidebar-tree label{align-items:center;cursor:pointer;display:flex;height:var(--sidebar-item-height);justify-content:center;position:absolute;right:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;width:var(--sidebar-expander-width)}.sidebar-tree .caption,.sidebar-tree :not(.caption)>.caption-text{color:var(--color-sidebar-caption-text);font-size:var(--sidebar-caption-font-size);font-weight:700;margin:var(--sidebar-caption-space-above) 0 0 0;padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal);text-transform:uppercase}.sidebar-tree li.has-children>.reference{padding-right:var(--sidebar-expander-width)}.sidebar-tree .toctree-l1>.reference,.sidebar-tree .toctree-l1>label .icon{color:var(--color-sidebar-link-text--top-level)}.sidebar-tree label{background:var(--color-sidebar-item-expander-background)}.sidebar-tree label:hover{background:var(--color-sidebar-item-expander-background--hover)}.sidebar-tree .current>.reference{background:var(--color-sidebar-item-background--current)}.sidebar-tree .current>.reference:hover{background:var(--color-sidebar-item-background--hover)}.toctree-checkbox{display:none;position:absolute}.toctree-checkbox~ul{display:none}.toctree-checkbox~label .icon svg{transform:rotate(90deg)}.toctree-checkbox:checked~ul{display:block}.toctree-checkbox:checked~label .icon svg{transform:rotate(-90deg)}.toc-title-container{padding:var(--toc-title-padding);padding-top:var(--toc-spacing-vertical)}.toc-title{color:var(--color-toc-title-text);font-size:var(--toc-title-font-size);padding-left:var(--toc-spacing-horizontal);text-transform:uppercase}.no-toc{display:none}.toc-tree-container{padding-bottom:var(--toc-spacing-vertical)}.toc-tree{border-left:1px solid var(--color-background-border);font-size:var(--toc-font-size);line-height:1.3;padding-left:calc(var(--toc-spacing-horizontal) - var(--toc-item-spacing-horizontal))}.toc-tree>ul>li:first-child{padding-top:0}.toc-tree>ul>li:first-child>ul{padding-left:0}.toc-tree>ul>li:first-child>a{display:none}.toc-tree ul{list-style-type:none;margin-bottom:0;margin-top:0;padding-left:var(--toc-item-spacing-horizontal)}.toc-tree li{padding-top:var(--toc-item-spacing-vertical)}.toc-tree li.scroll-current>.reference{color:var(--color-toc-item-text--active);font-weight:700}.toc-tree a.reference{color:var(--color-toc-item-text);overflow-wrap:anywhere;text-decoration:none}.toc-scroll{max-height:100vh;overflow-y:scroll}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here){background:rgba(255,0,0,.25);color:var(--color-problematic)}.contents:not(.this-will-duplicate-information-and-it-is-still-useful-here):before{content:"ERROR: Adding a table of contents in Furo-based documentation is unnecessary, and does not work well with existing styling. Add a 'this-will-duplicate-information-and-it-is-still-useful-here' class, if you want an escape hatch."}.text-align\:left>p{text-align:left}.text-align\:center>p{text-align:center}.text-align\:right>p{text-align:right} +/*# sourceMappingURL=furo.css.map*/ \ No newline at end of file diff --git a/docs/site/_static/styles/furo.css.map b/docs/site/_static/styles/furo.css.map new file mode 100644 index 0000000..db1dec1 --- /dev/null +++ b/docs/site/_static/styles/furo.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo.css","mappings":"AAAA,2EAA2E,CAU3E,KACE,gBAAiB,CACjB,6BACF,CASA,KACE,QACF,CAMA,KACE,aACF,CAOA,GACE,aAAc,CACd,cACF,CAUA,GACE,sBAAuB,CACvB,QAAS,CACT,gBACF,CAOA,IACE,+BAAiC,CACjC,aACF,CASA,EACE,4BACF,CAOA,YACE,kBAAmB,CACnB,yBAA0B,CAC1B,gCACF,CAMA,SAEE,kBACF,CAOA,cAGE,+BAAiC,CACjC,aACF,CAeA,QAEE,aAAc,CACd,aAAc,CACd,iBAAkB,CAClB,uBACF,CAEA,IACE,aACF,CAEA,IACE,SACF,CASA,IACE,iBACF,CAUA,sCAKE,mBAAoB,CACpB,cAAe,CACf,gBAAiB,CACjB,QACF,CAOA,aAEE,gBACF,CAOA,cAEE,mBACF,CAMA,gDAIE,yBACF,CAMA,wHAIE,iBAAkB,CAClB,SACF,CAMA,4GAIE,6BACF,CAMA,SACE,0BACF,CASA,OACE,qBAAsB,CACtB,aAAc,CACd,aAAc,CACd,cAAe,CACf,SAAU,CACV,kBACF,CAMA,SACE,uBACF,CAMA,SACE,aACF,CAOA,6BAEE,qBAAsB,CACtB,SACF,CAMA,kFAEE,WACF,CAOA,cACE,4BAA6B,CAC7B,mBACF,CAMA,yCACE,uBACF,CAOA,6BACE,yBAA0B,CAC1B,YACF,CASA,QACE,aACF,CAMA,QACE,iBACF,CAiBA,kBACE,YACF,CCvVA,aAcE,kEACE,uBAOF,WACE,iDAMF,kCACE,wBAEF,qCAEE,uBADA,uBACA,CAEF,SACE,wBAtBA,CCpBJ,iBAGE,qBAEA,sBACA,0BAFA,oBAHA,4BACA,oBAKA,6BAIA,2CAFA,mBACA,sCAFA,4BAGA,CAEF,gBACE,aCPF,KCCE,mHAGA,wGAGA,wCAAyC,CAEzC,wBAAyB,CACzB,wBAAyB,CACzB,4BAA6B,CAC7B,yBAA0B,CAC1B,2BAA4B,CAG5B,sDAAuD,CACvD,gDAAiD,CACjD,wDAAyD,CAGzD,0CAA2C,CAC3C,gDAAiD,CACjD,gDAAiD,CAKjD,gCAAiC,CACjC,sCAAuC,CAGvC,2CAA4C,CAG5C,uCAAwC,CCnCxC,+FAIA,uBAAwB,CAGxB,iCAAkC,CAClC,kCAAmC,CAEnC,+BAAgC,CAChC,sCAAuC,CACvC,sCAAuC,CACvC,qGAIA,mDAAoD,CAEpD,mCAAoC,CACpC,8CAA+C,CAC/C,gDAAiD,CACjD,kCAAmC,CACnC,6DAA8D,CAG9D,6BAA8B,CAC9B,6BAA8B,CAC9B,+BAAgC,CAChC,kCAAmC,CACnC,kCAAmC,CCRjC,+jBCaA,iqCAZF,iaCXA,8KAOA,4SAWA,4SAUA,0CACA,gEAGA,0CAGA,gEAGA,yCACA,+DAIA,4CACA,kEAGA,wCAUA,8DACA,uCAGA,4DACA,sCACA,2DAGA,4CACA,kEACA,uCAGA,6DACA,2GAGA,sHAEA,yFAEA,+CACA,+EAGA,4MAOA,gCACA,sHAIA,kCACA,uEACA,gEACA,4DACA,kEAGA,2DACA,sDACA,0CACA,8CACA,wGAGA,0BACA,iCAGA,+DACA,+BACA,sCACA,+DAEA,kGACA,oCACA,yDACA,sCL3HF,kCAEA,sDAIA,0CKyHE,kEAIA,oDACA,sDAGA,oCACA,oEAEA,0DACA,qDAIA,oDACA,6DAIA,iEAIA,2DAIA,2DAGA,4DACA,gEAIA,gEAEA,gFAEA,oNASA,qDLtKE,gFAGE,4DAIF,oEKgHF,yEAEA,6DAGA,0DAEA,uDACA,qDACA,wDAIA,6DAIA,yDACA,2DAIA,uCAGA,wCACA,sDAGA,+CAGA,6DAEA,iDACA,+DAEA,wDAEA,sEAMA,0DACA,sBACA,mEL5JI,wEAEA,iCACE,+BAMN,wEAGA,iCACE,kFAEA,uEAIF,gEACE,8BAGF,qEMzDA,sCAKA,wFAKA,iCAIA,0BAWA,iCACA,4BACA,mCAGA,+BAEA,sCACA,4BAEA,mCAEA,sCAKA,sDAIA,gCAEA,gEAQF,wCAME,sBACA,kCAKA,uBAEA,gEAIA,2BAIA,mCAEA,qCACA,iCAGE,+BACA,wEAEE,iCACA,kFAGF,6BACA,0CACF,kCAEE,8BACE,8BACA,qEAEE,sCACA,wFClFN,iCAGF,2DACE,4BACA,oCAKF,8BAGE,sCACA,+DAIA,sCAEA,sDAGA,gCACA,gEAGA,+CAEA,sBACE,yCAGF,uBACA,sEAIA,aAEA,mCAIA,kEACA,aACA,oEACA,YAIA,EAQE,4HAGA,gDACE,mBACA,wCAON,wCAGE,0DACA,mBAKA,mBACA,CANA,uCAKA,iBALA,iBAWA,mBAGF,mBACE,mDAIF,+BAEE,CAEA,yBAFA,kBAMA,CAJA,GACA,aAGA,mBAEF,wBAEE,iBACA,iBAEA,OACA,aAGF,CAHE,WAGF,GAEE,oBAEA,CAJF,gBAIE,aAEA,+CAKA,UANA,WACA,cADA,SAMA,WACA,iBAEE,GAMF,wBANE,yBAMF,kDACA,WAEA,gCACA,2DAGA,iBACE,uCAEJ,kEAIE,uCAGA,yDACE,cACA,+DAEA,yDAEE,mEAMJ,kEAMA,uBACA,kBAEA,uBACA,kDAKA,0DAIA,CALA,oBAKA,WACA,WAQA,4BAFF,0CAEE,CARA,qCAsBA,CAdA,iBAEA,kBACE,aADF,4BACE,WAMF,2BAGF,qCAEE,CAXE,UAWF,+BAGA,uBAEA,SAEA,0CAIE,CANF,qCAEA,CAIE,2DACE,gBAIN,+CAIA,CAEA,kDAKE,CAPF,8BAEA,CAOE,YACA,CAjBI,2BAGN,CAHM,WAcJ,UAGA,CAEA,2GAIF,iCAGE,8BAIA,qBACA,oBACF,uBAOI,0CAIA,CATF,6DAKE,CALF,sBASE,qCAKF,CACE,cACA,CAFF,sBAEE,CACA,+BAEA,qBAEE,WAKN,aACE,sCAGA,mBAEA,6BAMA,kCACA,CAJA,sBACA,aAEA,CAJA,eACA,MAIA,2FAEA,UAGA,YACA,sBACE,8BAEA,CALF,aACA,WAIE,OACA,oBAEF,uBACE,WAEF,YAFE,UAEF,eAgBA,kBACE,CAhBA,qDAQF,qCAGF,CAGI,YACF,CAJF,2BAGI,CAEA,eACA,qBAGA,mEAEA,qBACA,8BAIA,kBADF,kBACE,yBAEJ,oCAGI,qDAIJ,+BAGI,oCAEA,+CAQF,4CACE,yBACF,2BAOE,sBACA,CAHA,WACA,CAFF,cACE,CAJA,YAGF,CAEE,SAEA,mBAGA,kDAEE,CAJF,cAEA,cAEE,sBAEA,mBADA,YACA,uBACA,mDACE,CADF,YACE,iDAEA,uCAEN,+DAOE,mBADF,sBACE,mBAGF,aACE,sCAIA,aADF,WACE,CAKF,SACE,CAHJ,kBAEE,CAJE,gBAEJ,CAHI,iBAMA,yFAKA,aACA,eACA,cCxaJ,iBAEE,aADA,iBACA,6BAEA,kCAEA,SACA,UAIA,gCACA,CALA,SAEA,SAEA,CAJA,wEAEA,CAFA,OAKA,CAGA,mDACE,iBAGF,gCACE,CADF,UACE,aAEJ,iCAEE,CAFF,UAEE,wCAEA,WACA,WADA,UACA,CACA,4CAGA,MACA,CADA,KACA,wCACA,UAGA,CAJA,UAIA,6DAUA,0CACE,CAFF,mBAEE,wEACA,CAVA,YACA,CAMF,mBAJE,OAOA,gBAJJ,gCACE,CANE,cACA,CAHA,oBACA,CAGA,QAGJ,CAII,0BACA,CADA,UACA,wCAEJ,kBACE,0DACA,gCACE,kBACA,CADA,YACA,oEACA,2CAMF,mDAII,CALN,YACE,CANE,cAKJ,CACE,iBAII,kEACA,yCACE,kDACA,yDACE,+CACA,uBANN,CAMM,+BANN,uCACE,qDACA,4BAEE,mBADA,0CACA,CADA,qBACA,0DACE,wCACA,sGALJ,oCACA,sBACE,kBAFF,UAEE,2CACA,wFACE,cACA,kEANN,uBACE,iDACA,CADA,UACA,0DACE,wDAEE,iEACA,qEANN,sCACE,CAGE,iBAHF,gBAGE,qBACE,CAJJ,uBACA,gDACE,wDACA,6DAHF,2CACA,CADA,gBACA,eACE,CAGE,sBANN,8BACE,CAII,iBAFF,4DACA,WACE,YADF,uCACE,6EACA,2BANN,8CACE,kDACA,0CACE,8BACA,yFACE,sBACA,sFALJ,mEACA,sBACE,kEACA,6EACE,uCACA,kEALJ,qGAEE,kEACA,6EACE,uCACA,kEALJ,8CACA,uDACE,sEACA,2EACE,sCACA,iEALJ,mGACA,qCACE,oDACA,0DACE,6GACA,gDAGR,yDCvEA,sEACE,CACA,6GACE,gEACF,iGAIF,wFACE,qDAGA,mGAEE,2CAEF,4FACE,gCACF,wGACE,8DAEE,6FAIA,iJAKN,6GACE,gDAKF,yDACA,qCAGA,6BACA,kBACA,qDAKA,oCAEA,+DAGA,2CAGE,oDAIA,oEAEE,qBAEN,wDAEE,uCACE,kEAGJ,CACE,6CACA,uDAGF,CACE,mCAEF,yDAIE,gEAGA,CAEA,wHAIF,sDACE,+DAEE,sCAGF,8BACA,oCACE,oHAIF,gBACE,yGAIF,mBChHA,2MCDF,4HAQE,wKAOA,8HCbA,mBAEA,6HAIE,YACA,mIAaJ,gBAPE,YAOF,4FAKE,qDAuBE,sCACA,CAHA,oBAEA,CAbF,wCACE,CALF,8BAIA,CARE,eAIF,CAKE,mBAEF,qBAEE,CAIF,+BACE,mBACA,CAGA,kCACA,6BAIF,4CAIA,kDACE,6BACA,2BAGF,iBACE,mDAGA,8BACA,WAGJ,2BACE,cAGA,+BACA,CAHA,eAGA,wCACA,YACA,iBACA,uEAGA,0BACA,2CAEA,8EAGI,qBACA,CAFF,kBAEE,4DAMJ,mCACE,4BAGA,oBAGF,4CACE,qCACA,8BACA,gBACA,+CAEA,iCAEF,iCACE,oBACA,4CACA,qCAGF,8BAEE,+BAEA,WAEA,8BACE,oBACA,CADA,gBACA,yBAKF,gBADF,YACE,CACA,iBACA,qDAEA,mDCvIJ,2FAMA,iCACE,CACA,eAEA,CAFA,mBADA,wBAIA,8BACA,gBADA,YACA,0BAEE,8CAGA,wDAIE,gFAGE,iBAEN,wCAKF,+CACE,CACA,oDAEF,kDAIE,YAEF,CAHE,YAGF,CCpCE,mFAFA,QACA,UAIA,CAHA,IAGA,gDAGE,eACA,iEAGF,wBAEE,mBAMA,6CAEF,CAJE,mBACA,CAGF,kCAGE,CARF,kBACE,CAHA,eAUA,YACA,mBACA,CAFA,UAEA,wCC/BJ,mBACE,CDkCE,wBACA,sBCpCJ,iBACE,mDACA,2CACA,sBAGA,qBCDA,6CAIE,CATJ,uBAKE,CDGE,oBACF,yDAEE,CCDE,2CAGF,CAJA,kCACE,CDJJ,aAKE,eCXJ,CDME,uBCOE,gCACE,YAEF,2CAEE,wBACA,0BAIF,iBAEA,cADF,UACE,uBAEA,iCAEA,wCAEA,6CAMA,CAYF,gCATI,4BASJ,CAZE,mCAEE,iCAUJ,4BAGE,4DADA,+BACA,CAHF,qBAGE,sCACE,OAEF,iBAHA,SAGA,iHACE,2DAKF,CANA,8EAMA,uSAEE,kBAEF,+FACE,yCCjEJ,WACA,yBAGA,uBACA,gBAEA,uCAIA,CAJA,iCAIA,uCAGA,UACE,gBACA,qBAEA,0CClBJ,gBACE,KAGF,qBACE,YAGF,CAHE,cAGF,gCAEE,mBACA,iEAEA,oCACA,wCAEA,sBACA,WAEA,CAFA,YAEA,8EAEA,mCAFA,iBAEA,6BAIA,wEAKA,sDAIE,CARF,mDAIA,CAIE,cAEF,8CAIA,oBAFE,iBAEF,8CAGE,eAEF,CAFE,YAEF,OAEE,kBAGJ,CAJI,eACA,CAFF,mBAKF,yCCjDE,oBACA,CAFA,iBAEA,uCAKE,iBACA,qCAGA,mBCZJ,CDWI,gBCXJ,6BAEE,eACA,sBAGA,eAEA,sBACA,oDACA,iGAMA,gBAFE,YAEF,8FAME,iJCnBF,YACA,gNAWE,gDAEF,iSAaE,kBACE,gHAKF,oCACE,eACF,CADE,UACF,8CACE,gDACF,wCACE,oBCtCJ,oBAEF,6BACE,QACE,kDAGF,yBACE,kDAmBA,kDAEF,CAhBA,+CAaA,CAbA,oBAaA,0FACE,CADF,gGAfF,cACE,gBACA,CAaA,0BAGA,mQACE,gBAGF,oMACE,iBACA,CAFF,eACE,CADF,gBAEE,aAGJ,iCAEE,CAFF,wCAEE,wBAUE,+VAIE,uEAHA,2BAGA,wXAKJ,iDAGF,CARM,+CACE,iDAIN,CALI,gBAQN,mHACE,gBAGF,2DACE,0EAOA,0EAGF,gBAEE,6DCjFA,kDACA,gCACA,qDAGA,qBACA,qDCDA,cACA,eAEA,yBAGF,sBAEE,iBACA,sNAWA,iBACE,kBACA,wRAgBA,kBAEA,iOAgBA,uCACE,uEAEA,kBAEF,qUAuBE,iDAIJ,CACA,geCzFF,4BAEE,CAQA,6JACA,iDAIA,sEAGA,mDAOF,iDAGE,4DAIA,8CACA,qDAEE,eAFF,cAEE,oBAEF,uBAFE,kCAGA,eACA,iBACA,mBAIA,mDACA,CAHA,uCAEA,CAJA,0CACA,CAIA,gBAJA,gBACA,oBADA,gBAIA,wBAEJ,gBAGE,6BACA,YAHA,iBAGA,gCACA,iEAEA,6CACA,sDACA,0BADA,wBACA,0BACA,oIAIA,mBAFA,YAEA,qBACA,0CAIE,uBAEF,CAHA,yBACE,CAEF,iDACE,mFAKJ,oCACE,CANE,aAKJ,CACE,qEAIA,YAFA,WAEA,CAHA,aACA,CAEA,gBACE,4BACA,sBADA,aACA,gCAMF,oCACA,yDACA,2CAEA,qBAGE,kBAEA,CACA,mCAIF,CARE,YACA,CAOF,iCAEE,CAPA,oBACA,CAQA,oBACE,uDAEJ,sDAGA,CAHA,cAGA,0BACE,oDAIA,oCACA,4BACA,sBAGA,cAEA,oFAGA,sBAEA,yDACE,CAIF,iBAJE,wBAIF,6CAHE,6CAKA,eACA,aACA,CADA,cACA,yCAGJ,kBACE,CAKA,iDAEA,CARF,aACE,4CAGA,kBAIA,wEAGA,wDAGA,kCAOA,iDAGA,CAPF,WAEE,sCAEA,CAJF,2CACE,CAMA,qCACA,+BARF,kBACE,qCAOA,iBAsBA,sBACE,CAvBF,WAKA,CACE,0DAIF,CALA,uDACE,CANF,sBAqBA,4CACA,CALA,gRAIA,YAEE,6CAEN,mCAEE,+CASA,6EAIA,4BChNA,SDmNA,qFCnNA,gDACA,sCAGA,qCACA,sDACA,CAKA,kDAGA,CARA,0CAQA,kBAGA,YACA,sBACA,iBAFA,gBADF,YACE,CAHA,SAKA,kBAEA,SAFA,iBAEA,uEAGA,CAEE,6CAFF,oCAgBI,CAdF,yBACE,qBACF,CAGF,oBACE,CAIF,WACE,CALA,2CAGA,uBACF,CACE,mFAGE,CALF,qBAEA,UAGE,gCAIF,sDAEA,CALE,oCAKF,yCC7CJ,oCACE,CD+CA,yXAQE,sCCrDJ,wCAGA,oCACE","sources":["webpack:///./node_modules/normalize.css/normalize.css","webpack:///./src/furo/assets/styles/base/_print.sass","webpack:///./src/furo/assets/styles/base/_screen-readers.sass","webpack:///./src/furo/assets/styles/base/_theme.sass","webpack:///./src/furo/assets/styles/variables/_fonts.scss","webpack:///./src/furo/assets/styles/variables/_spacing.scss","webpack:///./src/furo/assets/styles/variables/_icons.scss","webpack:///./src/furo/assets/styles/variables/_admonitions.scss","webpack:///./src/furo/assets/styles/variables/_colors.scss","webpack:///./src/furo/assets/styles/base/_typography.sass","webpack:///./src/furo/assets/styles/_scaffold.sass","webpack:///./src/furo/assets/styles/content/_admonitions.sass","webpack:///./src/furo/assets/styles/content/_api.sass","webpack:///./src/furo/assets/styles/content/_blocks.sass","webpack:///./src/furo/assets/styles/content/_captions.sass","webpack:///./src/furo/assets/styles/content/_code.sass","webpack:///./src/furo/assets/styles/content/_footnotes.sass","webpack:///./src/furo/assets/styles/content/_images.sass","webpack:///./src/furo/assets/styles/content/_indexes.sass","webpack:///./src/furo/assets/styles/content/_lists.sass","webpack:///./src/furo/assets/styles/content/_math.sass","webpack:///./src/furo/assets/styles/content/_misc.sass","webpack:///./src/furo/assets/styles/content/_rubrics.sass","webpack:///./src/furo/assets/styles/content/_sidebar.sass","webpack:///./src/furo/assets/styles/content/_tables.sass","webpack:///./src/furo/assets/styles/content/_target.sass","webpack:///./src/furo/assets/styles/content/_gui-labels.sass","webpack:///./src/furo/assets/styles/components/_footer.sass","webpack:///./src/furo/assets/styles/components/_sidebar.sass","webpack:///./src/furo/assets/styles/components/_table_of_contents.sass","webpack:///./src/furo/assets/styles/_shame.sass"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","// This file contains styles for managing print media.\n\n////////////////////////////////////////////////////////////////////////////////\n// Hide elements not relevant to print media.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Hide icon container.\n .content-icon-container\n display: none !important\n\n // Hide showing header links if hovering over when printing.\n .headerlink\n display: none !important\n\n // Hide mobile header.\n .mobile-header\n display: none !important\n\n // Hide navigation links.\n .related-pages\n display: none !important\n\n////////////////////////////////////////////////////////////////////////////////\n// Tweaks related to decolorization.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n // Apply a border around code which no longer have a color background.\n .highlight\n border: 0.1pt solid var(--color-foreground-border)\n\n////////////////////////////////////////////////////////////////////////////////\n// Avoid page break in some relevant cases.\n////////////////////////////////////////////////////////////////////////////////\n@media print\n ul, ol, dl, a, table, pre, blockquote, p\n page-break-inside: avoid\n\n h1, h2, h3, h4, h5, h6, img, figure, caption\n page-break-inside: avoid\n page-break-after: avoid\n\n ul, ol, dl\n page-break-before: avoid\n",".visually-hidden\n position: absolute !important\n width: 1px !important\n height: 1px !important\n padding: 0 !important\n margin: -1px !important\n overflow: hidden !important\n clip: rect(0,0,0,0) !important\n white-space: nowrap !important\n border: 0 !important\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\n:-moz-focusring\n outline: auto\n","// This file serves as the \"skeleton\" of the theming logic.\n//\n// This contains the bulk of the logic for handling dark mode, color scheme\n// toggling and the handling of color-scheme-specific hiding of elements.\n\n@use \"../variables\" as *\n\nbody\n @include fonts\n @include spacing\n @include icons\n @include admonitions\n @include default-admonition(#651fff, \"abstract\")\n @include default-topic(#14B8A6, \"pencil\")\n\n @include colors\n\n.only-light\n display: block !important\nhtml body .only-dark\n display: none !important\n\n// Ignore dark-mode hints if print media.\n@media not print\n // Enable dark-mode, if requested.\n body[data-theme=\"dark\"]\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n // Enable dark mode, unless explicitly told to avoid.\n @media (prefers-color-scheme: dark)\n body:not([data-theme=\"light\"])\n @include colors-dark\n\n html & .only-light\n display: none !important\n .only-dark\n display: block !important\n\n//\n// Theme toggle presentation\n//\nbody[data-theme=\"auto\"]\n .theme-toggle svg.theme-icon-when-auto-light\n display: block\n\n @media (prefers-color-scheme: dark)\n .theme-toggle svg.theme-icon-when-auto-dark\n display: block\n .theme-toggle svg.theme-icon-when-auto-light\n display: none\n\nbody[data-theme=\"dark\"]\n .theme-toggle svg.theme-icon-when-dark\n display: block\n\nbody[data-theme=\"light\"]\n .theme-toggle svg.theme-icon-when-light\n display: block\n","// Fonts used by this theme.\n//\n// There are basically two things here -- using the system font stack and\n// defining sizes for various elements in %ages. We could have also used `em`\n// but %age is easier to reason about for me.\n\n@mixin fonts {\n // These are adapted from https://systemfontstack.com/\n --font-stack:\n -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif,\n Apple Color Emoji, Segoe UI Emoji;\n --font-stack--monospace:\n \"SFMono-Regular\", Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,\n monospace;\n --font-stack--headings: var(--font-stack);\n\n --font-size--normal: 100%;\n --font-size--small: 87.5%;\n --font-size--small--2: 81.25%;\n --font-size--small--3: 75%;\n --font-size--small--4: 62.5%;\n\n // Sidebar\n --sidebar-caption-font-size: var(--font-size--small--2);\n --sidebar-item-font-size: var(--font-size--small);\n --sidebar-search-input-font-size: var(--font-size--small);\n\n // Table of Contents\n --toc-font-size: var(--font-size--small--3);\n --toc-font-size--mobile: var(--font-size--normal);\n --toc-title-font-size: var(--font-size--small--4);\n\n // Admonitions\n //\n // These aren't defined in terms of %ages, since nesting these is permitted.\n --admonition-font-size: 0.8125rem;\n --admonition-title-font-size: 0.8125rem;\n\n // Code\n --code-font-size: var(--font-size--small--2);\n\n // API\n --api-font-size: var(--font-size--small);\n}\n","// Spacing for various elements on the page\n//\n// If the user wants to tweak things in a certain way, they are permitted to.\n// They also have to deal with the consequences though!\n\n@mixin spacing {\n // Header!\n --header-height: calc(\n var(--sidebar-item-line-height) + 4 *\n #{var(--sidebar-item-spacing-vertical)}\n );\n --header-padding: 0.5rem;\n\n // Sidebar\n --sidebar-tree-space-above: 1.5rem;\n --sidebar-caption-space-above: 1rem;\n\n --sidebar-item-line-height: 1rem;\n --sidebar-item-spacing-vertical: 0.5rem;\n --sidebar-item-spacing-horizontal: 1rem;\n --sidebar-item-height: calc(\n var(--sidebar-item-line-height) + 2 *#{var(--sidebar-item-spacing-vertical)}\n );\n\n --sidebar-expander-width: var(--sidebar-item-height); // be square\n\n --sidebar-search-space-above: 0.5rem;\n --sidebar-search-input-spacing-vertical: 0.5rem;\n --sidebar-search-input-spacing-horizontal: 0.5rem;\n --sidebar-search-input-height: 1rem;\n --sidebar-search-icon-size: var(--sidebar-search-input-height);\n\n // Table of Contents\n --toc-title-padding: 0.25rem 0;\n --toc-spacing-vertical: 1.5rem;\n --toc-spacing-horizontal: 1.5rem;\n --toc-item-spacing-vertical: 0.4rem;\n --toc-item-spacing-horizontal: 1rem;\n}\n","// Expose theme icons as CSS variables.\n\n$icons: (\n // Adapted from tabler-icons\n // url: https://tablericons.com/\n \"search\":\n url('data:image/svg+xml;charset=utf-8,'),\n // Factored out from mkdocs-material on 24-Aug-2020.\n // url: https://squidfunk.github.io/mkdocs-material/reference/admonitions/\n \"pencil\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"abstract\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"info\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"flame\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"question\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"warning\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"failure\":\n url('data:image/svg+xml;charset=utf-8,'),\n \"spark\":\n url('data:image/svg+xml;charset=utf-8,')\n);\n\n@mixin icons {\n @each $name, $glyph in $icons {\n --icon-#{$name}: #{$glyph};\n }\n}\n","@use \"sass:list\";\n// Admonitions\n\n// Structure of these is:\n// admonition-class: color \"icon-name\";\n//\n// The colors are translated into CSS variables below. The icons are\n// used directly in the main declarations to set the `mask-image` in\n// the title.\n\n// prettier-ignore\n$admonitions: (\n // Each of these has an reST directives for it.\n \"caution\": #ff9100 \"spark\",\n \"warning\": #ff9100 \"warning\",\n \"danger\": #ff5252 \"spark\",\n \"attention\": #ff5252 \"warning\",\n \"error\": #ff5252 \"failure\",\n \"hint\": #00c852 \"question\",\n \"tip\": #00c852 \"info\",\n \"important\": #00bfa5 \"flame\",\n \"note\": #00b0ff \"pencil\",\n \"seealso\": #448aff \"info\",\n \"admonition-todo\": #808080 \"pencil\"\n);\n\n@mixin default-admonition($color, $icon-name) {\n --color-admonition-title: #{$color};\n --color-admonition-title-background: #{rgba($color, 0.2)};\n\n --icon-admonition-default: var(--icon-#{$icon-name});\n}\n\n@mixin default-topic($color, $icon-name) {\n --color-topic-title: #{$color};\n --color-topic-title-background: #{rgba($color, 0.2)};\n\n --icon-topic-default: var(--icon-#{$icon-name});\n}\n\n@mixin admonitions {\n @each $name, $values in $admonitions {\n --color-admonition-title--#{$name}: #{list.nth($values, 1)};\n --color-admonition-title-background--#{$name}: #{rgba(\n list.nth($values, 1),\n 0.2\n )};\n }\n}\n","// Colors used throughout this theme.\n//\n// The aim is to give the user more control. Thus, instead of hard-coding colors\n// in various parts of the stylesheet, the approach taken is to define all\n// colors as CSS variables and reusing them in all the places.\n//\n// `colors-dark` depends on `colors` being included at a lower specificity.\n\n@mixin colors {\n --color-problematic: #b30000;\n\n // Base Colors\n --color-foreground-primary: black; // for main text and headings\n --color-foreground-secondary: #5a5c63; // for secondary text\n --color-foreground-muted: #6b6f76; // for muted text\n --color-foreground-border: #878787; // for content borders\n\n --color-background-primary: white; // for content\n --color-background-secondary: #f8f9fb; // for navigation + ToC\n --color-background-hover: #efeff4ff; // for navigation-item hover\n --color-background-hover--transparent: #efeff400;\n --color-background-border: #eeebee; // for UI borders\n --color-background-item: #ccc; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #0a4bff;\n --color-brand-content: #2757dd;\n --color-brand-visited: #872ee0;\n\n // API documentation\n --color-api-background: var(--color-background-hover--transparent);\n --color-api-background-hover: var(--color-background-hover);\n --color-api-overall: var(--color-foreground-secondary);\n --color-api-name: var(--color-problematic);\n --color-api-pre-name: var(--color-problematic);\n --color-api-paren: var(--color-foreground-secondary);\n --color-api-keyword: var(--color-foreground-primary);\n\n --color-api-added: #21632c;\n --color-api-added-border: #38a84d;\n --color-api-changed: #046172;\n --color-api-changed-border: #06a1bc;\n --color-api-deprecated: #605706;\n --color-api-deprecated-border: #f0d90f;\n --color-api-removed: #b30000;\n --color-api-removed-border: #ff5c5c;\n\n --color-highlight-on-target: #ffffcc;\n\n // Inline code background\n --color-inline-code-background: var(--color-background-secondary);\n\n // Highlighted text (search)\n --color-highlighted-background: #ddeeff;\n --color-highlighted-text: var(--color-foreground-primary);\n\n // GUI Labels\n --color-guilabel-background: #ddeeff80;\n --color-guilabel-border: #bedaf580;\n --color-guilabel-text: var(--color-foreground-primary);\n\n // Admonitions!\n --color-admonition-background: transparent;\n\n //////////////////////////////////////////////////////////////////////////////\n // Everything below this should be one of:\n // - var(...)\n // - *-gradient(...)\n // - special literal values (eg: transparent, none)\n //////////////////////////////////////////////////////////////////////////////\n\n // Tables\n --color-table-header-background: var(--color-background-secondary);\n --color-table-border: var(--color-background-border);\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: transparent;\n --color-card-marginals-background: var(--color-background-secondary);\n\n // Header\n --color-header-background: var(--color-background-primary);\n --color-header-border: var(--color-background-border);\n --color-header-text: var(--color-foreground-primary);\n\n // Sidebar (left)\n --color-sidebar-background: var(--color-background-secondary);\n --color-sidebar-background-border: var(--color-background-border);\n\n --color-sidebar-brand-text: var(--color-foreground-primary);\n --color-sidebar-caption-text: var(--color-foreground-muted);\n --color-sidebar-link-text: var(--color-foreground-secondary);\n --color-sidebar-link-text--top-level: var(--color-brand-primary);\n\n --color-sidebar-item-background: var(--color-sidebar-background);\n --color-sidebar-item-background--current: var(\n --color-sidebar-item-background\n );\n --color-sidebar-item-background--hover: linear-gradient(\n 90deg,\n var(--color-background-hover--transparent) 0%,\n var(--color-background-hover) var(--sidebar-item-spacing-horizontal),\n var(--color-background-hover) 100%\n );\n\n --color-sidebar-item-expander-background: transparent;\n --color-sidebar-item-expander-background--hover: var(\n --color-background-hover\n );\n\n --color-sidebar-search-text: var(--color-foreground-primary);\n --color-sidebar-search-background: var(--color-background-secondary);\n --color-sidebar-search-background--focus: var(--color-background-primary);\n --color-sidebar-search-border: var(--color-background-border);\n --color-sidebar-search-icon: var(--color-foreground-muted);\n\n // Table of Contents (right)\n --color-toc-background: var(--color-background-primary);\n --color-toc-title-text: var(--color-foreground-muted);\n --color-toc-item-text: var(--color-foreground-secondary);\n --color-toc-item-text--hover: var(--color-foreground-primary);\n --color-toc-item-text--active: var(--color-brand-primary);\n\n // Actual page contents\n --color-content-foreground: var(--color-foreground-primary);\n --color-content-background: transparent;\n\n // Links\n --color-link: var(--color-brand-content);\n --color-link-underline: var(--color-background-border);\n --color-link--hover: var(--color-brand-content);\n --color-link-underline--hover: var(--color-foreground-border);\n\n --color-link--visited: var(--color-brand-visited);\n --color-link-underline--visited: var(--color-background-border);\n --color-link--visited--hover: var(--color-brand-visited);\n --color-link-underline--visited--hover: var(--color-foreground-border);\n}\n\n@mixin colors-dark {\n --color-problematic: #ee5151;\n\n // Base Colors\n --color-foreground-primary: #cfd0d0; // for main text and headings\n --color-foreground-secondary: #9ca0a5; // for secondary text\n --color-foreground-muted: #81868d; // for muted text\n --color-foreground-border: #666666; // for content borders\n\n --color-background-primary: #131416; // for content\n --color-background-secondary: #1a1c1e; // for navigation + ToC\n --color-background-hover: #1e2124ff; // for navigation-item hover\n --color-background-hover--transparent: #1e212400;\n --color-background-border: #303335; // for UI borders\n --color-background-item: #444; // for \"background\" items (eg: copybutton)\n\n // Announcements\n --color-announcement-background: #000000dd;\n --color-announcement-text: #eeebee;\n\n // Brand colors\n --color-brand-primary: #3d94ff;\n --color-brand-content: #5ca5ff;\n --color-brand-visited: #b27aeb;\n\n // Highlighted text (search)\n --color-highlighted-background: #083563;\n\n // GUI Labels\n --color-guilabel-background: #08356380;\n --color-guilabel-border: #13395f80;\n\n // API documentation\n --color-api-keyword: var(--color-foreground-secondary);\n --color-highlight-on-target: #333300;\n\n --color-api-added: #3db854;\n --color-api-added-border: #267334;\n --color-api-changed: #09b0ce;\n --color-api-changed-border: #056d80;\n --color-api-deprecated: #b1a10b;\n --color-api-deprecated-border: #6e6407;\n --color-api-removed: #ff7575;\n --color-api-removed-border: #b03b3b;\n\n // Admonitions\n --color-admonition-background: #18181a;\n\n // Cards\n --color-card-border: var(--color-background-secondary);\n --color-card-background: #18181a;\n --color-card-marginals-background: var(--color-background-hover);\n}\n","// This file contains the styling for making the content throughout the page,\n// including fonts, paragraphs, headings and spacing among these elements.\n\nbody\n font-family: var(--font-stack)\npre,\ncode,\nkbd,\nsamp\n font-family: var(--font-stack--monospace)\n\n// Make fonts look slightly nicer.\nbody\n -webkit-font-smoothing: antialiased\n -moz-osx-font-smoothing: grayscale\n\n// Line height from Bootstrap 4.1\narticle\n line-height: 1.5\n\n//\n// Headings\n//\nh1,\nh2,\nh3,\nh4,\nh5,\nh6\n line-height: 1.25\n font-family: var(--font-stack--headings)\n font-weight: bold\n\n border-radius: 0.5rem\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n margin-left: -0.5rem\n margin-right: -0.5rem\n padding-left: 0.5rem\n padding-right: 0.5rem\n\n + p\n margin-top: 0\n\nh1\n font-size: 2.5em\n margin-top: 1.75rem\n margin-bottom: 1rem\nh2\n font-size: 2em\n margin-top: 1.75rem\nh3\n font-size: 1.5em\nh4\n font-size: 1.25em\nh5\n font-size: 1.125em\nh6\n font-size: 1em\n\nsmall\n opacity: 75%\n font-size: 80%\n\n// Paragraph\np\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n\n// Horizontal rules\nhr.docutils\n height: 1px\n padding: 0\n margin: 2rem 0\n background-color: var(--color-background-border)\n border: 0\n\n.centered\n text-align: center\n\n// Links\na\n text-decoration: underline\n\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n &:visited\n color: var(--color-link--visited)\n text-decoration-color: var(--color-link-underline--visited)\n &:hover\n color: var(--color-link--visited--hover)\n text-decoration-color: var(--color-link-underline--visited--hover)\n\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &.muted-link\n color: inherit\n &:hover\n color: var(--color-link--hover)\n text-decoration-color: var(--color-link-underline--hover)\n &:visited\n color: var(--color-link--visited--hover)\n text-decoration-color: var(--color-link-underline--visited--hover)\n","// This file contains the styles for the overall layouting of the documentation\n// skeleton, including the responsive changes as well as sidebar toggles.\n//\n// This is implemented as a mobile-last design, which isn't ideal, but it is\n// reasonably good-enough and I got pretty tired by the time I'd finished this\n// to move the rules around to fix this. Shouldn't take more than 3-4 hours,\n// if you know what you're doing tho.\n\n// HACK: Not all browsers account for the scrollbar width in media queries.\n// This results in horizontal scrollbars in the breakpoint where we go\n// from displaying everything to hiding the ToC. We accomodate for this by\n// adding a bit of padding to the TOC drawer, disabling the horizontal\n// scrollbar and allowing the scrollbars to cover the padding.\n// https://www.456bereastreet.com/archive/201301/media_query_width_and_vertical_scrollbars/\n\n// HACK: Always having the scrollbar visible, prevents certain browsers from\n// causing the content to stutter horizontally between taller-than-viewport and\n// not-taller-than-viewport pages.\n@use \"variables\" as *\n\nhtml\n overflow-x: hidden\n overflow-y: scroll\n scroll-behavior: smooth\n\n.sidebar-scroll, .toc-scroll, article[role=main] *\n scrollbar-width: thin\n scrollbar-color: var(--color-foreground-border) transparent\n\n//\n// Overalls\n//\nhtml,\nbody\n height: 100%\n color: var(--color-foreground-primary)\n background: var(--color-background-primary)\n\n.skip-to-content\n position: fixed\n padding: 1rem\n border-radius: 1rem\n left: 0.25rem\n top: 0.25rem\n z-index: 40\n background: var(--color-background-primary)\n color: var(--color-foreground-primary)\n\n transform: translateY(-200%)\n transition: transform 300ms ease-in-out\n\n &:focus-within\n transform: translateY(0%)\n\narticle\n color: var(--color-content-foreground)\n background: var(--color-content-background)\n overflow-wrap: break-word\n\n.page\n display: flex\n // fill the viewport for pages with little content.\n min-height: 100%\n\n.mobile-header\n width: 100%\n height: var(--header-height)\n background-color: var(--color-header-background)\n color: var(--color-header-text)\n border-bottom: 1px solid var(--color-header-border)\n\n // Looks like sub-script/super-script have this, and we need this to\n // be \"on top\" of those.\n z-index: 10\n\n // We don't show the header on large screens.\n display: none\n\n // Add shadow when scrolled\n &.scrolled\n border-bottom: none\n box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.1), 0 0.2rem 0.4rem rgba(0, 0, 0, 0.2)\n\n .header-center\n a\n color: var(--color-header-text)\n text-decoration: none\n\n.main\n display: flex\n flex: 1\n\n// Sidebar (left) also covers the entire left portion of screen.\n.sidebar-drawer\n box-sizing: border-box\n\n border-right: 1px solid var(--color-sidebar-background-border)\n background: var(--color-sidebar-background)\n\n display: flex\n justify-content: flex-end\n // These next two lines took me two days to figure out.\n width: calc((100% - #{$full-width}) / 2 + #{$sidebar-width})\n min-width: $sidebar-width\n\n// Scroll-along sidebars\n.sidebar-container,\n.toc-drawer\n box-sizing: border-box\n width: $sidebar-width\n\n.toc-drawer\n background: var(--color-toc-background)\n // See HACK described on top of this document\n padding-right: 1rem\n\n.sidebar-sticky,\n.toc-sticky\n position: sticky\n top: 0\n height: min(100%, 100vh)\n height: 100vh\n\n display: flex\n flex-direction: column\n\n.sidebar-scroll,\n.toc-scroll\n flex-grow: 1\n flex-shrink: 1\n\n overflow: auto\n scroll-behavior: smooth\n\n// Central items.\n.content\n padding: 0 $content-padding\n width: $content-width\n\n display: flex\n flex-direction: column\n justify-content: space-between\n\n.icon\n display: inline-block\n height: 1rem\n width: 1rem\n svg\n width: 100%\n height: 100%\n\n//\n// Accommodate announcement banner\n//\n.announcement\n background-color: var(--color-announcement-background)\n color: var(--color-announcement-text)\n\n height: var(--header-height)\n display: flex\n align-items: center\n overflow-x: auto\n & + .page\n min-height: calc(100% - var(--header-height))\n\n.announcement-content\n box-sizing: border-box\n padding: 0.5rem\n min-width: 100%\n white-space: nowrap\n text-align: center\n\n a\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-announcement-text)\n\n &:hover\n color: var(--color-announcement-text)\n text-decoration-color: var(--color-link--hover)\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for theme\n////////////////////////////////////////////////////////////////////////////////\n.no-js .theme-toggle-container // don't show theme toggle if there's no JS\n display: none\n\n.theme-toggle-container\n display: flex\n\n.theme-toggle\n display: flex\n cursor: pointer\n border: none\n padding: 0\n background: transparent\n\n.theme-toggle svg\n height: 1.25rem\n width: 1.25rem\n color: var(--color-foreground-primary)\n display: none\n\n.theme-toggle-header\n display: flex\n align-items: center\n justify-content: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Toggles for elements\n////////////////////////////////////////////////////////////////////////////////\n.toc-overlay-icon, .nav-overlay-icon\n display: none\n cursor: pointer\n\n .icon\n color: var(--color-foreground-secondary)\n height: 1.5rem\n width: 1.5rem\n\n.toc-header-icon, .nav-overlay-icon\n // for when we set display: flex\n justify-content: center\n align-items: center\n\n.toc-content-icon\n height: 1.5rem\n width: 1.5rem\n\n.content-icon-container\n float: right\n display: flex\n margin-top: 1.5rem\n margin-left: 1rem\n margin-bottom: 1rem\n gap: 0.5rem\n\n .edit-this-page, .view-this-page\n svg\n color: inherit\n height: 1.25rem\n width: 1.25rem\n\n.sidebar-toggle\n position: absolute\n display: none\n// \n.sidebar-toggle[name=\"__toc\"]\n left: 20px\n.sidebar-toggle:checked\n left: 40px\n// \n\n.overlay\n position: fixed\n top: 0\n width: 0\n height: 0\n\n transition: width 0ms, height 0ms, opacity 250ms ease-out\n\n opacity: 0\n background-color: rgba(0, 0, 0, 0.54)\n.sidebar-overlay\n z-index: 20\n.toc-overlay\n z-index: 40\n\n// Keep things on top and smooth.\n.sidebar-drawer\n z-index: 30\n transition: left 250ms ease-in-out\n.toc-drawer\n z-index: 50\n transition: right 250ms ease-in-out\n\n// Show the Sidebar\n#__navigation:checked\n & ~ .sidebar-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .sidebar-drawer\n top: 0\n left: 0\n // Show the toc sidebar\n#__toc:checked\n & ~ .toc-overlay\n width: 100%\n height: 100%\n opacity: 1\n & ~ .page\n .toc-drawer\n top: 0\n right: 0\n\n////////////////////////////////////////////////////////////////////////////////\n// Back to top\n////////////////////////////////////////////////////////////////////////////////\n.back-to-top\n text-decoration: none\n\n display: none\n position: fixed\n left: 0\n top: 1rem\n padding: 0.5rem\n padding-right: 0.75rem\n border-radius: 1rem\n font-size: 0.8125rem\n\n background: var(--color-background-primary)\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), #6b728080 0px 0px 1px 0px\n\n z-index: 10\n\n margin-left: 50%\n transform: translateX(-50%)\n svg\n height: 1rem\n width: 1rem\n fill: currentColor\n display: inline-block\n\n span\n margin-left: 0.25rem\n\n .show-back-to-top &\n display: flex\n align-items: center\n\n////////////////////////////////////////////////////////////////////////////////\n// Responsive layouting\n////////////////////////////////////////////////////////////////////////////////\n// Make things a bit bigger on bigger screens.\n@media (min-width: $full-width + $sidebar-width)\n html\n font-size: 110%\n\n@media (max-width: $full-width)\n // Collapse \"toc\" into the icon.\n .toc-content-icon\n display: flex\n .toc-drawer\n position: fixed\n height: 100vh\n top: 0\n right: -$sidebar-width\n border-left: 1px solid var(--color-background-muted)\n .toc-tree\n border-left: none\n font-size: var(--toc-font-size--mobile)\n\n // Accomodate for a changed content width.\n .sidebar-drawer\n width: calc((100% - #{$full-width - $sidebar-width}) / 2 + #{$sidebar-width})\n\n@media (max-width: $content-padded-width + $sidebar-width)\n // Center the page\n .content\n margin-left: auto\n margin-right: auto\n padding: 0 $content-padding--small\n\n@media (max-width: $content-padded-width--small + $sidebar-width)\n // Collapse \"navigation\".\n .nav-overlay-icon\n display: flex\n .sidebar-drawer\n position: fixed\n height: 100vh\n width: $sidebar-width\n\n top: 0\n left: -$sidebar-width\n\n // Swap which icon is visible.\n .toc-header-icon, .theme-toggle-header\n display: flex\n .toc-content-icon, .theme-toggle-content\n display: none\n\n // Show the header.\n .mobile-header\n position: sticky\n top: 0\n display: flex\n justify-content: space-between\n align-items: center\n\n .header-left,\n .header-right\n display: flex\n height: var(--header-height)\n padding: 0 var(--header-padding)\n label\n height: 100%\n width: 100%\n user-select: none\n\n .nav-overlay-icon .icon,\n .theme-toggle svg\n height: 1.5rem\n width: 1.5rem\n\n // Add a scroll margin for the content\n :target\n scroll-margin-top: calc(var(--header-height) + 2.5rem)\n\n // Show back-to-top below the header\n .back-to-top\n top: calc(var(--header-height) + 0.5rem)\n\n // Accommodate for the header.\n .page\n flex-direction: column\n justify-content: center\n\n@media (max-width: $content-width + 2* $content-padding--small)\n // Content should respect window limits.\n .content\n width: 100%\n overflow-x: auto\n\n@media (max-width: $content-width)\n article[role=main] aside.sidebar\n float: none\n width: 100%\n margin: 1rem 0\n","@use \"sass:list\"\n@use \"../variables\" as *\n\n// The design here is strongly inspired by mkdocs-material.\n.admonition, .topic\n margin: 1rem auto\n padding: 0 0.5rem 0.5rem 0.5rem\n\n background: var(--color-admonition-background)\n\n border-radius: 0.2rem\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n font-size: var(--admonition-font-size)\n\n overflow: hidden\n page-break-inside: avoid\n\n // First element should have no margin, since the title has it.\n > :nth-child(2)\n margin-top: 0\n\n // Last item should have no margin, since we'll control that w/ padding\n > :last-child\n margin-bottom: 0\n\n.admonition p.admonition-title,\np.topic-title\n position: relative\n margin: 0 -0.5rem 0.5rem\n padding-left: 2rem\n padding-right: .5rem\n padding-top: .4rem\n padding-bottom: .4rem\n\n font-weight: 500\n font-size: var(--admonition-title-font-size)\n line-height: 1.3\n\n // Our fancy icon\n &::before\n content: \"\"\n position: absolute\n left: 0.5rem\n width: 1rem\n height: 1rem\n\n// Default styles\np.admonition-title\n background-color: var(--color-admonition-title-background)\n &::before\n background-color: var(--color-admonition-title)\n mask-image: var(--icon-admonition-default)\n mask-repeat: no-repeat\n\np.topic-title\n background-color: var(--color-topic-title-background)\n &::before\n background-color: var(--color-topic-title)\n mask-image: var(--icon-topic-default)\n mask-repeat: no-repeat\n\n//\n// Variants\n//\n.admonition\n border-left: 0.2rem solid var(--color-admonition-title)\n\n @each $type, $value in $admonitions\n &.#{$type}\n border-left-color: var(--color-admonition-title--#{$type})\n > .admonition-title\n background-color: var(--color-admonition-title-background--#{$type})\n &::before\n background-color: var(--color-admonition-title--#{$type})\n mask-image: var(--icon-#{list.nth($value, 2)})\n\n.admonition-todo > .admonition-title\n text-transform: uppercase\n","// This file stylizes the API documentation (stuff generated by autodoc). It's\n// deeply nested due to how autodoc structures the HTML without enough classes\n// to select the relevant items.\n\n// API docs!\ndl[class]:not(.option-list):not(.field-list):not(.footnote):not(.glossary):not(.simple)\n // Tweak the spacing of all the things!\n dd\n margin-left: 2rem\n > :first-child\n margin-top: 0.125rem\n > :last-child\n margin-bottom: 0.75rem\n\n // This is used for the arguments\n .field-list\n margin-bottom: 0.75rem\n\n // \"Headings\" (like \"Parameters\" and \"Return\")\n > dt\n text-transform: uppercase\n font-size: var(--font-size--small)\n\n dd:empty\n margin-bottom: 0.5rem\n dd > ul\n margin-left: -1.2rem\n > li\n > p:nth-child(2)\n margin-top: 0\n // When the last-empty-paragraph follows a paragraph, it doesn't need\n // to augument the existing spacing.\n > p + p:last-child:empty\n margin-top: 0\n margin-bottom: 0\n\n // Colorize the elements\n > dt\n color: var(--color-api-overall)\n\n.sig:not(.sig-inline)\n font-weight: bold\n\n font-size: var(--api-font-size)\n font-family: var(--font-stack--monospace)\n\n margin-left: -0.25rem\n margin-right: -0.25rem\n padding-top: 0.25rem\n padding-bottom: 0.25rem\n padding-right: 0.5rem\n\n // These are intentionally em, to properly match the font size.\n padding-left: 3em\n text-indent: -2.5em\n\n border-radius: 0.25rem\n\n background: var(--color-api-background)\n transition: background 100ms ease-out\n\n &:hover\n background: var(--color-api-background-hover)\n\n // adjust the size of the [source] link on the right.\n a.reference\n .viewcode-link\n font-weight: normal\n width: 4.25rem\n\nem.property, span.property\n font-style: normal\n &:first-child\n color: var(--color-api-keyword)\n.sig-name\n color: var(--color-api-name)\n.sig-prename\n font-weight: normal\n color: var(--color-api-pre-name)\n.sig-paren\n color: var(--color-api-paren)\n.sig-param\n font-style: normal\n\ndiv.versionadded,\ndiv.versionchanged,\ndiv.deprecated,\ndiv.versionremoved\n border-left: 0.1875rem solid\n border-radius: 0.125rem\n\n padding-left: 0.75rem\n\n p\n margin-top: 0.125rem\n margin-bottom: 0.125rem\n\ndiv.versionadded\n border-color: var(--color-api-added-border)\n .versionmodified\n color: var(--color-api-added)\n\ndiv.versionchanged\n border-color: var(--color-api-changed-border)\n .versionmodified\n color: var(--color-api-changed)\n\ndiv.deprecated\n border-color: var(--color-api-deprecated-border)\n .versionmodified\n color: var(--color-api-deprecated)\n\ndiv.versionremoved\n border-color: var(--color-api-removed-border)\n .versionmodified\n color: var(--color-api-removed)\n\n// Align the [docs] and [source] to the right.\n.viewcode-link, .viewcode-back\n float: right\n text-align: right\n",".line-block\n margin-top: 0.5rem\n margin-bottom: 0.75rem\n .line-block\n margin-top: 0rem\n margin-bottom: 0rem\n padding-left: 1rem\n","// Captions\narticle p.caption,\ntable > caption,\n.code-block-caption\n font-size: var(--font-size--small)\n text-align: center\n\n// Caption above a TOCTree\n.toctree-wrapper.compound\n .caption, :not(.caption) > .caption-text\n font-size: var(--font-size--small)\n text-transform: uppercase\n\n text-align: initial\n margin-bottom: 0\n\n > ul\n margin-top: 0\n margin-bottom: 0\n","// Inline code\ncode.literal, .sig-inline\n background: var(--color-inline-code-background)\n border-radius: 0.2em\n // Make the font smaller, and use padding to recover.\n font-size: var(--font-size--small--2)\n padding: 0.1em 0.2em\n\n pre.literal-block &\n font-size: inherit\n padding: 0\n\n p &\n border: 1px solid var(--color-background-border)\n\n.sig-inline\n font-family: var(--font-stack--monospace)\n\n// Code and Literal Blocks\n$code-spacing-vertical: 0.625rem\n$code-spacing-horizontal: 0.875rem\n\n// Wraps every literal block + line numbers.\ndiv[class*=\" highlight-\"],\ndiv[class^=\"highlight-\"]\n margin: 1em 0\n display: flex\n\n .table-wrapper\n margin: 0\n padding: 0\n\npre\n margin: 0\n padding: 0\n overflow: auto\n\n // Needed to have more specificity than pygments' \"pre\" selector. :(\n article[role=\"main\"] .highlight &\n line-height: 1.5\n\n &.literal-block,\n .highlight &\n font-size: var(--code-font-size)\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n // Make it look like all the other blocks.\n &.literal-block\n margin-top: 1rem\n margin-bottom: 1rem\n\n border-radius: 0.2rem\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n\n// All code is always contained in this.\n.highlight\n width: 100%\n border-radius: 0.2rem\n\n // Make line numbers and prompts un-selectable.\n .gp, span.linenos\n user-select: none\n pointer-events: none\n\n // Expand the line-highlighting.\n .hll\n display: block\n margin-left: -$code-spacing-horizontal\n margin-right: -$code-spacing-horizontal\n padding-left: $code-spacing-horizontal\n padding-right: $code-spacing-horizontal\n\n/* Make code block captions be nicely integrated */\n.code-block-caption\n display: flex\n padding: $code-spacing-vertical $code-spacing-horizontal\n\n border-radius: 0.25rem\n border-bottom-left-radius: 0\n border-bottom-right-radius: 0\n font-weight: 300\n border-bottom: 1px solid\n\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n border-color: var(--color-background-border)\n\n + div[class]\n margin-top: 0\n > .highlight\n border-top-left-radius: 0\n border-top-right-radius: 0\n\n// When `html_codeblock_linenos_style` is table.\n.highlighttable\n width: 100%\n display: block\n tbody\n display: block\n\n tr\n display: flex\n\n // Line numbers\n td.linenos\n background-color: var(--color-code-background)\n color: var(--color-code-foreground)\n padding: $code-spacing-vertical $code-spacing-horizontal\n padding-right: 0\n border-top-left-radius: 0.2rem\n border-bottom-left-radius: 0.2rem\n\n .linenodiv\n padding-right: $code-spacing-horizontal\n font-size: var(--code-font-size)\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n\n // Actual code\n td.code\n padding: 0\n display: block\n flex: 1\n overflow: hidden\n\n .highlight\n border-top-left-radius: 0\n border-bottom-left-radius: 0\n\n// When `html_codeblock_linenos_style` is inline.\n.highlight\n span.linenos\n display: inline-block\n padding-left: 0\n padding-right: $code-spacing-horizontal\n margin-right: $code-spacing-horizontal\n box-shadow: -0.0625rem 0 var(--color-foreground-border) inset\n","// Inline Footnote Reference\n.footnote-reference\n font-size: var(--font-size--small--4)\n vertical-align: super\n\n// Definition list, listing the content of each note.\n// docutils <= 0.17\ndl.footnote.brackets\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\n display: grid\n grid-template-columns: max-content auto\n dt\n margin: 0\n > .fn-backref\n margin-left: 0.25rem\n\n &:after\n content: \":\"\n\n .brackets\n &:before\n content: \"[\"\n &:after\n content: \"]\"\n\n dd\n margin: 0\n padding: 0 1rem\n\n// docutils >= 0.18\naside.footnote\n font-size: var(--font-size--small)\n color: var(--color-foreground-secondary)\n\naside.footnote > span,\ndiv.citation > span\n float: left\n font-weight: 500\n padding-right: 0.25rem\n\naside.footnote > *:not(span),\ndiv.citation > p\n margin-left: 2rem\n","//\n// Figures\n//\nimg\n box-sizing: border-box\n max-width: 100%\n height: auto\n\narticle\n figure, .figure\n border-radius: 0.2rem\n\n margin: 0\n :last-child\n margin-bottom: 0\n\n .align-left\n float: left\n clear: left\n margin: 0 1rem 1rem\n\n .align-right\n float: right\n clear: right\n margin: 0 1rem 1rem\n\n .align-default,\n .align-center\n display: block\n text-align: center\n margin-left: auto\n margin-right: auto\n\n // WELL, table needs to be stylised like a table.\n table.align-default\n display: table\n text-align: initial\n",".genindex-jumpbox, .domainindex-jumpbox\n border-top: 1px solid var(--color-background-border)\n border-bottom: 1px solid var(--color-background-border)\n padding: 0.25rem\n\n.genindex-section, .domainindex-section\n h2\n margin-top: 0.75rem\n margin-bottom: 0.5rem\n ul\n margin-top: 0\n margin-bottom: 0\n","ul,\nol\n padding-left: 1.2rem\n\n // Space lists out like paragraphs\n margin-top: 1rem\n margin-bottom: 1rem\n // reduce margins within li.\n li\n > p:first-child\n margin-top: 0.25rem\n margin-bottom: 0.25rem\n\n > p:last-child\n margin-top: 0.25rem\n\n > ul,\n > ol\n margin-top: 0.5rem\n margin-bottom: 0.5rem\n\nol\n &.arabic\n list-style: decimal\n &.loweralpha\n list-style: lower-alpha\n &.upperalpha\n list-style: upper-alpha\n &.lowerroman\n list-style: lower-roman\n &.upperroman\n list-style: upper-roman\n\n// Don't space lists out when they're \"simple\" or in a `.. toctree::`\n.simple,\n.toctree-wrapper\n li\n > ul,\n > ol\n margin-top: 0\n margin-bottom: 0\n\n// Definition Lists\n.field-list,\n.option-list,\ndl:not([class]),\ndl.simple,\ndl.footnote,\ndl.glossary\n dt\n font-weight: 500\n margin-top: 0.25rem\n + dt\n margin-top: 0\n\n .classifier::before\n content: \":\"\n margin-left: 0.2rem\n margin-right: 0.2rem\n\n dd\n > p:first-child,\n ul\n margin-top: 0.125rem\n\n ul\n margin-bottom: 0.125rem\n",".math-wrapper\n width: 100%\n overflow-x: auto\n\ndiv.math\n position: relative\n text-align: center\n\n .headerlink,\n &:focus .headerlink\n display: none\n\n &:hover .headerlink\n display: inline-block\n\n span.eqno\n position: absolute\n right: 0.5rem\n top: 50%\n transform: translate(0, -50%)\n z-index: 1\n","// Abbreviations\nabbr[title]\n cursor: help\n\n// \"Problematic\" content, as identified by Sphinx\n.problematic\n color: var(--color-problematic)\n\n// Keyboard / Mouse \"instructions\"\nkbd:not(.compound)\n margin: 0 0.2rem\n padding: 0 0.2rem\n border-radius: 0.2rem\n border: 1px solid var(--color-foreground-border)\n color: var(--color-foreground-primary)\n vertical-align: text-bottom\n\n font-size: var(--font-size--small--3)\n display: inline-block\n\n box-shadow: 0 0.0625rem 0 rgba(0, 0, 0, 0.2), inset 0 0 0 0.125rem var(--color-background-primary)\n\n background-color: var(--color-background-secondary)\n\n// Blockquote\nblockquote\n border-left: 4px solid var(--color-background-border)\n background: var(--color-background-secondary)\n\n margin-left: 0\n margin-right: 0\n padding: 0.5rem 1rem\n\n .attribution\n font-weight: 600\n text-align: right\n\n &.pull-quote,\n &.highlights\n font-size: 1.25em\n\n &.epigraph,\n &.pull-quote\n border-left-width: 0\n border-radius: 0.5rem\n\n &.highlights\n border-left-width: 0\n background: transparent\n\n// Center align embedded-in-text images\np .reference img\n vertical-align: middle\n","p.rubric\n line-height: 1.25\n font-weight: bold\n font-size: 1.125em\n\n // For Numpy-style documentation that's got rubrics within it.\n // https://github.com/pradyunsg/furo/discussions/505\n dd &\n line-height: inherit\n font-weight: inherit\n\n font-size: var(--font-size--small)\n text-transform: uppercase\n","article .sidebar\n float: right\n clear: right\n width: 30%\n\n margin-left: 1rem\n margin-right: 0\n\n border-radius: 0.2rem\n background-color: var(--color-background-secondary)\n border: var(--color-background-border) 1px solid\n\n > *\n padding-left: 1rem\n padding-right: 1rem\n\n > ul, > ol // lists need additional padding, because bullets.\n padding-left: 2.2rem\n\n .sidebar-title\n margin: 0\n padding: 0.5rem 1rem\n border-bottom: var(--color-background-border) 1px solid\n\n font-weight: 500\n\n// TODO: subtitle\n// TODO: dedicated variables?\n","[role=main] .table-wrapper.container\n width: 100%\n overflow-x: auto\n margin-top: 1rem\n margin-bottom: 0.5rem\n padding: 0.2rem 0.2rem 0.75rem\n\ntable.docutils\n border-radius: 0.2rem\n border-spacing: 0\n border-collapse: collapse\n\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n th\n background: var(--color-table-header-background)\n\n td,\n th\n // Space things out properly\n padding: 0 0.25rem\n\n // Get the borders looking just-right.\n border-left: 1px solid var(--color-table-border)\n border-right: 1px solid var(--color-table-border)\n border-bottom: 1px solid var(--color-table-border)\n\n p\n margin: 0.25rem\n\n &:first-child\n border-left: none\n &:last-child\n border-right: none\n\n // MyST-parser tables set these classes for control of column alignment\n &.text-left\n text-align: left\n &.text-right\n text-align: right\n &.text-center\n text-align: center\n","@use \"../variables\" as *\n\n:target\n scroll-margin-top: 2.5rem\n\n@media (max-width: $full-width - $sidebar-width)\n :target\n scroll-margin-top: calc(2.5rem + var(--header-height))\n\n // When a heading is selected\n section > span:target\n scroll-margin-top: calc(2.8rem + var(--header-height))\n\n// Permalinks\n.headerlink\n font-weight: 100\n user-select: none\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6,\ndl dt,\np.caption,\nfigcaption p,\ntable > caption,\n.code-block-caption\n > .headerlink\n margin-left: 0.5rem\n visibility: hidden\n &:hover > .headerlink\n visibility: visible\n\n // Don't change to link-like, if someone adds the contents directive.\n > .toc-backref\n color: inherit\n text-decoration-line: none\n\n// Figure and table captions are special.\nfigure:hover > figcaption > p > .headerlink,\ntable:hover > caption > .headerlink\n visibility: visible\n\n:target >, // Regular section[id] style anchors\nspan:target ~ // Non-regular span[id] style \"extra\" anchors\n h1,\n h2,\n h3,\n h4,\n h5,\n h6\n &:nth-of-type(1)\n background-color: var(--color-highlight-on-target)\n // .headerlink\n // visibility: visible\n code.literal\n background-color: transparent\n\ntable:target > caption,\nfigure:target\n background-color: var(--color-highlight-on-target)\n\n// Inline page contents\n.this-will-duplicate-information-and-it-is-still-useful-here li :target\n background-color: var(--color-highlight-on-target)\n\n// Code block permalinks\n.literal-block-wrapper:target .code-block-caption\n background-color: var(--color-highlight-on-target)\n\n// When a definition list item is selected\n//\n// There isn't really an alternative to !important here, due to the\n// high-specificity of API documentation's selector.\ndt:target\n background-color: var(--color-highlight-on-target) !important\n\n// When a footnote reference is selected\n.footnote > dt:target + dd,\n.footnote-reference:target\n background-color: var(--color-highlight-on-target)\n",".guilabel\n background-color: var(--color-guilabel-background)\n border: 1px solid var(--color-guilabel-border)\n color: var(--color-guilabel-text)\n\n padding: 0 0.3em\n border-radius: 0.5em\n font-size: 0.9em\n","// This file contains the styles used for stylizing the footer that's shown\n// below the content.\n@use \"../variables\" as *\n\nfooter\n font-size: var(--font-size--small)\n display: flex\n flex-direction: column\n\n margin-top: 2rem\n\n// Bottom of page information\n.bottom-of-page\n display: flex\n align-items: center\n justify-content: space-between\n\n margin-top: 1rem\n padding-top: 1rem\n padding-bottom: 1rem\n\n color: var(--color-foreground-secondary)\n border-top: 1px solid var(--color-background-border)\n\n line-height: 1.5\n\n @media (max-width: $content-width)\n text-align: center\n flex-direction: column-reverse\n gap: 0.25rem\n\n .left-details\n font-size: var(--font-size--small)\n\n .right-details\n display: flex\n flex-direction: column\n gap: 0.25rem\n text-align: right\n\n .icons\n display: flex\n justify-content: flex-end\n gap: 0.25rem\n font-size: 1rem\n\n a\n text-decoration: none\n\n svg,\n img\n font-size: 1.125rem\n height: 1em\n width: 1em\n\n// Next/Prev page information\n.related-pages\n a\n display: flex\n align-items: center\n\n text-decoration: none\n &:hover .page-info .title\n text-decoration: underline\n color: var(--color-link)\n text-decoration-color: var(--color-link-underline)\n\n svg.furo-related-icon,\n svg.furo-related-icon > use\n flex-shrink: 0\n\n color: var(--color-foreground-border)\n\n width: 0.75rem\n height: 0.75rem\n margin: 0 0.5rem\n\n &.next-page\n max-width: 50%\n\n float: right\n clear: right\n text-align: right\n\n &.prev-page\n max-width: 50%\n\n float: left\n clear: left\n\n svg\n transform: rotate(180deg)\n\n.page-info\n display: flex\n flex-direction: column\n overflow-wrap: anywhere\n\n .next-page &\n align-items: flex-end\n\n .context\n display: flex\n align-items: center\n\n padding-bottom: 0.1rem\n\n color: var(--color-foreground-muted)\n font-size: var(--font-size--small)\n text-decoration: none\n","// This file contains the styles for the contents of the left sidebar, which\n// contains the navigation tree, logo, search etc.\n\n////////////////////////////////////////////////////////////////////////////////\n// Brand on top of the scrollable tree.\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-brand\n display: flex\n flex-direction: column\n flex-shrink: 0\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n text-decoration: none\n\n.sidebar-brand-text\n color: var(--color-sidebar-brand-text)\n overflow-wrap: break-word\n margin: var(--sidebar-item-spacing-vertical) 0\n font-size: 1.5rem\n\n.sidebar-logo-container\n margin: var(--sidebar-item-spacing-vertical) 0\n\n.sidebar-logo\n margin: 0 auto\n display: block\n max-width: 100%\n\n////////////////////////////////////////////////////////////////////////////////\n// Search\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-search-container\n display: flex\n align-items: center\n margin-top: var(--sidebar-search-space-above)\n\n position: relative\n\n background: var(--color-sidebar-search-background)\n &:hover,\n &:focus-within\n background: var(--color-sidebar-search-background--focus)\n\n &::before\n content: \"\"\n position: absolute\n left: var(--sidebar-item-spacing-horizontal)\n width: var(--sidebar-search-icon-size)\n height: var(--sidebar-search-icon-size)\n\n background-color: var(--color-sidebar-search-icon)\n mask-image: var(--icon-search)\n\n.sidebar-search\n box-sizing: border-box\n\n border: none\n border-top: 1px solid var(--color-sidebar-search-border)\n border-bottom: 1px solid var(--color-sidebar-search-border)\n\n padding-top: var(--sidebar-search-input-spacing-vertical)\n padding-bottom: var(--sidebar-search-input-spacing-vertical)\n padding-right: var(--sidebar-search-input-spacing-horizontal)\n padding-left: calc(var(--sidebar-item-spacing-horizontal) + var(--sidebar-search-input-spacing-horizontal) + var(--sidebar-search-icon-size))\n\n width: 100%\n\n color: var(--color-sidebar-search-foreground)\n background: transparent\n z-index: 10\n\n &:focus\n outline: none\n\n &::placeholder\n font-size: var(--sidebar-search-input-font-size)\n\n//\n// Hide Search Matches link\n//\n#searchbox .highlight-link\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0\n margin: 0\n text-align: center\n\n a\n color: var(--color-sidebar-search-icon)\n font-size: var(--font-size--small--2)\n\n////////////////////////////////////////////////////////////////////////////////\n// Structure/Skeleton of the navigation tree (left)\n////////////////////////////////////////////////////////////////////////////////\n.sidebar-tree\n font-size: var(--sidebar-item-font-size)\n margin-top: var(--sidebar-tree-space-above)\n margin-bottom: var(--sidebar-item-spacing-vertical)\n\n ul\n padding: 0\n margin-top: 0\n margin-bottom: 0\n\n display: flex\n flex-direction: column\n\n list-style: none\n\n li\n position: relative\n margin: 0\n\n > ul\n margin-left: var(--sidebar-item-spacing-horizontal)\n\n .icon\n color: var(--color-sidebar-link-text)\n\n .reference\n box-sizing: border-box\n color: var(--color-sidebar-link-text)\n\n // Fill the parent.\n display: inline-block\n line-height: var(--sidebar-item-line-height)\n text-decoration: none\n\n // Don't allow long words to cause wrapping.\n overflow-wrap: anywhere\n\n height: 100%\n width: 100%\n\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n &:hover\n color: var(--color-sidebar-link-text)\n background: var(--color-sidebar-item-background--hover)\n\n // Add a nice little \"external-link\" arrow here.\n &.external::after\n content: url('data:image/svg+xml,')\n margin: 0 0.25rem\n vertical-align: middle\n color: var(--color-sidebar-link-text)\n\n // Make the current page reference bold.\n .current-page > .reference\n font-weight: bold\n\n label\n position: absolute\n top: 0\n right: 0\n height: var(--sidebar-item-height)\n width: var(--sidebar-expander-width)\n\n cursor: pointer\n user-select: none\n\n display: flex\n justify-content: center\n align-items: center\n\n .caption, :not(.caption) > .caption-text\n font-size: var(--sidebar-caption-font-size)\n color: var(--color-sidebar-caption-text)\n\n font-weight: bold\n text-transform: uppercase\n\n margin: var(--sidebar-caption-space-above) 0 0 0\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n\n // If it has children, add a bit more padding to wrap the content to avoid\n // overlapping with the