diff --git a/solid2/__init__.py b/solid2/__init__.py index 74e4953..a08b1f8 100644 --- a/solid2/__init__.py +++ b/solid2/__init__.py @@ -1,7 +1,6 @@ -#only import the external interface! +# only import the external interface! from .core import * - from .extensions.greedy_scad_interface import * +from .extensions.module import * from .extensions.scad_control_structures import * - diff --git a/solid2/examples/19-modules.py b/solid2/examples/19-modules.py new file mode 100644 index 0000000..94b83d2 --- /dev/null +++ b/solid2/examples/19-modules.py @@ -0,0 +1,73 @@ +#! /usr/bin/env python + +from solid2 import cube, cylinder, difference, module, set_global_fn, sphere, translate + +set_global_fn(72) + +# Example 1: Module with custom name +# Create a complex shape that we'll reuse multiple times +complex_shape = difference()( + cube([10, 10, 10]), sphere(6), cylinder(r=2, h=12, center=True) +) + +# Wrap it in a module for performance +my_module = module(complex_shape, name="complex_shape") + +# Use the module multiple times with transformations +scene = ( + my_module() + + my_module().translate([15, 0, 0]) + + my_module().translate([0, 15, 0]) + + my_module().translate([15, 15, 0]) +) + +scene.save_as_scad() + +# When you run this, you'll see that the module definition appears once at the top +# of the generated SCAD file, and then it's instantiated 4 times. +# This is more efficient than repeating the entire geometry 4 times! +# +# Generated SCAD will look like: +# +# module complex_shape() { +# difference() { +# cube(size = [10, 10, 10]); +# sphere(r = 6); +# cylinder(r = 2, h = 12, center = true); +# } +# } +# +# complex_shape(); +# translate(v = [15, 0, 0]) { +# complex_shape(); +# } +# ... etc + + +# Example 2: Module with auto-generated name +# If you don't specify a name, a stable name is generated based on content hash +gear_shape = cylinder(r=5, h=2) +gear_module = module(gear_shape) # Name will be like "mod_a3f2c1b8" + +# Example 2b: Module with custom prefix +# You can customize the prefix for auto-generated names +gear_with_prefix = module( + gear_shape, name_prefix="gear" +) # Name will be like "gear_a3f2c1b8" + +# Example 3: Multiple modules +wheel = cylinder(r=3, h=1) +axle = cylinder(r=0.5, h=10, center=True) + +wheel_mod = module(wheel, name="wheel") +axle_mod = module(axle, name="axle") + +# Build a simple car with 4 wheels and 2 axles +car = ( + wheel_mod().translate([0, 0, 0]) + + wheel_mod().translate([8, 0, 0]) + + wheel_mod().translate([0, 6, 0]) + + wheel_mod().translate([8, 6, 0]) + + axle_mod().translate([4, 0, 0]) + + axle_mod().translate([4, 6, 0]) +) diff --git a/solid2/extensions/module.py b/solid2/extensions/module.py new file mode 100644 index 0000000..708f477 --- /dev/null +++ b/solid2/extensions/module.py @@ -0,0 +1,166 @@ +import hashlib + +from solid2 import register_pre_render as _register_pre_render +from solid2.core.object_base import ObjectBase as _ObjectBase +from solid2.core.object_base.access_syntax_mixin import ( + AccessSyntaxMixin as _AccessSyntaxMixin, +) +from solid2.core.object_base.operator_mixin import OperatorMixin as _OperatorMixin +from solid2.core.utils import indent as _indent + +# Global registry of all modules that need to be rendered +_module_registry = {} + + +def _reset_module_registry(): + """Reset the module registry. Useful for testing.""" + global _module_registry + _module_registry = {} + + +def _get_module_definitions(): + """Get all module definitions that need to be rendered.""" + return _module_registry + + +class Module(_ObjectBase): + """ + Wraps an OpenSCAD object tree as a module for performance optimization. + Modules are cached by OpenSCAD, which improves rendering performance + for complex shapes that are used multiple times. + """ + + def __init__(self, obj, name=None, name_prefix="mod"): + super().__init__() + self._obj = obj + + # Generate or use provided name + if name: + self._module_name = name + else: + # Generate stable name based on content hash + content = obj._render() + content_hash = hashlib.sha256(content.encode()).hexdigest()[:8] + self._module_name = f"{name_prefix}_{content_hash}" + + # Register this module globally, checking for duplicates + # Only error if user explicitly provided a name (not auto-generated) + if name is not None and self._module_name in _module_registry: + raise ValueError( + f"Module name '{self._module_name}' already exists. " + f"Please choose a different name." + ) + + # Register the module (auto-generated names can reuse existing registration) + if self._module_name not in _module_registry: + _module_registry[self._module_name] = self._obj + + def _render(self): + """ + Render module instantiation (just the call). + The module definition itself is rendered separately. + """ + return f"{self._module_name}();\n" + + def __call__(self, *args): + """ + Create a new instance of this module. + If called with no arguments, returns self. + If called with arguments, adds them as children (standard OpenSCAD behavior). + """ + if not args: + # Return a new instance that can be transformed + return ModuleInstance(self._module_name) + else: + # Add children if provided + return super().__call__(*args) + + +class ModuleInstance(_AccessSyntaxMixin, _OperatorMixin, _ObjectBase): + """ + Represents an instantiation of a module. + This allows the module to be transformed without affecting the original. + """ + + def __init__(self, module_name): + super().__init__() + self._module_name = module_name + + def _render(self): + """Render module instantiation with any children/transformations.""" + + if self._children: + # If there are children, render them inside + rendered_children = [_indent(child._render()) for child in self._children] + return f"{self._module_name}() {{\n{''.join(rendered_children)}}}\n" + else: + # Simple instantiation + return f"{self._module_name}();\n" + + +def module(obj, name=None, name_prefix="mod"): + """ + Create a reusable OpenSCAD module from an object tree. + + Modules are cached by OpenSCAD and provide performance benefits when + complex shapes are used multiple times. + + Args: + obj: The OpenSCAD object tree to wrap in a module + name: Optional custom name for the module. If not provided, + a stable name will be generated based on the content hash. + Explicit names must be unique, but auto-generated names can + be reused (same content = same module). + name_prefix: Prefix to use for auto-generated names. Defaults to "mod". + Only used when name is not provided. + + Returns: + A Module object that can be instantiated multiple times + + Raises: + ValueError: If an explicit name is provided that already exists + + Example: + >>> my_shape = module(cube(5) + sphere(3), name="my_shape") + >>> scene = my_shape() + my_shape().translate([10, 0, 0]) + >>> + >>> # With custom prefix + >>> gear = module(cylinder(r=5, h=2), name_prefix="gear") + >>> # Generates name like "gear_a3f2c1b8" + >>> + >>> # Explicit duplicate names raise an error + >>> mod1 = module(cube(5), name="shape") + >>> mod2 = module(sphere(3), name="shape") # ValueError! + >>> + >>> # Auto-generated names can be reused (same content) + >>> mod3 = module(cube(5)) # auto-generated name + >>> mod4 = module(cube(5)) # OK! Reuses same module registration + """ + return Module(obj, name=name, name_prefix=name_prefix) + + +def render_module_definitions(): + """ + Render all module definitions. + This should be called during the pre_render phase. + """ + if not _module_registry: + return "" + + output = [] + for module_name, obj in _module_registry.items(): + # Render the module definition + obj_render = obj._render() + # Indent the content + indented_content = _indent(obj_render) + output.append(f"module {module_name}() {{\n{indented_content}}}\n") + + return "\n".join(output) + "\n" + + +def _module_pre_render_hook(_root): + """Hook function for pre_render phase to output module definitions.""" + return render_module_definitions() + + +_register_pre_render(_module_pre_render_hook) diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..4db7773 --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,157 @@ +import unittest + +from solid2 import cube, cylinder, scad_render, sphere, translate + + +class ModuleTest(unittest.TestCase): + def setUp(self): + """Reset module registry before each test""" + from solid2.extensions.module import _reset_module_registry + + _reset_module_registry() + + def test_module_with_custom_name(self): + """Test creating a module with a custom name""" + from solid2 import module + + # Create a module with custom name + my_shape = module(cube(5) + sphere(3).translate([5, 0, 0]), name="my_shape") + + # Use the module twice + scene = my_shape() + my_shape().translate([20, 0, 0]) + + scad_output = scad_render(scene) + + # Module definition should be at the top + self.assertIn("module my_shape()", scad_output) + self.assertIn("cube(size = 5)", scad_output) + self.assertIn("sphere(r = 3)", scad_output) + + # Module should be instantiated twice + self.assertEqual(scad_output.count("my_shape();"), 2) + + def test_module_with_automatic_name(self): + """Test creating a module with automatically generated name""" + from solid2 import module + + # Create a module without specifying name + my_shape = module(cube(5) + sphere(3)) + + # Use the module + scene = my_shape() + + scad_output = scad_render(scene) + + # Module definition should exist with generated name + self.assertIn("module mod_", scad_output) + self.assertIn("cube(size = 5)", scad_output) + self.assertIn("sphere(r = 3)", scad_output) + + def test_module_name_stability(self): + """Test that module names are stable and based on content""" + from solid2 import module + + # Create a module and verify name is consistent + shape1 = cube(5) + sphere(3) + mod1 = module(shape1) + first_name = mod1._module_name + + # Create the same content again (should reuse same name) + shape2 = cube(5) + sphere(3) + mod2 = module(shape2) + + # They should have the same generated name (stable hash) + self.assertEqual(first_name, mod2._module_name) + + # Different content should have different names + mod3 = module(cylinder(r=2, h=5)) + self.assertNotEqual(first_name, mod3._module_name) + + def test_module_definitions_rendered_once(self): + """Test that module definitions are only rendered once even if used multiple times""" + from solid2 import module + + my_shape = module(cube(5), name="test_shape") + + # Use module multiple times + scene = my_shape() + my_shape() + my_shape() + + scad_output = scad_render(scene) + + # Module definition should appear only once + self.assertEqual(scad_output.count("module test_shape()"), 1) + + # But instantiation should appear three times + self.assertEqual(scad_output.count("test_shape();"), 3) + + def test_module_with_custom_prefix(self): + """Test creating a module with custom name prefix""" + from solid2 import module + + # Create a module with custom prefix + my_shape = module(cube(5) + sphere(3), name_prefix="gear") + + scad_output = scad_render(my_shape()) + + # Module definition should use custom prefix + self.assertIn("module gear_", scad_output) + self.assertNotIn("module mod_", scad_output) + + # Verify the hash part is still there + lines = scad_output.split("\n") + module_line = [l for l in lines if l.startswith("module gear_")][0] + # Should be like "module gear_a3f2c1b8() {" + self.assertTrue(module_line.startswith("module gear_")) + self.assertIn("()", module_line) + + def test_module_default_prefix(self): + """Test that default prefix is 'mod_' when not specified""" + from solid2 import module + + my_shape = module(cube(5)) + + scad_output = scad_render(my_shape()) + + # Should use default "mod_" prefix + self.assertIn("module mod_", scad_output) + + def test_module_duplicate_name_raises_exception(self): + """Test that creating modules with duplicate names raises an exception""" + from solid2 import module + + # Create first module with a name + mod1 = module(cube(5), name="duplicate_name") + + # Trying to create another module with the same name should raise + with self.assertRaises(ValueError) as context: + mod2 = module(sphere(3), name="duplicate_name") + + self.assertIn("duplicate_name", str(context.exception)) + self.assertIn("already exists", str(context.exception).lower()) + + def test_module_autogenerated_name_reuses_existing(self): + """Test that auto-generated duplicate names reuse existing module registration""" + from solid2 import module + + # Create first module with auto-generated name + mod1 = module(cube(5)) + first_name = mod1._module_name + + # Creating another module with the same content should work (reuses same registration) + mod2 = module(cube(5)) + + # They should have the same name + self.assertEqual(mod1._module_name, mod2._module_name) + + # And both should work in rendering + scene = mod1() + mod2() + scad_output = scad_render(scene) + + # Module definition should appear only once + self.assertEqual(scad_output.count(f"module {first_name}()"), 1) + # But instantiation should appear twice + self.assertEqual(scad_output.count(f"{first_name}();"), 2) + + +if __name__ == "__main__": + unittest.main()