Skip to content

feat(blender): add Blender extension for mesh remeshing#176

Open
kmarchais wants to merge 3 commits intomainfrom
125-create-blender-extension
Open

feat(blender): add Blender extension for mesh remeshing#176
kmarchais wants to merge 3 commits intomainfrom
125-create-blender-extension

Conversation

@kmarchais
Copy link
Owner

Summary

Create a Blender 4.2+ extension that integrates mmgpy functionality directly into Blender's UI, allowing users to remesh models without leaving Blender.

  • Add complete Blender extension structure with operators, panels, and properties
  • Implement mesh conversion utilities (Blender ↔ mmgpy)
  • Support local refinement via Empty objects (spheres and boxes)
  • Include build script using blender-extension-builder for dependency management

Features

  • Remesh Panel: N-panel in 3D View with presets (Fine, Medium, Coarse)
  • Size Control: hmin, hmax, hsiz, hausd parameters
  • Local Refinement: Add spheres/boxes as Empty objects to define refinement zones
  • Full Undo Support: All operations are undoable

Installation Workflow for Users

  1. Download platform-specific package (built with ./build.sh --all)
  2. In Blender: Edit > Preferences > Get Extensions > Install from Disk
  3. Select the downloaded .zip file

Building

cd blender_mmgpy
pip install blender-extension-builder

# Build for current platform
./build.sh

# Build for all platforms (creates separate packages)
./build.sh --all

Test Plan

  • Verify extension builds without errors
  • Install in Blender 4.2+ and verify panels appear
  • Test remeshing on basic mesh (cube, sphere)
  • Test presets (Fine, Medium, Coarse)
  • Test local refinement with sphere/box empties
  • Verify undo/redo works correctly

Related Issue

Closes #125

Create a Blender 4.2+ extension that integrates mmgpy functionality
directly into Blender's UI, allowing users to remesh models without
leaving Blender.

Features:
- Remesh selected mesh objects with customizable parameters
- Presets (Fine, Medium, Coarse) for quick remeshing
- Size control (hmin, hmax, hsiz, hausd)
- Local refinement via Empty objects (spheres and boxes)
- Batch processing support
- Full undo/redo support

The extension uses the new Blender Extensions system with:
- blender_manifest.toml for metadata and wheel bundling
- blender-extension-builder for dependency management
- Platform-specific builds via --split-platforms flag

Closes #125
@codecov
Copy link

codecov bot commented Jan 27, 2026

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
747 4 743 47
View the top 3 failed test(s) by shortest run time
tests\wheel_executable_test.py::wheel_executable_test::TestPythonEntryPoints::test_mmgs_entry_point
Stack Traces | 9.99s run time
self = <tests.wheel_executable_test.TestPythonEntryPoints object at 0x000002BC145ECD30>

    def test_mmgs_entry_point(self) -> None:
        """Test that mmgs entry point works."""
>       result = subprocess.run(
            ["mmgs", "-h"],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )

tests\wheel_executable_test.py:200: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:505: in run
    stdout, stderr = process.communicate(input, timeout=timeout)
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1154: in communicate
    stdout, stderr = self._communicate(input, endtime, timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <Popen: returncode: 1 args: ['mmgs', '-h']>, input = None
endtime = 978.187, orig_timeout = 10

    def _communicate(self, input, endtime, orig_timeout):
        # Start reader threads feeding into a list hanging off of this
        # object, unless they've already been started.
        if self.stdout and not hasattr(self, "_stdout_buff"):
            self._stdout_buff = []
            self.stdout_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stdout, self._stdout_buff))
            self.stdout_thread.daemon = True
            self.stdout_thread.start()
        if self.stderr and not hasattr(self, "_stderr_buff"):
            self._stderr_buff = []
            self.stderr_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stderr, self._stderr_buff))
            self.stderr_thread.daemon = True
            self.stderr_thread.start()
    
        if self.stdin:
            self._stdin_write(input)
    
        # Wait for the reader threads, or time out.  If we time out, the
        # threads remain reading and the fds left open in case the user
        # calls communicate again.
        if self.stdout is not None:
            self.stdout_thread.join(self._remaining_time(endtime))
            if self.stdout_thread.is_alive():
>               raise TimeoutExpired(self.args, orig_timeout)
E               subprocess.TimeoutExpired: Command '['mmgs', '-h']' timed out after 10 seconds

C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1546: TimeoutExpired
tests\wheel_executable_test.py::wheel_executable_test::TestPythonEntryPoints::test_mmg2d_entry_point
Stack Traces | 10s run time
self = <tests.wheel_executable_test.TestPythonEntryPoints object at 0x000002BC145EF880>

    def test_mmg2d_entry_point(self) -> None:
        """Test that mmg2d entry point works."""
>       result = subprocess.run(
            ["mmg2d", "-h"],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )

tests\wheel_executable_test.py:187: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:505: in run
    stdout, stderr = process.communicate(input, timeout=timeout)
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1154: in communicate
    stdout, stderr = self._communicate(input, endtime, timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <Popen: returncode: 1 args: ['mmg2d', '-h']>, input = None
endtime = 968.046, orig_timeout = 10

    def _communicate(self, input, endtime, orig_timeout):
        # Start reader threads feeding into a list hanging off of this
        # object, unless they've already been started.
        if self.stdout and not hasattr(self, "_stdout_buff"):
            self._stdout_buff = []
            self.stdout_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stdout, self._stdout_buff))
            self.stdout_thread.daemon = True
            self.stdout_thread.start()
        if self.stderr and not hasattr(self, "_stderr_buff"):
            self._stderr_buff = []
            self.stderr_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stderr, self._stderr_buff))
            self.stderr_thread.daemon = True
            self.stderr_thread.start()
    
        if self.stdin:
            self._stdin_write(input)
    
        # Wait for the reader threads, or time out.  If we time out, the
        # threads remain reading and the fds left open in case the user
        # calls communicate again.
        if self.stdout is not None:
            self.stdout_thread.join(self._remaining_time(endtime))
            if self.stdout_thread.is_alive():
                raise TimeoutExpired(self.args, orig_timeout)
        if self.stderr is not None:
            self.stderr_thread.join(self._remaining_time(endtime))
            if self.stderr_thread.is_alive():
>               raise TimeoutExpired(self.args, orig_timeout)
E               subprocess.TimeoutExpired: Command '['mmg2d', '-h']' timed out after 10 seconds

C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1550: TimeoutExpired
tests\cli_test.py::cli_test::TestMmgAliases::test_mmgs_alias_runs
Stack Traces | 10s run time
self = <tests.cli_test.TestMmgAliases object at 0x000002BC13AE4DF0>

    def test_mmgs_alias_runs(self) -> None:
        """Test that mmgs alias works."""
>       result = subprocess.run(
            ["mmgs", "-h"],
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )

tests\cli_test.py:279: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:505: in run
    stdout, stderr = process.communicate(input, timeout=timeout)
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1154: in communicate
    stdout, stderr = self._communicate(input, endtime, timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <Popen: returncode: 1 args: ['mmgs', '-h']>, input = None
endtime = 883.312, orig_timeout = 10

    def _communicate(self, input, endtime, orig_timeout):
        # Start reader threads feeding into a list hanging off of this
        # object, unless they've already been started.
        if self.stdout and not hasattr(self, "_stdout_buff"):
            self._stdout_buff = []
            self.stdout_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stdout, self._stdout_buff))
            self.stdout_thread.daemon = True
            self.stdout_thread.start()
        if self.stderr and not hasattr(self, "_stderr_buff"):
            self._stderr_buff = []
            self.stderr_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stderr, self._stderr_buff))
            self.stderr_thread.daemon = True
            self.stderr_thread.start()
    
        if self.stdin:
            self._stdin_write(input)
    
        # Wait for the reader threads, or time out.  If we time out, the
        # threads remain reading and the fds left open in case the user
        # calls communicate again.
        if self.stdout is not None:
            self.stdout_thread.join(self._remaining_time(endtime))
            if self.stdout_thread.is_alive():
>               raise TimeoutExpired(self.args, orig_timeout)
E               subprocess.TimeoutExpired: Command '['mmgs', '-h']' timed out after 10 seconds

C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1546: TimeoutExpired
tests\wheel_executable_test.py::wheel_executable_test::TestPythonEntryPoints::test_mmg_unified_entry_point
Stack Traces | 10.1s run time
self = <tests.wheel_executable_test.TestPythonEntryPoints object at 0x000002BC145EC9A0>

    def test_mmg_unified_entry_point(self) -> None:
        """Test that unified mmg entry point shows help."""
>       result = subprocess.run(
            ["mmg", "--help"],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )

tests\wheel_executable_test.py:213: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:505: in run
    stdout, stderr = process.communicate(input, timeout=timeout)
C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1154: in communicate
    stdout, stderr = self._communicate(input, endtime, timeout)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <Popen: returncode: 1 args: ['mmg', '--help']>, input = None
endtime = 988.312, orig_timeout = 10

    def _communicate(self, input, endtime, orig_timeout):
        # Start reader threads feeding into a list hanging off of this
        # object, unless they've already been started.
        if self.stdout and not hasattr(self, "_stdout_buff"):
            self._stdout_buff = []
            self.stdout_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stdout, self._stdout_buff))
            self.stdout_thread.daemon = True
            self.stdout_thread.start()
        if self.stderr and not hasattr(self, "_stderr_buff"):
            self._stderr_buff = []
            self.stderr_thread = \
                    threading.Thread(target=self._readerthread,
                                     args=(self.stderr, self._stderr_buff))
            self.stderr_thread.daemon = True
            self.stderr_thread.start()
    
        if self.stdin:
            self._stdin_write(input)
    
        # Wait for the reader threads, or time out.  If we time out, the
        # threads remain reading and the fds left open in case the user
        # calls communicate again.
        if self.stdout is not None:
            self.stdout_thread.join(self._remaining_time(endtime))
            if self.stdout_thread.is_alive():
>               raise TimeoutExpired(self.args, orig_timeout)
E               subprocess.TimeoutExpired: Command '['mmg', '--help']' timed out after 10 seconds

C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\subprocess.py:1546: TimeoutExpired

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions
Copy link

github-actions bot commented Jan 27, 2026

Benchmark Results Summary

Group Benchmarks Mean Time Range
autodetect-api 2 796.37ms - 809.20ms
autodetect-exe 2 866.66ms - 1808.39ms
comparison-2d 3 8.40ms - 913.38ms
comparison-3d 3 794.49ms - 1741.07ms
comparison-3d-wallclock 1 855.25ms - 855.25ms
comparison-surface 3 58.06ms - 953.13ms
duplicate-detection 3 5.80ms - 892.43ms
io-2d-read 1 3.83ms - 3.83ms
io-2d-write 1 6.06ms - 6.06ms
io-3d-read 1 41.98ms - 41.98ms
io-3d-write 1 2.70ms - 2.70ms
io-data-access 3 0.01ms - 0.16ms
io-pyvista-3d 2 1.09ms - 38.15ms
io-pyvista-surface 2 0.51ms - 13.80ms
io-surface-read 1 15.33ms - 15.33ms
io-surface-write 1 3.62ms - 3.62ms
mesh-construction-2d 3 0.31ms - 0.47ms
mesh-construction-3d 3 36.56ms - 37.23ms
mesh-construction-surface 3 8.58ms - 9.37ms
mesh-fields 2 0.00ms - 0.13ms
mesh-lowlevel-3d 2 36.77ms - 36.85ms
mesh-topology 3 0.00ms - 0.01ms
remesh-2d-baseline 3 0.88ms - 54.86ms
remesh-2d-modes 3 8.54ms - 9.26ms
remesh-2d-options 4 0.88ms - 8.53ms
remesh-2d-quality 2 0.04ms - 20.72ms
remesh-3d-baseline 3 301.11ms - 8201.75ms
remesh-3d-modes 3 325.65ms - 1010.25ms
remesh-3d-options 4 309.12ms - 814.01ms
remesh-3d-quality 2 0.09ms - 3.47ms
remesh-surface-baseline 3 37.39ms - 181.73ms
remesh-surface-modes 3 48.24ms - 58.09ms
remesh-surface-options 4 36.59ms - 59.95ms
remesh-surface-quality 2 0.05ms - 22.49ms

Total: 82 benchmarks

- Add sync_version.py to sync extension version from pyproject.toml
- Update build.sh to run version sync before building
- Add GitHub Actions workflow to build extension for all platforms
  (linux-x64, windows-x64, macos-arm64) on release
- Workflow uploads packages as release assets automatically

The extension version is now derived from mmgpy's version:
- 0.6.0.dev0 -> 0.6.0 (dev suffix stripped for Blender manifest)
Convert Python PEP 440 versions to SemVer format for Blender:
- 0.6.0.dev0 -> 0.6.0-dev.0
- 0.6.0a1 -> 0.6.0-alpha.1
- 0.6.0b1 -> 0.6.0-beta.1
- 0.6.0rc1 -> 0.6.0-rc.1

Keep Python format for dependency specifier (PEP 508 compatible).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create Blender extension for mmgpy

1 participant