diff --git a/AGENTS.md b/AGENTS.md index f7e58e068..8065dd6a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ Source repository: https://github.com/barebaric/raygeo ### Testing with a local Raygeo checkout +Attention: Running "pixi run", even to lint, reinstalls from PyPi. + `scripts/pixi-raygeo.sh` wraps any pixi command with a `dependency-override` that uses a local raygeo checkout. The project's real `pixi.toml`/`pixi.lock` are never permanently modified. diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 53349ab37..e8fe7bb4e 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,5 +1,5 @@ asyncudp==0.11.0 pyvips==3.1.1 -raygeo==1.45.1 +raygeo==1.46.0 trimesh==5.0.0 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index f131da064..65433d51c 100644 --- a/pixi.lock +++ b/pixi.lock @@ -458,6 +458,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/33/a1/724b18d6757ab7253a8fecd3a430eb8d980ed26872ba16651e7b5ddfc63f/pypdf-6.16.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - pypi: https://files.pythonhosted.org/packages/37/82/19a03ba344ecb66ea8caab697b3059e0fbea576420f99945944c479caa78/trimesh-5.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/38/f0/0625bef33032dcf01b698a9ce466634659f4881f0147a1a224fc89c97b9e/raygeo-1.46.0.tar.gz - pypi: https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl @@ -482,7 +483,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b3/10/b961aa2a6b7b5931ee7fcf103997bebfcd9bff0cf791dd348fa5407f6f0e/raygeo-1.45.1.tar.gz - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl @@ -707,7 +707,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/f0/62db657f5aedd5cae4c25dd56eadc8ed2d8125aead14c8f7e3de1b673d97/raygeo-1.45.1-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl @@ -718,6 +717,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bf/e0/5011747466414c12cac8a8df77aa235068669a6a5a5df301a96209db6054/cairosvg-2.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/c0/30/cc1c2d878a3e6c1e8ebdab7b22ac638f45a18b93d9b76cc4aa1b1a2785a4/raygeo-1.46.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl @@ -9009,7 +9009,7 @@ packages: - pypi: ./ name: rayforge requires_dist: - - raygeo==1.45.1 + - raygeo==1.46.0 - aiohttp==3.14.3 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9337,6 +9337,26 @@ packages: - aiohttp ; extra == 'test-more' - trimesh[deprecated,easy,recommend,test,test-more] ; extra == 'all' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/38/f0/0625bef33032dcf01b698a9ce466634659f4881f0147a1a224fc89c97b9e/raygeo-1.46.0.tar.gz + name: raygeo + version: 1.46.0 + sha256: 5b69f765cffd54d82f02cc7667cebbdd2df78e7f78a90939bed5112e71430373 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl name: ruff version: 0.16.3 @@ -9546,26 +9566,6 @@ packages: - pytest-xdist ; extra == 'testing' - virtualenv ; extra == 'testing' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/9e/f0/62db657f5aedd5cae4c25dd56eadc8ed2d8125aead14c8f7e3de1b673d97/raygeo-1.45.1-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.45.1 - sha256: b301f6c5b408a21cc7fa7c87320e3e4985225eb6e5580d31d031237ff67734bb - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl name: flake8 version: 7.3.0 @@ -9643,26 +9643,6 @@ packages: version: 1.8.0 sha256: cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b3/10/b961aa2a6b7b5931ee7fcf103997bebfcd9bff0cf791dd348fa5407f6f0e/raygeo-1.45.1.tar.gz - name: raygeo - version: 1.45.1 - sha256: b509eaf92d38386af16658bb48a93601e79ec3c5177ba4efa9e15649d1d2ac59 - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl name: cffi version: 2.1.1 @@ -9696,6 +9676,26 @@ packages: - flake8 ; extra == 'test' - isort ; extra == 'test' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c0/30/cc1c2d878a3e6c1e8ebdab7b22ac638f45a18b93d9b76cc4aa1b1a2785a4/raygeo-1.46.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.46.0 + sha256: 8945f5b0bcd6eca5e5f0448ab87956827105a3fe646b83fb60f17df60e86f140 + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl name: smmap version: 5.0.3 diff --git a/pixi.toml b/pixi.toml index 2564b3de3..0bda36e93 100644 --- a/pixi.toml +++ b/pixi.toml @@ -37,7 +37,7 @@ rust = "==1.97.1" patchelf = "==0.17.2|==0.18.0" [feature.app.pypi-dependencies] -raygeo = "==1.45.1" +raygeo = "==1.46.0" rayforge = { editable = true, path = "." } aiohttp = "==3.14.3" asyncudp = "==0.11.0" diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py index 5006df550..bd4c9b9aa 100644 --- a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/__init__.py @@ -1,3 +1,3 @@ """ -CNC Commands (planned for Phase 5). +CNC Commands. """ diff --git a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py index fbbee48ed..024ced4c5 100644 --- a/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-cnc/cnc_essentials/commands/generate_plan_cmd.py @@ -1,3 +1,3 @@ """ -Auto-plan command (planned for Phase 5). +Auto-plan command. """ diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py index f25cb38b6..27ab2a240 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -321,12 +321,6 @@ def get_operation_mode_short(self): except KeyError: return None - def get_operation_color(self, head) -> str | None: - """The head's raster color, used to represent engraving.""" - if isinstance(head, LaserHead): - return head.raster_color - return None - def is_position_sensitive(self) -> bool: """The raster assembler bakes ``workpiece.bbox`` into its output via ``offset_x_mm`` / ``offset_y_mm`` so the compute diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py index 3e07628a5..93cedd6fa 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_laser_step.py @@ -286,24 +286,22 @@ def test_create_applies_head_pwm_defaults(): assert s.pulse_width == 50 -def test_laser_step_uses_cut_color(): - """A cutting laser step reports the head's cut color.""" +def test_laser_step_uses_head_color(): + """A laser step reports the head's color.""" head = MagicMock(spec=LaserHead) head.cut_color = "#112233" - head.raster_color = "#445566" s = ContourStep(name="t") assert s.get_operation_color(head) == "#112233" -def test_engrave_step_uses_raster_color(): - """An engraving step reports the head's raster color.""" +def test_engrave_step_uses_head_color(): + """An engraving step reports the head's color.""" head = MagicMock(spec=LaserHead) head.cut_color = "#112233" - head.raster_color = "#445566" s = EngraveStep(name="t") - assert s.get_operation_color(head) == "#445566" + assert s.get_operation_color(head) == "#112233" def test_laser_step_color_none_for_non_laser_head(): diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml index d8ce3a944..4f1ef821d 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/abs.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#F5F5DC" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: abs.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml index ade11d4cb..08e6687ff 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: acrylic.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_clear.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_clear.yaml index cd1e1d27c..ada88dd57 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_clear.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/acrylic_clear.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: + absorption: + blue: 0.05 + ir: 0.1 + co2: 0.92 pattern: transparent - tintable: true texture: acrylic_clear.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml index 9c90be1af..f62cbe97c 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/aluminum.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#C0C0C0" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: aluminum.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml index 177cf36cb..e29fbb6d1 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ash.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#F0E68C" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: ash.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml index 6aba80bb5..cb93a09fc 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/bamboo.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#D2B48C" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: bamboo.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml index 0342fc586..c5799c724 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/birch.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#FFE4C4" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: birch.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml index ad067c069..b750c88d7 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/brass.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#B5A642" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: brass.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml index 88913759a..ca8e9e7c5 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/bronze.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#CD7F32" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: bronze.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml index b1f37c0ae..166ea6bb0 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cardboard.yaml @@ -24,7 +24,10 @@ category: uk: Папір zh_CN: 纸张 appearance: - color: "#D3D3D3" + absorption: + blue: 0.9 + ir: 0.3 + co2: 0.9 pattern: solid texture: cardboard.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml index 98af635d8..2adeceeee 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cedar.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#E6C288" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: cedar.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml index 4843a2219..2ef474583 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ceramic.yaml @@ -24,7 +24,10 @@ category: uk: Кераміка zh_CN: 陶瓷 appearance: - color: "#F5F5F5" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: ceramic.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml index 115edc754..e6478d59a 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cherry.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#CD5C5C" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: cherry.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml index 7f9c32bd1..e725f23cc 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/copper.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#B87333" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: copper.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml index 66fc11c98..2bab16e5e 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/cork.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#DEB887" + absorption: + blue: 0.85 + ir: 0.4 + co2: 0.9 pattern: solid texture: cork.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml index 62d644b3e..2a1b2f7ea 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ebony.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#2F4F4F" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: ebony.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml index 0892d6d47..9d44e100a 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/elm.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#CD853F" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: elm.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml index d5dec4f75..af61893ab 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_canvas.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#D2B48C" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.85 pattern: solid texture: fabric_canvas.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml index 1414b4341..7c1eea646 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_cotton.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#F5F5DC" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.85 pattern: solid texture: fabric_cotton.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml index 42127e5a9..b0716ca6a 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_denim.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#4169E1" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.85 pattern: solid texture: fabric_denim.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml index 8013c5aa4..8eb573184 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/fabric_felt.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#9370DB" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.85 pattern: solid texture: fabric_felt.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml index c3becca73..429ea855d 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/foam.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#FFF8DC" + absorption: + blue: 0.85 + ir: 0.4 + co2: 0.9 pattern: solid texture: foam.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml index f8f1e8a64..c4e853ae0 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/galvanized_steel.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#B0C4DE" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: galvanized_steel.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml index 710c56c76..780b5b094 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/glass.yaml @@ -24,8 +24,10 @@ category: uk: Скло zh_CN: 玻璃 appearance: - color: "#ADD8E6" + absorption: + blue: 0.05 + ir: 0.1 + co2: 0.9 pattern: transparent - tintable: true texture: glass.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml index 022d5d3f6..21f39dc68 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/granite.yaml @@ -24,7 +24,10 @@ category: uk: Камінь zh_CN: 石材 appearance: - color: "#DCDCDC" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: granite.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml index d945bf631..620d492e7 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/hdpe.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#F5F5F5" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: hdpe.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml index a7762dece..2e55603e3 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/hickory.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#DAA520" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: hickory.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml index ecf945d4c..9efbfd7db 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/lead.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#434B4D" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: lead.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml index ad4c9a1b4..29c198e0a 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/leather.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#8B4513" + absorption: + blue: 0.85 + ir: 0.4 + co2: 0.9 pattern: solid texture: leather.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml index 323c4fe76..19ba12d76 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/mahogany.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#8B4513" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: mahogany.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml index 8facefbd7..b609e931c 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/maple.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#F5DEB3" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: maple.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml index 0e50fd366..5bd69d345 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/marble.yaml @@ -24,7 +24,10 @@ category: uk: Камінь zh_CN: 石材 appearance: - color: "#F5F5F5" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: marble.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml index 48e4c5ce8..2c939e1ce 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/mdf.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#D2691E" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.9 pattern: solid texture: mdf.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml index 6e012e69b..dcfcc71ae 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/nickel.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#E0E0E0" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: nickel.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml index 04d6c075b..723293073 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/nylon.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#FFFACD" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: nylon.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.webp b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.webp index fd8d8fa79..da06d70ac 100644 Binary files a/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.webp and b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.webp differ diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml index 7ef830e24..e5249b5eb 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/oak.yaml @@ -24,7 +24,6 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#A0522D" pattern: wood_grain texture: oak.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml index 727871615..49c688fba 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/paper.yaml @@ -24,7 +24,10 @@ category: uk: Папір zh_CN: 纸张 appearance: - color: "#FFFFFF" + absorption: + blue: 0.9 + ir: 0.3 + co2: 0.9 pattern: solid texture: paper.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml index 1c54d9971..112ea1f61 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/petg.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#F0F8FF" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: transparent - tintable: true texture: petg.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml index d5fe06110..a9a3db9d8 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pine.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#FAEBD7" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: pine.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml index 7f7a4d6d1..af5f1c3b7 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pla.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#FFFACD" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: pla.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml index ec24912a1..70f4e19b9 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/plywood.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#DEB887" + absorption: + blue: 0.8 + ir: 0.3 + co2: 0.9 pattern: wood_grain texture: plywood.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml index bc909b494..0402c6683 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polycarbonate.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#E0FFFF" + absorption: + blue: 0.05 + ir: 0.1 + co2: 0.92 pattern: transparent - tintable: true texture: polycarbonate.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml index 382c87a2f..1a92989da 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polyethylene.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#F8F8FF" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: polyethylene.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml index 0be2b604b..e83e0c739 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/polypropylene.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#F0F0F0" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: polypropylene.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml index 979e9716e..15e9cbe40 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/ptfe.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#FFFFFF" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: ptfe.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml index 2153b908b..2ffd0c365 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/pvc.yaml @@ -24,8 +24,10 @@ category: uk: Пластик zh_CN: 塑料 appearance: - color: "#E0FFFF" + absorption: + blue: 0.3 + ir: 0.2 + co2: 0.85 pattern: solid - tintable: true texture: pvc.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml index 104154b1f..5e4fb42fc 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/rosewood.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#800000" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: rosewood.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml index 534e5a59c..fbe88464c 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/rubber.yaml @@ -24,7 +24,10 @@ category: uk: Органічний zh_CN: 有机材料 appearance: - color: "#2F4F4F" + absorption: + blue: 0.85 + ir: 0.4 + co2: 0.9 pattern: solid texture: rubber.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml index 559498253..d987205e5 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate.yaml @@ -24,7 +24,10 @@ category: uk: Камінь zh_CN: 石材 appearance: - color: "#696969" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: slate.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml index 691b9526a..0fa13ddba 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/slate_blue.yaml @@ -24,7 +24,10 @@ category: uk: Камінь zh_CN: 石材 appearance: - color: "#6A5ACD" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: slate_blue.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml index 853c7d457..2d15c455b 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/spruce.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#FFF5E1" + absorption: + blue: 0.7 + ir: 0.25 + co2: 0.9 pattern: wood_grain texture: spruce.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml index 632409bd4..53a75316a 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/stainless_steel.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#A9A9A9" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: stainless_steel.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml index bc0b91328..d13bf768c 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/steel.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#708090" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: steel.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml index b6ef4a57a..8e1e0cfba 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/stone.yaml @@ -24,7 +24,10 @@ category: uk: Камінь zh_CN: 石材 appearance: - color: "#808080" + absorption: + blue: 0.6 + ir: 0.5 + co2: 0.7 pattern: solid texture: stone.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml index 938cb25d7..b4a8e0a76 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/teak.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#D2691E" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: teak.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml index b2023d3f2..eda58a532 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/titanium.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#71797E" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: titanium.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml index fbde07630..dc7e9fced 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/walnut.yaml @@ -24,7 +24,10 @@ category: uk: Деревина zh_CN: 木材 appearance: - color: "#5D4037" + absorption: + blue: 0.95 + ir: 0.3 + co2: 0.95 pattern: wood_grain texture: walnut.webp texture_size_mm: 300 diff --git a/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml b/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml index 85c70ce20..66dcdf2db 100644 --- a/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml +++ b/rayforge/builtin_addons/rayforge-addon-materials/materials/zinc.yaml @@ -24,7 +24,10 @@ category: uk: Метал zh_CN: 金属 appearance: - color: "#A0A0A0" + absorption: + blue: 0.4 + ir: 0.05 + co2: 0.05 pattern: metallic texture: zinc.webp texture_size_mm: 300 diff --git a/rayforge/config.py b/rayforge/config.py index 32ad6de71..538b1f7ef 100644 --- a/rayforge/config.py +++ b/rayforge/config.py @@ -61,6 +61,8 @@ def get_addon_data_dir(addon_name: str) -> Path: # Material directories USER_MATERIALS_DIR = CONFIG_DIR / "materials" +BUNDLED_MATERIALS_DIR = Path(__file__).parent / "resources" / "materials" +BUNDLED_DEFAULT_MATERIAL_UID = "oak" USER_RECIPES_DIR = CONFIG_DIR / "recipes" USER_COLOR_PRESETS_DIR = CONFIG_DIR / "color_presets" diff --git a/rayforge/core/config.py b/rayforge/core/config.py index 45899db1b..ad6ac038c 100644 --- a/rayforge/core/config.py +++ b/rayforge/core/config.py @@ -40,7 +40,6 @@ class CanvasViewState: show_grid: bool = True show_models: bool = True show_tabs: bool = True - show_ops_underlay: bool = True show_stock: bool = True perspective_mode: bool = False @@ -88,6 +87,12 @@ def __init__(self): self.cache_budget_bytes: int = 2 * 1024 * 1024 * 1024 # Language preference: None = system default, or a code like "de" self.language: str | None = None + # Default stock material UID: None = use the bundled fallback + # material (oak). When the user picks a material in the + # settings, its UID is stored here. + self.default_stock_material_uid: str | None = None + # Default stock thickness in mm, applied to new stock assets. + self.default_stock_thickness_mm: float = 18.0 self.changed = Signal() def set_machine(self, machine: Machine | None): @@ -195,6 +200,24 @@ def set_language(self, language: str | None): self.language = language self.changed.send(self) + def set_default_stock_material(self, uid: str | None): + """Sets the default stock material UID. + + Args: + uid: Material UID, or None to use the bundled fallback. + """ + if self.default_stock_material_uid == uid: + return + self.default_stock_material_uid = uid + self.changed.send(self) + + def set_default_stock_thickness(self, thickness_mm: float): + """Sets the default stock thickness in mm.""" + if self.default_stock_thickness_mm == thickness_mm: + return + self.default_stock_thickness_mm = thickness_mm + self.changed.send(self) + def set_usage_consent(self, consent: bool): """Sets the usage tracking consent preference.""" new_value = "" @@ -249,6 +272,8 @@ def to_dict(self) -> dict[str, Any]: "import_dpi": self.import_dpi, "cache_budget_bytes": self.cache_budget_bytes, "language": self.language, + "default_stock_material_uid": self.default_stock_material_uid, + "default_stock_thickness_mm": self.default_stock_thickness_mm, } @classmethod @@ -321,6 +346,16 @@ def from_dict(cls, data: dict[str, Any], get_machine_by_id) -> "Config": # Load language preference (None = system default) config.language = data.get("language", None) + # Load default stock material (None = bundled fallback) + config.default_stock_material_uid = data.get( + "default_stock_material_uid", None + ) + + # Load default stock thickness + config.default_stock_thickness_mm = data.get( + "default_stock_thickness_mm", 18.0 + ) + # Get the machine by ID. add fallbacks in case the machines # no longer exist. machine_id = data.get("machine") diff --git a/rayforge/core/library_manager.py b/rayforge/core/library_manager.py index 2a319fb4b..0b372a046 100644 --- a/rayforge/core/library_manager.py +++ b/rayforge/core/library_manager.py @@ -7,6 +7,8 @@ from blinker import Signal +from ..config import BUNDLED_DEFAULT_MATERIAL_UID, BUNDLED_MATERIALS_DIR +from ..context import get_context from .material import Material from .material_library import MaterialLibrary @@ -215,6 +217,60 @@ def resolve_material(self, uid: str) -> Material | None: return material + def get_default_material(self) -> Material: + """Resolve the default stock material. + + Resolution order: + 1. The user-configured default (``config.default_stock_material_uid``) + if it resolves in any registered library. + 2. The bundled fallback UID (``"oak"``) in any registered library + (e.g. the core-materials addon is active). + 3. The bundled fallback material loaded directly from + ``rayforge/resources/materials/`` (not registered as a + library, so invisible in the material manager UI). + 4. The first material from any registered library. + 5. Raise ``RuntimeError`` if no material is available at all. + + Returns: + A Material instance that is guaranteed to exist. + """ + configured_uid = get_context().config.default_stock_material_uid + if configured_uid: + mat = self.get_material_or_none(configured_uid) + if mat is not None: + return mat + + mat = self.get_material_or_none(BUNDLED_DEFAULT_MATERIAL_UID) + if mat is not None: + return mat + + mat = self._load_bundled_material() + if mat is not None: + return mat + + all_mats = self.get_all_materials() + if all_mats: + return all_mats[0] + + raise RuntimeError("No materials available") + + def _load_bundled_material(self) -> Material | None: + """Load the bundled fallback material directly from resources. + + This bypasses the library system entirely so the bundled + material is never visible in the material manager UI but is + always available as a fallback default. + """ + yaml_path = ( + BUNDLED_MATERIALS_DIR / f"{BUNDLED_DEFAULT_MATERIAL_UID}.yaml" + ) + try: + if yaml_path.is_file(): + return Material.from_file(yaml_path) + except OSError as e: + logger.warning("Could not load bundled default material: %s", e) + return None + def add_material(self, material: Material, library_id: str) -> bool: """ Add a material to a library. diff --git a/rayforge/core/material.py b/rayforge/core/material.py index f66e2e2fe..cc1817a50 100644 --- a/rayforge/core/material.py +++ b/rayforge/core/material.py @@ -9,6 +9,7 @@ import yaml from ..shared.util.localized import LocalizedField +from .color import hex_to_rgba # Accept both plain strings and LocalizedField as input LocalizedInput = str | LocalizedField @@ -43,10 +44,6 @@ class MaterialAppearance: texture_size_mm: float = 300.0 roughness: float = 0.8 metallic: float = 0.0 - # When True the texture can be tinted with ``color``: the renderer - # multiplies the texture by the tint color. ``color`` may be None, - # meaning "not tinted" (only meaningful when a texture is set). - tintable: bool = False extra: dict[str, Any] = field(default_factory=dict) @classmethod @@ -59,7 +56,6 @@ def from_dict(cls, data: dict[str, Any]) -> "MaterialAppearance": "texture_size_mm", "roughness", "metallic", - "tintable", } extra = {k: v for k, v in data.items() if k not in known_keys} @@ -73,7 +69,6 @@ def from_dict(cls, data: dict[str, Any]) -> "MaterialAppearance": texture_size_mm=data.get("texture_size_mm", cls.texture_size_mm), roughness=data.get("roughness", cls.roughness), metallic=data.get("metallic", cls.metallic), - tintable=bool(data.get("tintable", cls.tintable)), extra=extra, ) @@ -82,8 +77,6 @@ def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = {"pattern": self.pattern} if self.color is not None: result["color"] = self.color - if self.tintable: - result["tintable"] = True if self.texture is not None: result["texture"] = self.texture result.update( @@ -98,12 +91,11 @@ def to_dict(self) -> dict[str, Any]: def get_tint_rgba(self) -> tuple[float, float, float, float] | None: """ - The tint color as RGBA, or None when the material is not tintable - or has no color set ("not tinted"). + The tint color as RGBA, or None when no color is set ("not + tinted"). """ - if not self.tintable or not self.color: + if not self.color: return None - from .color import hex_to_rgba try: return hex_to_rgba(self.color) @@ -278,7 +270,7 @@ def get_display_rgba( """ color_hex = self.appearance.color if not color_hex: - # No color set (e.g. tintable material without a tint). + # No color set (untinted material). return (0.5, 0.5, 0.5, alpha) color_pattern = r"^#?([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$" match = re.match(color_pattern, color_hex) diff --git a/rayforge/core/optical.py b/rayforge/core/optical.py new file mode 100644 index 000000000..1e13b1582 --- /dev/null +++ b/rayforge/core/optical.py @@ -0,0 +1,179 @@ +"""Physical optical model helpers for the burn char pipeline. + +Maps a laser's emission wavelength to a coarse absorption band and +returns the material's absorption coefficient for that band, plus the +char-curve (``burn_response``) parameters that drive the stock +shader's burn block. + +Three bands cover the three real laser types shipped today (blue +diode ~455 nm, IR/fiber ~1064 nm, CO₂ ~10600 nm) — deliberately +coarser than a full spectrum, matching the data the material YAMLs +carry (see ``physical-burn.md`` step 1). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from .color import hex_to_rgba + +# Coarse wavelength bands, ordered by representative wavelength. Each +# band is keyed by a short name that appears in material YAML +# ``appearance.absorption`` dicts. The representative wavelength is +# used to map a laser's ``wavelength_nm`` to the nearest band. +BANDS: tuple[tuple[str, float], ...] = ( + ("blue", 455.0), + ("ir", 1064.0), + ("co2", 10600.0), +) + +# Absorption returned when the material has an ``absorption`` dict but +# the resolved band is not listed. Neutral, so unconfigured bands do +# not silently zero out the burn. +DEFAULT_BAND_ABSORPTION = 0.5 + +# Absorption returned when the material has no ``absorption`` dict at +# all — full absorption, preserving the pre-physical-model behaviour +# (the burn depends only on fluence, not on a wavelength coefficient). +DEFAULT_ABSORPTION = 1.0 + +# Default ``burn_response`` parameters, used when a material's +# ``appearance.burn_response`` is absent or incomplete. These are in +# the fluence (J/cm²) domain, calibrated against real-world desktop +# diode behaviour: a 5 W laser at 10 % power and 1000 mm/min deposits +# ~30 J/cm² on the surface and leaves NO visible mark on wood, while +# full power on a typical raster (~75 J/cm² absorbed) marks clearly. +# Charring therefore starts around 35 J/cm² and saturates near +# 125 J/cm². Vector-cut outline fluence is capped at 100 J/cm² (see +# raygeo's OUTLINE_MAX_FLUENCE) so cuts read as a strong dark kerf +# rather than overexposed black. The char colors are black (not the +# laser color): the physical model produces carbonized material, whose +# color is independent of the beam. +DEFAULT_BURN_RESPONSE: dict[str, Any] = { + "char_threshold": 35.0, + "char_saturation": 125.0, + "char_color_low": (0.04, 0.03, 0.02), + "char_color_high": (0.01, 0.01, 0.01), +} + + +def wavelength_to_band(wavelength_nm: float) -> str: + """Map a laser wavelength in nm to the nearest absorption band. + + Returns one of the band names in :data:`BANDS` (``"blue"``, + ``"ir"``, ``"co2"``). Wavelengths equidistant between two bands + round to the physically closer representative; the three bands are + spaced far enough apart (logarithmically) that any real laser + wavelength lands unambiguously. + """ + if not wavelength_nm or wavelength_nm <= 0: + return BANDS[0][0] + nearest = min(BANDS, key=lambda b: abs(b[1] - wavelength_nm)) + return nearest[0] + + +def absorption_for( + wavelength_nm: float, absorption: Mapping[str, float] | None +) -> float: + """Absorption coefficient (0–1) for a wavelength and material. + + ``absorption`` is the material's ``appearance.absorption`` dict + (band name → coefficient). ``None`` (no optical data) returns + :data:`DEFAULT_ABSORPTION` (1.0, full absorption — current + behaviour). A present dict with the resolved band missing returns + :data:`DEFAULT_BAND_ABSORPTION` (0.5, neutral). + """ + if absorption is None: + return DEFAULT_ABSORPTION + band = wavelength_to_band(wavelength_nm) + value = absorption.get(band) + if value is None: + return DEFAULT_BAND_ABSORPTION + try: + return max(0.0, min(1.0, float(value))) + except (TypeError, ValueError): + return DEFAULT_BAND_ABSORPTION + + +def _coerce_color(value: Any) -> tuple[float, float, float]: + """Coerce a YAML color entry to a 0–1 RGB triple. + + Accepts a 3-tuple/list of floats/ints (0–1 or 0–255) or a hex + string. Falls back to a neutral grey so a malformed entry never + breaks the shader. + """ + if isinstance(value, str): + try: + r, g, b, _a = hex_to_rgba(value) + return (r, g, b) + except ValueError: + return (0.5, 0.5, 0.5) + if isinstance(value, (list, tuple)) and len(value) >= 3: + out = [] + for v in value[:3]: + try: + f = float(v) + except (TypeError, ValueError): + return (0.5, 0.5, 0.5) + out.append(f / 255.0 if f > 1.0 else f) + return (out[0], out[1], out[2]) + return (0.5, 0.5, 0.5) + + +def burn_response_for( + burn_response: dict[str, Any] | None, +) -> dict[str, Any]: + """Resolved char-curve parameters for a material. + + Merges the material's ``appearance.burn_response`` (if any) over + :data:`DEFAULT_BURN_RESPONSE`, coercing color entries to 0–1 RGB + triples and clamping thresholds to non-negative fluence (J/cm²) + with ``char_saturation > char_threshold``. The returned dict + always has all keys, so the shader wiring can index it + unconditionally. + """ + out = dict(DEFAULT_BURN_RESPONSE) + if burn_response: + for key in ("char_threshold", "char_saturation"): + if key in burn_response: + try: + out[key] = max(0.0, float(burn_response[key])) + except (TypeError, ValueError): + pass + for key in ("char_color_low", "char_color_high"): + if key in burn_response: + out[key] = _coerce_color(burn_response[key]) + # Ensure a non-degenerate ramp: saturation must exceed threshold. + if out["char_saturation"] <= out["char_threshold"]: + out["char_saturation"] = out["char_threshold"] + 1e-3 + return out + + +def material_absorption(material: Any, wavelength_nm: float) -> float: + """Absorption for a :class:`~rayforge.core.material.Material`. + + Reads the material's ``appearance.absorption`` extra field. ``None`` + for a material without optical data (the common case) returns + :data:`DEFAULT_ABSORPTION`. + """ + absorption = _appearance_extra(material).get("absorption") + if not isinstance(absorption, Mapping): + absorption = None + return absorption_for(wavelength_nm, absorption) + + +def material_burn_response(material: Any) -> dict[str, Any]: + """Resolved burn-response dict for a :class:`Material`.""" + br = _appearance_extra(material).get("burn_response") + if not isinstance(br, dict): + br = None + return burn_response_for(br) + + +def _appearance_extra(material: Any) -> dict[str, Any]: + """The material's ``appearance.extra`` dict, or ``{}`` if absent.""" + appearance = getattr(material, "appearance", None) + if appearance is None: + return {} + return getattr(appearance, "extra", None) or {} diff --git a/rayforge/core/step.py b/rayforge/core/step.py index db9552d9e..f0f37431e 100644 --- a/rayforge/core/step.py +++ b/rayforge/core/step.py @@ -19,6 +19,7 @@ from raygeo.ops.state import CoolantMode from ..machine.models.head import Head +from ..machine.models.laser import LaserHead from ..machine.models.spindle import SpindleHead from ..pipeline.transformer.registry import transformer_registry from ..shared.units.formatter import format_value @@ -427,6 +428,51 @@ def populate_payload(self, payload, machine: "Machine"): head = self.get_selected_head(machine) payload.head_uid = head.uid if head else None payload.power = 0.0 + self._populate_laser_physics(payload, head) + + @staticmethod + def _populate_laser_physics(payload, head) -> None: + """Stamp the laser physics fields the burn fluence model needs. + + Reads wavelength, optical wattage, spot size, and scan speed + from the selected head when it is a ``LaserHead``. The cut + speed is the step's ``cut_speed`` (mm/min → mm/s). Unconfigured + heads leave the 0-sentinels; the raygeo converter falls back + to neutral defaults so the burn still renders. + """ + if not isinstance(head, LaserHead): + return + payload.wavelength_nm = head.effective_wavelength_nm() + payload.max_power_watts = head.effective_max_power_watts() + spot_x, spot_y = LaserHead.get_spot_size(head) + payload.spot_size_mm = (spot_x, spot_y) + # cut_speed is mm/min; the fluence model wants mm/s. + payload.scan_speed_mm_per_s = max(0.0, payload.cut_speed / 60.0) + if payload.scan_speed_mm_per_s <= 0.0: + # Fallback so a zero cut speed does not zero the fluence. + payload.scan_speed_mm_per_s = 100.0 + + def get_laser_cache_params(self, machine) -> dict[str, Any]: + """JSON-serialisable effective laser physics for cache tokens. + + The burn fluence model consumes the selected head's wavelength, + optical wattage, and spot size (plus this step's cut speed via + the scan-speed conversion). Folding them into the compute token + ensures a machine-power/wavelength/spot change invalidates the + raster/vector compute cache — otherwise the pipeline would keep + returning the pre-change burn until some unrelated edit bumps + the token. Returns ``{}`` for non-laser or headless steps. + """ + head = self.get_selected_head(machine) + if not isinstance(head, LaserHead): + return {} + spot_x, spot_y = LaserHead.get_spot_size(head) + return { + "wavelength_nm": head.effective_wavelength_nm(), + "max_power_watts": head.effective_max_power_watts(), + "spot_size_mm": [spot_x, spot_y], + "cut_speed_mm_min": self.cut_speed, + } def get_cache_params(self) -> dict[str, Any]: """JSON-serialisable step attributes that influence compute output. diff --git a/rayforge/doceditor/layer_cmd.py b/rayforge/doceditor/layer_cmd.py index d9042d85c..908773bf0 100644 --- a/rayforge/doceditor/layer_cmd.py +++ b/rayforge/doceditor/layer_cmd.py @@ -4,6 +4,7 @@ from gettext import gettext as _ from typing import TYPE_CHECKING +from ..context import get_context from ..core.color import pick_unused_color from ..core.group import Group from ..core.item import DocItem @@ -122,6 +123,9 @@ def _create_default_layer(self) -> Layer: new_name = f"{base_name} {highest_num + 1}" layer = Layer(name=new_name) + layer.set_stock_material_uid( + get_context().material_mgr.get_default_material().uid + ) used = {layer.color for layer in self._editor.doc.layers} layer.set_color(pick_unused_color(used)) diff --git a/rayforge/doceditor/stock_cmd.py b/rayforge/doceditor/stock_cmd.py index 5617e2d22..43504bb82 100644 --- a/rayforge/doceditor/stock_cmd.py +++ b/rayforge/doceditor/stock_cmd.py @@ -6,6 +6,7 @@ from raygeo.geo import Geometry, Matrix +from ..context import get_context from ..core.stock import StockItem from ..core.stock_asset import StockAsset from ..core.undo import ChangePropertyCommand, Command @@ -34,7 +35,10 @@ def __init__( ): super().__init__(name=_("Add Stock")) self.doc = doc + ctx = get_context() self.asset = StockAsset(name=name, geometry=geometry) + self.asset.material_uid = ctx.material_mgr.get_default_material().uid + self.asset.thickness = ctx.config.default_stock_thickness_mm self.item = StockItem(stock_asset_uid=self.asset.uid, name=name) w, h = self.asset.get_natural_size() self.item.matrix = Matrix.scale(w, h) @@ -88,7 +92,10 @@ def __init__(self, doc: Doc, workpiece: WorkPiece): if geometry is None: geometry = Geometry() + ctx = get_context() self.asset = StockAsset(name=workpiece.name, geometry=geometry) + self.asset.material_uid = ctx.material_mgr.get_default_material().uid + self.asset.thickness = ctx.config.default_stock_thickness_mm self.stock_item = StockItem( stock_asset_uid=self.asset.uid, name=workpiece.name ) diff --git a/rayforge/machine/models/colors.py b/rayforge/machine/models/colors.py index 3e76571f7..2a03bfb1e 100644 --- a/rayforge/machine/models/colors.py +++ b/rayforge/machine/models/colors.py @@ -42,17 +42,16 @@ def from_laser( Create an OpsColorSet from a Laser model and theme colors. Args: - laser: The Laser model with cut_color and raster_color properties + laser: The Laser model with a cut_color property theme_colors: The theme's ColorSet for travel and zero_power colors Returns: - An OpsColorSet with laser-specific cut/raster LUTs + An OpsColorSet with laser-specific cut/engrave LUTs """ - cut_rgba = hex_to_rgba(laser.cut_color) - raster_rgba = hex_to_rgba(laser.raster_color) + rgba = hex_to_rgba(laser.cut_color) - cut_lut = create_lut_from_color(cut_rgba) - engrave_lut = create_alpha_lut_from_color(raster_rgba) + cut_lut = create_lut_from_color(rgba) + engrave_lut = create_alpha_lut_from_color(rgba) travel_rgba = theme_colors.get_rgba("travel") zero_power_rgba = theme_colors.get_rgba("zero_power") diff --git a/rayforge/machine/models/laser.py b/rayforge/machine/models/laser.py index 84a643546..797ac5a0b 100644 --- a/rayforge/machine/models/laser.py +++ b/rayforge/machine/models/laser.py @@ -47,6 +47,9 @@ def default_wavelength_nm(self) -> float: # to this so unconfigured heads still produce a plausible burn. DEFAULT_MAX_POWER_WATTS = 40.0 +# Fallback operation colour when a laser's cut/raster colour is unset. +DEFAULT_LASER_COLOR = "#ff00ff" + def effective_focal_distance(head: Head | None) -> float: """The focal distance used for 3D visuals for a laser head. @@ -109,8 +112,7 @@ def __init__(self): self.frame_repeat_count = 20 self.frame_corner_pause = 0 # seconds self.spot_size_mm = 0.1, 0.1 # millimeters - self.cut_color: str = "#ff00ff" # Magenta for cut - self.raster_color: str = "#000000" # Black for raster + self.cut_color: str = DEFAULT_LASER_COLOR self.focal_distance = 0.0 self.laser_type = LaserType.DIODE self.wavelength_nm = 0.0 # 0 ⇒ LaserType.default_wavelength_nm @@ -193,12 +195,8 @@ def set_spot_size(self, spot_size_x_mm, spot_size_y_mm): self.spot_size_mm = spot_size_x_mm, spot_size_y_mm self.changed.send(self) - def set_cut_color(self, color: str): - self.cut_color = color - self.changed.send(self) - - def set_raster_color(self, color: str): - self.raster_color = color + def set_cut_color(self, color: str | None): + self.cut_color = color or DEFAULT_LASER_COLOR self.changed.send(self) def set_focal_distance(self, distance: float): @@ -289,7 +287,6 @@ def to_dict(self) -> dict[str, Any]: "frame_corner_pause": self.frame_corner_pause, "spot_size_mm": self.spot_size_mm, "cut_color": self.cut_color, - "raster_color": self.raster_color, "focal_distance": self.focal_distance, "laser_type": self.laser_type.value, "wavelength_nm": self.wavelength_nm, @@ -350,8 +347,7 @@ def from_dict(cls, data: dict[str, Any]) -> "LaserHead": lh.focus_power_percent = focus_power / lh.max_power lh.spot_size_mm = data.get("spot_size_mm", lh.spot_size_mm) - lh.cut_color = data.get("cut_color", lh.cut_color) - lh.raster_color = data.get("raster_color", lh.raster_color) + lh.cut_color = data.get("cut_color") or DEFAULT_LASER_COLOR lh.frame_speed = data.get("frame_speed", lh.frame_speed) lh.frame_repeat_count = data.get( "frame_repeat_count", lh.frame_repeat_count diff --git a/rayforge/pipeline/artifact/__init__.py b/rayforge/pipeline/artifact/__init__.py index f76b286f0..0a3717614 100644 --- a/rayforge/pipeline/artifact/__init__.py +++ b/rayforge/pipeline/artifact/__init__.py @@ -1,6 +1,10 @@ -from .base import BaseArtifact, TextureData +from .base import BaseArtifact from .handle import BaseArtifactHandle, create_handle_from_dict from .job import JobArtifact +from .material_state import ( + MaterialStateArtifact, + MaterialStateArtifactHandle, +) from .step_ops import StepOpsArtifact from .store import ArtifactStore from .workpiece import WorkPieceArtifact, WorkPieceArtifactHandle @@ -15,9 +19,10 @@ "BaseArtifact", "BaseArtifactHandle", "JobArtifact", + "MaterialStateArtifact", + "MaterialStateArtifactHandle", "RenderContext", "StepOpsArtifact", - "TextureData", "WorkPieceArtifact", "WorkPieceArtifactHandle", "WorkPieceViewArtifact", diff --git a/rayforge/pipeline/artifact/base.py b/rayforge/pipeline/artifact/base.py index 057b78695..c1597ae96 100644 --- a/rayforge/pipeline/artifact/base.py +++ b/rayforge/pipeline/artifact/base.py @@ -1,22 +1,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass - -import numpy as np from .handle import BaseArtifactHandle -@dataclass -class TextureData: - """A container for texture-based raster data.""" - - power_texture_data: np.ndarray - dimensions_mm: tuple[float, float] - position_mm: tuple[float, float] - - class BaseArtifact(ABC): @property def artifact_type(self) -> str: diff --git a/rayforge/pipeline/artifact/material_state.py b/rayforge/pipeline/artifact/material_state.py new file mode 100644 index 000000000..356178cf3 --- /dev/null +++ b/rayforge/pipeline/artifact/material_state.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from .base import BaseArtifact +from .handle import BaseArtifactHandle + +if TYPE_CHECKING: + from raygeo.ops.material.state import MaterialState + + +logger = logging.getLogger(__name__) + + +class MaterialStateArtifactHandle(BaseArtifactHandle): + """Handle to a stored :class:`MaterialStateArtifact`.""" + + def __init__( + self, + stock_uid: str, + key: str, + handle_class_name: str, + artifact_type_name: str, + generation_id: int, + array_metadata: dict[str, Any] | None = None, + **_kwargs, + ): + super().__init__( + key=key, + handle_class_name=handle_class_name, + artifact_type_name=artifact_type_name, + generation_id=generation_id, + array_metadata=array_metadata, + ) + self.stock_uid = stock_uid + + +class MaterialStateArtifact(BaseArtifact): + """The folded material state of one stock, as a pipeline artifact. + + Wraps the raygeo :class:`~raygeo.ops.material.state.MaterialState` + (voids, surface map, provenance) alongside the stock identity and + generation id. Consumers ask for a projection ("give me voids and a + heightmap for this stock"), so renderer code never assumes which + profile produced the state. + """ + + logger = logging.getLogger(__name__) + + def __init__( + self, + material_state: MaterialState, + stock_uid: str, + generation_id: int, + ): + super().__init__() + self.material_state = material_state + self.stock_uid = stock_uid + self.generation_id = generation_id + + def build_handle(self, key: str) -> MaterialStateArtifactHandle: + return MaterialStateArtifactHandle( + key=key, + handle_class_name=MaterialStateArtifactHandle.__name__, + artifact_type_name=self.__class__.__name__, + generation_id=self.generation_id, + stock_uid=self.stock_uid, + ) diff --git a/rayforge/pipeline/intent_builder.py b/rayforge/pipeline/intent_builder.py index 491806353..3c76503a3 100644 --- a/rayforge/pipeline/intent_builder.py +++ b/rayforge/pipeline/intent_builder.py @@ -66,6 +66,12 @@ GcodeSpec, PythonEncoder, ) +from raygeo.ops.material.spec import ( + CylinderStock, + FoldEntry, + MaterialFoldSpec, + PrismaticStock, +) from raygeo.pipeline.request import NodeRequest from raygeo.pipeline.stage import StageSpec @@ -74,6 +80,7 @@ from ..machine.kinematic_math import KinematicMath from ..machine.models.coordspace import MachineSpace from ..machine.models.dialect import GRBL_DIALECT +from ..machine.models.laser import LaserHead from ..machine.models.rotary_module import RotaryMode, RotaryType from .encoder.base import EncodedOutput from .encoder.rust_helpers import build_encode_context, dialect_to_spec @@ -84,6 +91,7 @@ from ..core.doc import Doc from ..core.layer import Layer from ..core.step import Step + from ..core.stock import StockItem from ..core.workpiece import WorkPiece from ..machine.models.dialect import GcodeDialect from ..machine.models.machine import Machine @@ -98,6 +106,7 @@ JOB_KEY = "job" JOB_ENCODE_KEY = "job:encode" JOB_MACHINEXFORM_KEY = "job:machinexform" +STOCK_KEY_FMT = "stock:{stock_uid}" class UnsupportedRotaryPanelOrientationError(ValueError): @@ -157,6 +166,24 @@ def job_machinexform_key() -> str: return JOB_MACHINEXFORM_KEY +def stock_key(stock_uid: str) -> str: + return STOCK_KEY_FMT.format(stock_uid=stock_uid) + + +def parse_stock_key(key: str) -> str | None: + """Parse a ``stock:{stock_uid}`` key. + + Returns the stock uid, or ``None`` if *key* does not match the + expected format. + """ + if not key.startswith("stock:"): + return None + rest = key[len("stock:") :] + if not rest or ":" in rest: + return None + return rest + + class IntentBuilder: """ Builds a flat :class:`NodeRequest` list from a :class:`Doc`. @@ -215,6 +242,16 @@ def build(self, doc: Doc) -> list[NodeRequest]: ) step_compute_inputs[step.uid] = inputs + # Collect every workpiece compute key alongside its workpiece + # so each stock fold node can depend on the workpiece compute + # nodes whose world AABB intersects the stock. + wp_compute_keys: list[tuple[str, WorkPiece]] = [] + for inputs in step_compute_inputs.values(): + for wp_key, _token, wp in inputs: + wp_compute_keys.append((wp_key, wp)) + self._build_stock_fold_nodes(doc, wp_compute_keys, nodes) + self._build_rotary_fold_nodes(doc, wp_compute_keys, nodes) + for layer in doc.layers: if not layer.workflow: continue @@ -386,6 +423,219 @@ def _build_encoder_node( stage = self._encode_stage(doc) out.append(self._make_request(key, token, stage)) + # ------------------------------------------------------------------ + # Stock fold compute nodes + # ------------------------------------------------------------------ + + def _workpiece_world_aabb( + self, wp: WorkPiece + ) -> tuple[float, float, float, float] | None: + """The workpiece's world-space AABB for fold dependency wiring. + + Uses the resolved world geometry when available (vector + workpieces). Image/raster workpieces carry no vector geometry + (``get_world_geometry`` returns ``None``), so their AABB is + derived from the workpiece ``size`` transformed into world + space via its world transform. Returns ``None`` when neither + yields a usable rect (no geometry and no size). + """ + geo = wp.get_world_geometry() + if geo is not None and not geo.is_empty(): + return geo.rect() + if not wp.size: + return None + w, h = wp.size + if w <= 0 or h <= 0: + return None + world = wp.get_world_transform() + corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)] + pts = [world.transform_point(x, y) for x, y in corners] + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return (min(xs), min(ys), max(xs), max(ys)) + + def _active_laser_physics(self) -> tuple[float, float]: + """The active laser's ``(wavelength_nm, max_power_watts)``. + + Used as provenance for the fold's surface map: the renderer + looks up the material's absorption coefficient for this + wavelength's band. Falls back to ``(0, 0)`` (unconfigured) when + the machine has no laser head, so the renderer falls back to + full absorption. + """ + if self._machine is None: + return (0.0, 0.0) + for head in self._machine.heads: + if isinstance(head, LaserHead): + return ( + head.effective_wavelength_nm(), + head.effective_max_power_watts(), + ) + return (0.0, 0.0) + + def _build_stock_fold_nodes( + self, + doc: Doc, + wp_compute_keys: list[tuple[str, WorkPiece]], + out: list[NodeRequest], + ) -> None: + """Emit one ``MaterialFold`` compute node per visible stock item. + + Each fold node depends on every workpiece compute node whose + world AABB intersects that stock's world AABB. Stock items with + no intersecting workpiece (nothing to fold) are skipped. + """ + for stock_item in doc.stock_items: + if not stock_item.visible: + continue + self._build_stock_fold_node(stock_item, wp_compute_keys, out) + + def _build_stock_fold_node( + self, + stock_item: StockItem, + wp_compute_keys: list[tuple[str, WorkPiece]], + out: list[NodeRequest], + ) -> None: + """Emit a ``stock:{uid}`` fold node for one stock item.""" + stock_geo = stock_item.get_world_rect_geometry() + if stock_geo is None or stock_geo.is_empty(): + return + stock_rect = stock_geo.rect() + thickness = stock_item.thickness + if thickness is None or thickness <= 0: + return + stock_polygons = stock_geo.to_polygons() + + entries: list[FoldEntry] = [] + source_keys: list[str] = [] + for wp_key, wp in wp_compute_keys: + wp_rect = self._workpiece_world_aabb(wp) + if wp_rect is None: + continue + if not _aabb_intersects(stock_rect, wp_rect): + continue + placement = _workpiece_placement_matrix_obj(wp) + entries.append( + FoldEntry( + source_key=wp_key, + placement=placement, + effects=[], # filled at runtime by MaterialFoldCompute + ) + ) + source_keys.append(wp_key) + + if not entries: + return + + key = stock_key(stock_item.uid) + token = self._stock_fold_token(stock_item, source_keys) + wavelength_nm, max_power_watts = self._active_laser_physics() + spec = MaterialFoldSpec( + stock=PrismaticStock( + polygons=stock_polygons, + thickness=thickness, + ), + entries=entries, + wavelength_nm=wavelength_nm, + max_power_watts=max_power_watts, + ) + # The MaterialFoldSpec is passed directly as the node's stage. + out.append(self._make_request(key, token, spec)) + + def _build_rotary_fold_nodes( + self, + doc: Doc, + wp_compute_keys: list[tuple[str, WorkPiece]], + out: list[NodeRequest], + ) -> None: + """Emit one ``MaterialFold`` compute node per rotary layer. + + Rotary stock folds in unrolled space: world x is the axial + coordinate, world y the arc length around the circumference, + centered on the machine origin. The fold domain spans the + rotary module's maximum workpiece length; renderers map their + (possibly shorter) cylinder through the returned grid. + """ + for layer in doc.layers: + if not layer.rotary_enabled or layer.rotary_diameter <= 0: + continue + module = self._machine.get_rotary_module_for_layer(layer) + if module is None: + logger.warning( + "Rotary layer %r has no rotary module on the " + "machine; skipping its burn fold", + layer.name, + ) + continue + self._build_rotary_fold_node( + layer, + float(module.max_workpiece_length), + {wp.uid: key for key, wp in wp_compute_keys}, + out, + ) + + def _build_rotary_fold_node( + self, + layer: Layer, + max_length: float, + keys_by_wp_uid: dict[str, str], + out: list[NodeRequest], + ) -> None: + """Emit a ``stock:{layer.uid}`` fold node for one rotary layer. + + Every workpiece of the layer with a compute node contributes + an entry — no AABB gate: image workpieces may not resolve + world geometry outside rendering, and the fold's raster + sampling is harmless for entries that miss the unrolled + domain. + """ + diameter = float(layer.rotary_diameter) + circumference = math.pi * diameter + + entries: list[FoldEntry] = [] + source_keys: list[str] = [] + for wp in layer.all_workpieces: + wp_key = keys_by_wp_uid.get(wp.uid) + if wp_key is None: + continue + placement = _workpiece_placement_matrix_obj(wp) + entries.append( + FoldEntry( + source_key=wp_key, + placement=placement, + effects=[], # filled at runtime by MaterialFoldCompute + ) + ) + source_keys.append(wp_key) + + if not entries: + logger.debug( + "Rotary layer %r: no workpiece compute nodes; skipping fold", + layer.name, + ) + return + + key = stock_key(layer.uid) + token = self._rotary_fold_token(layer, max_length, source_keys) + wavelength_nm, max_power_watts = self._active_laser_physics() + spec = MaterialFoldSpec( + stock=CylinderStock(diameter=diameter, length=max_length), + entries=entries, + wavelength_nm=wavelength_nm, + max_power_watts=max_power_watts, + ) + logger.info( + "Emitting burn fold for rotary layer %r (domain %.0fx%.0f mm," + " %d entr%s)", + layer.name, + max_length, + circumference, + len(entries), + "y" if len(entries) == 1 else "ies", + ) + # The MaterialFoldSpec is passed directly as the node's stage. + out.append(self._make_request(key, token, spec)) + # ------------------------------------------------------------------ # Token computation # ------------------------------------------------------------------ @@ -411,6 +661,49 @@ def _stock_revision(self) -> int: ) return _hash_int({"kind": "stock", "items": payload}) + def _stock_fold_token( + self, stock_item: StockItem, source_keys: list[str] + ) -> int: + """Version token for a stock fold node. + + Folds in stock identity, world transform, thickness, and the + set of upstream source keys. Does NOT fold in upstream compute + tokens — the pipeline's dependency-based invalidation handles + that: if an upstream compute token changes, the fold node's + deps change and it re-executes regardless of its own token. + """ + thickness = stock_item.thickness + payload = { + "kind": "stock_fold", + "stock_uid": stock_item.uid, + "stock_asset_uid": stock_item.stock_asset_uid, + "stock_matrix": stock_item.matrix.to_list(), + "thickness": thickness if thickness is not None else 0.0, + "source_keys": sorted(source_keys), + } + return _hash_int(payload) + + def _rotary_fold_token( + self, layer: Layer, max_length: float, source_keys: list[str] + ) -> int: + """Version token for a rotary layer's fold node. + + Folds in the layer identity, diameter, and fold-domain length + plus the set of upstream source keys. Does NOT fold in + upstream compute tokens — the pipeline's dependency-based + invalidation handles that: if an upstream compute token + changes, the fold node's deps change and it re-executes + regardless of its own token. + """ + payload = { + "kind": "stock_fold_rotary", + "layer_uid": layer.uid, + "diameter": float(layer.rotary_diameter), + "length": max_length, + "source_keys": sorted(source_keys), + } + return _hash_int(payload) + def _compute_token( self, step: Step, wp: WorkPiece, pos_sensitive: bool ) -> int: @@ -421,6 +714,12 @@ def _compute_token( "geo_rev": wp.geometry_revision, "wp_size": list(wp.size) if wp.size else [0, 0], "step_params": step.get_cache_params(), + # The burn fluence model consumes the selected head's laser + # physics; fold them in so a machine power/wavelength/spot + # change invalidates the compute cache. + "laser_params": _canonical( + step.get_laser_cache_params(self._machine) + ), "assembler_params": _canonical(self._assembler_params(step, wp)), "wpxf": _canonical(step.per_workpiece_transformers_dicts), } @@ -1077,12 +1376,29 @@ def _workpiece_placement_matrix(wp: WorkPiece) -> list[list[float]]: aggregate via ``target_dimensions`` for scalable artifacts, so the placement matrix only carries translation, rotation, flip, and skew. """ + return _workpiece_placement_matrix_obj(wp).to_4x4_list() + + +def _workpiece_placement_matrix_obj(wp: WorkPiece) -> Matrix: + """The workpiece world placement as a raygeo :class:`Matrix`. + + Scale-normalised like :func:`_workpiece_placement_matrix` so the + fold placement only carries translation, rotation, flip, and skew; + the fold operates in world mm, where the workpiece's source + geometry already carries its true dimensions. + """ world = wp.get_world_transform() tx, ty, angle, _sx, sy, skew = world.decompose() - placement = Matrix.compose( - tx, ty, angle, 1.0, math.copysign(1.0, sy), skew - ) - return placement.to_4x4_list() + return Matrix.compose(tx, ty, angle, 1.0, math.copysign(1.0, sy), skew) + + +def _aabb_intersects( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], +) -> bool: + """Return ``True`` if two AABBs ``(min_x, min_y, max_x, max_y)`` + overlap. Touching edges are not considered an intersection.""" + return not (a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1]) def _canonical(obj: Any) -> Any: diff --git a/rayforge/pipeline/intent_controller.py b/rayforge/pipeline/intent_controller.py index 274032cbd..ceced69c3 100644 --- a/rayforge/pipeline/intent_controller.py +++ b/rayforge/pipeline/intent_controller.py @@ -48,6 +48,7 @@ from .intent_builder import ( IntentBuilder, UnsupportedRotaryPanelOrientationError, + parse_stock_key, parse_workpiece_key, ) from .status_messages import status_message_for_key @@ -55,7 +56,9 @@ if TYPE_CHECKING: from ..core.doc import Doc from ..core.item import DocItem + from ..core.layer import Layer from ..core.step import Step + from ..core.stock import StockItem from ..core.workpiece import WorkPiece from ..machine.models.machine import Machine @@ -151,6 +154,7 @@ def __init__( self._key_to_item: dict[str, DocItem] = {} self._workpieces_by_uid: dict[str, WorkPiece] = {} self._steps_by_uid: dict[str, Step] = {} + self._stock_items_by_uid: dict[str, StockItem] = {} # Signals for notifying the UI of generation progress. self.workpiece_artifact_ready = Signal() @@ -158,6 +162,7 @@ def __init__( self.job_aggregate_ready = Signal() self.job_generation_finished = Signal() self.job_time_updated = Signal() + self.material_state_ready = Signal() self.progress_changed = Signal() self.rebuild_started = Signal() self.rebuild_finished = Signal() @@ -611,6 +616,19 @@ def _reattach(self, key: str, item: DocItem, output: Any) -> None: self.job_generation_finished.send( self, handle=output, task_status="completed" ) + elif key.startswith("stock:"): + stock_uid = parse_stock_key(key) + if stock_uid is not None: + host: DocItem | None = self._find_stock_item(stock_uid) + if host is None: + host = self._find_layer(stock_uid) + if host is not None: + self.material_state_ready.send( + self, + item=host, + output=output, + generation_id=gen, + ) # ------------------------------------------------------------------ # Helpers @@ -641,6 +659,16 @@ def _refresh_key_to_item_map(self, nodes: list[NodeRequest]) -> None: self._workpieces_by_uid = workpieces self._steps_by_uid = steps + stock_items: dict[str, StockItem] = {} + layers_by_uid: dict[str, Layer] = {} + if self._doc is not None: + for item in self._doc.stock_items: + stock_items[item.uid] = item + for layer in self._doc.layers: + layers_by_uid[layer.uid] = layer + self._stock_items_by_uid = stock_items + self._layers_by_uid = layers_by_uid + for n in nodes: key = n.key # ``workpiece:{wp_uid}:{step_uid}`` @@ -660,6 +688,16 @@ def _refresh_key_to_item_map(self, nodes: list[NodeRequest]) -> None: step = steps.get(s_uid) if step is not None: self._key_to_item[key] = step + # ``stock:{stock_uid}`` — a flat stock item or, when no + # stock item carries the uid, a rotary layer. + elif key.startswith("stock:"): + stock_uid = parse_stock_key(key) + if stock_uid is not None: + item = stock_items.get(stock_uid) + if item is None: + item = layers_by_uid.get(stock_uid) + if item is not None: + self._key_to_item[key] = item # ``job`` or ``job:encode`` elif key == "job" or key == "job:encode": self._key_to_item[key] = self._doc @@ -670,6 +708,12 @@ def _find_workpiece(self, uid: str) -> WorkPiece | None: def _find_step(self, uid: str) -> Step | None: return self._steps_by_uid.get(uid) + def _find_stock_item(self, uid: str) -> StockItem | None: + return self._stock_items_by_uid.get(uid) + + def _find_layer(self, uid: str) -> Layer | None: + return self._layers_by_uid.get(uid) + def shutdown(self) -> None: """Cancel any pending rebuild timer and disconnect signals.""" if self._rebuild_timer is not None: diff --git a/rayforge/pipeline/pipeline.py b/rayforge/pipeline/pipeline.py index 0714207d4..44295c11f 100644 --- a/rayforge/pipeline/pipeline.py +++ b/rayforge/pipeline/pipeline.py @@ -17,7 +17,12 @@ from ..core.doc import Doc from ..core.workpiece import WorkPiece from ..machine.kinematic_mapping import KinematicMapping -from .artifact import BaseArtifactHandle, JobArtifact, WorkPieceArtifact +from .artifact import ( + BaseArtifactHandle, + JobArtifact, + MaterialStateArtifact, + WorkPieceArtifact, +) from .artifact.store import ArtifactStore from .encoder.base import EncodedOutput, MachineCodeOpMap from .intent_builder import ( @@ -72,6 +77,7 @@ def __init__( self._last_known_busy = False self._wp_handles: dict[tuple[str, str], BaseArtifactHandle] = {} + self._material_state_handles: dict[str, BaseArtifactHandle] = {} self._last_aggregate_output: Any = None self._last_job_handle: BaseArtifactHandle | None = None @@ -82,6 +88,7 @@ def __init__( self.step_assembly_starting = Signal() self.job_generation_finished = Signal() self.job_time_updated = Signal() + self.material_state_ready = Signal() self.visual_chunk_available = Signal() self.data_stale = Signal() self.pipeline_error = Signal() @@ -139,6 +146,7 @@ def _connect_ctl_signals(self) -> None: ctl.job_aggregate_ready.connect(self._on_job_aggregate) ctl.job_generation_finished.connect(self._on_job_encoded) ctl.job_time_updated.connect(self._job_time_relay) + ctl.material_state_ready.connect(self._on_material_state) ctl.rebuild_started.connect(self._on_rebuild_started) ctl.rebuild_finished.connect(self._on_rebuild_finished) ctl.data_stale.connect(self._on_data_stale) @@ -159,6 +167,7 @@ def doc(self, new_doc: Doc | None) -> None: return self._doc = new_doc self._wp_handles.clear() + self._material_state_handles.clear() self._last_job_handle = None self._last_aggregate_output = None self._intent_ctl.set_doc(new_doc) @@ -348,6 +357,36 @@ def _on_wp_output( generation_id=generation_id, ) + def _on_material_state( + self, sender, *, item, output, generation_id + ) -> None: + """Wrap a ``MaterialState`` into a :class:`MaterialStateArtifact`, + store it, and emit :attr:`material_state_ready` with the handle. + + ``output`` is a raygeo ``MaterialState`` returned by the fold + compute node. ``item`` is the material host — a flat stock item + or a rotary layer. The artifact is stored so renderers can pick + it up without changing the signal path. + """ + if self._is_shutting_down or output is None: + return + artifact = MaterialStateArtifact( + material_state=output, + stock_uid=item.uid, + generation_id=generation_id, + ) + old = self._material_state_handles.pop(item.uid, None) + if old is not None: + self._store.release(old) + handle = self._store.put(artifact, "material") + self._material_state_handles[item.uid] = handle + self.material_state_ready.send( + self, + item=item, + handle=handle, + generation_id=generation_id, + ) + def _on_job_aggregate(self, sender, *, output, generation_id) -> None: if self._is_shutting_down: return diff --git a/rayforge/resources/materials/oak.webp b/rayforge/resources/materials/oak.webp new file mode 100644 index 000000000..fd8d8fa79 Binary files /dev/null and b/rayforge/resources/materials/oak.webp differ diff --git a/rayforge/resources/materials/oak.yaml b/rayforge/resources/materials/oak.yaml new file mode 100644 index 000000000..fc9e88d62 --- /dev/null +++ b/rayforge/resources/materials/oak.yaml @@ -0,0 +1,31 @@ +uid: oak +name: + default: Oak + de: Eiche + es: Roble + fr: Chêne + pt: Carvalho + uk: Дуб + zh_CN: 橡木 +description: + default: Hardwood with distinctive grain pattern + de: Hartholz mit ausgeprägter Maserung + es: Madera dura con patrón de veta distintivo + fr: Bois dur avec un motif de grain distinctif + pt: Madeira dura com padrão de grão distinto + uk: Тверда деревина з характерним візерунком + zh_CN: 具有独特纹理的硬木 +category: + default: Wood + de: Holz + es: Madera + fr: Bois + pt: Madeira + uk: Деревина + zh_CN: 木材 +appearance: + pattern: wood_grain + texture: oak.webp + texture_size_mm: 300 + roughness: 0.55 + metallic: 0.0 \ No newline at end of file diff --git a/rayforge/simulator/scene3d/__init__.py b/rayforge/simulator/scene3d/__init__.py index 24f4d5ccc..08303245e 100644 --- a/rayforge/simulator/scene3d/__init__.py +++ b/rayforge/simulator/scene3d/__init__.py @@ -3,7 +3,6 @@ CompiledSceneArtifactHandle, ScanlineOverlayLayer, StockLayer, - TextureLayer, VertexLayer, WorkpieceImage, ) @@ -30,7 +29,6 @@ "ScanlineOverlayLayer", "SceneItem", "StockLayer", - "TextureLayer", "VertexLayer", "WorkpieceImage", "build_pick_scene", diff --git a/rayforge/simulator/scene3d/compiled_scene.py b/rayforge/simulator/scene3d/compiled_scene.py index 9583c430a..9f88c709f 100644 --- a/rayforge/simulator/scene3d/compiled_scene.py +++ b/rayforge/simulator/scene3d/compiled_scene.py @@ -42,29 +42,6 @@ class VertexLayer(SceneItem): is_rotary: bool = False -@dataclass -class TextureLayer(SceneItem): - power_texture: CompressedArray - width_px: int - height_px: int - model_matrix: np.ndarray - cylinder_vertices: np.ndarray | None = None - rotary_diameter: float = 0.0 - rotary_enabled: bool = False - activation_cmd_idx: int = -1 - laser_uid: str = "" - - def pick_mesh(self, ctx: PickContext) -> PickMesh | None: - if self.rotary_enabled: - if self.cylinder_vertices is None or ctx.cyl_model is None: - return None - verts = np.asarray( - self.cylinder_vertices, dtype=np.float32 - ).reshape(-1, 5) - return PickMesh(verts[:, :3], ctx.cyl_model) - return PickMesh(_QUAD_TRIANGLES, self.model_matrix) - - @dataclass class ScanlineOverlayLayer(SceneItem): positions: CompressedArray @@ -109,6 +86,15 @@ class StockLayer(SceneItem): ``transform`` maps them into visual space at draw time. UVs are ``world_xy / texture_size_mm`` so the texture tiles at a physical density and repeats via ``GL_REPEAT``. + + When the stock has a folded burn surface map, ``power_texture`` + carries it as an F32 :class:`CompressedArray` (fluence in J/cm², + bottom-up rows), ``power_size_px`` its ``(width, height)``, + ``power_aabb`` the world-mm burn grid rect + ``(min_x, min_y, max_x, max_y)``, ``power_uvs`` the per-vertex + coordinates into that grid sampled by the stock shader's burn + pass, and ``wavelength_nm`` the emission wavelength for the + material's absorption lookup. """ positions: np.ndarray @@ -130,6 +116,30 @@ class StockLayer(SceneItem): # Resolved per-instance tint color (RGBA), or None for no tint. # Applied on the GPU as colorization (luma * tint) in the stock shader. tint_rgba: tuple[float, float, float, float] | None = None + # Burn-in surface map (F32 fluence, world-y-up rows) and its + # placement. + power_texture: CompressedArray | None = None + power_size_px: tuple[int, int] | None = None + power_aabb: tuple[float, float, float, float] | None = None + power_uvs: np.ndarray = field( + default_factory=lambda: np.empty((0, 2), dtype=np.float32) + ) + # Emission wavelength in nm of the laser that produced the fluence + # (0 = unconfigured; the renderer falls back to full absorption). + wavelength_nm: float = 0.0 + # Resolved absorption coefficient (0-1) for the laser's wavelength + # band and this material. 1.0 = full absorption (default/fallback). + absorption: float = 1.0 + # Resolved burn-response char-curve parameters (char_threshold, + # char_saturation, char_color_low/high) for the shader. + burn_response: dict = field( + default_factory=lambda: { + "char_threshold": 35.0, + "char_saturation": 125.0, + "char_color_low": (0.04, 0.03, 0.02), + "char_color_high": (0.01, 0.01, 0.01), + } + ) def pick_mesh(self, ctx: PickContext) -> PickMesh | None: matrix = ctx.cyl_model if self.is_rotary else self.transform @@ -166,14 +176,12 @@ def __init__( self, generation_id: int, vertex_layers: list[VertexLayer], - texture_layers: list[TextureLayer], overlay_layers: list[ScanlineOverlayLayer], laser_uid_order: list[str] | None = None, stock_layers: list[StockLayer] | None = None, ): self.generation_id = generation_id self.vertex_layers = vertex_layers - self.texture_layers = texture_layers self.overlay_layers = overlay_layers self.laser_uid_order = laser_uid_order or [] self.stock_layers = stock_layers or [] diff --git a/rayforge/simulator/scene3d/render_config.py b/rayforge/simulator/scene3d/render_config.py index e217af11d..bf243ea9d 100644 --- a/rayforge/simulator/scene3d/render_config.py +++ b/rayforge/simulator/scene3d/render_config.py @@ -54,7 +54,6 @@ class RenderConfig3D: stock_top_z: float = 0.0 has_z_axis: bool = True layer_configs: dict[str, LayerRenderConfig] | None = None - laser_dot_widths_mm: dict[str, float] | None = None stock_specs: list[dict] | None = None def to_dict(self) -> dict: @@ -78,8 +77,6 @@ def to_dict(self) -> dict: d["layer_configs"] = { k: v.to_dict() for k, v in self.layer_configs.items() } - if self.laser_dot_widths_mm: - d["laser_dot_widths_mm"] = dict(self.laser_dot_widths_mm) if self.stock_specs: d["stock_specs"] = list(self.stock_specs) return d @@ -116,6 +113,5 @@ def from_dict(cls, data: dict[str, Any]) -> RenderConfig3D: stock_top_z=data.get("stock_top_z", 0.0), has_z_axis=data.get("has_z_axis", True), layer_configs=layer_configs, - laser_dot_widths_mm=data.get("laser_dot_widths_mm"), stock_specs=data.get("stock_specs"), ) diff --git a/rayforge/simulator/scene3d/scene_compiler.py b/rayforge/simulator/scene3d/scene_compiler.py index e95b77315..98133758c 100644 --- a/rayforge/simulator/scene3d/scene_compiler.py +++ b/rayforge/simulator/scene3d/scene_compiler.py @@ -1,6 +1,10 @@ """ Scene compiler: thin wrapper that delegates vertex compilation to -raygeo's Rust ``compile_scene_3d`` and handles texture generation. +raygeo's Rust ``compile_scene_3d`` and packs the stock meshes. + +Engraving is visualized by the burned-in stock only; the Ops lines and +the scanline ring buffer carry the toolpath/trail. No LUT engrave +underlay quads are produced. """ from __future__ import annotations @@ -9,145 +13,18 @@ from collections.abc import Callable import numpy as np -from raygeo.compressed_array import CompressedArray -from raygeo.image import rasterize_scanlines -from raygeo.ops import LayerInfo, Ops +from raygeo.ops import Ops from .compiled_scene import ( CompiledSceneArtifact, ScanlineOverlayLayer, - TextureLayer, VertexLayer, ) -from .cylinder_compiler import generate_cylinder_vertices from .render_config import RenderConfig3D from .stock_compiler import compile_stock_layers logger = logging.getLogger(__name__) -MAX_TEXTURE_DIMENSION = 8192 -PX_PER_MM = 50.0 - -# Fallback laser dot width (mm) when no laser head info is available, -# matching the minimum sane spot size used elsewhere in the codebase. -DEFAULT_DOT_WIDTH_MM = 0.1 - - -# ── Texture generation ───────────────────────── - - -def _rasterize_scanlines( - ops: Ops, - bbox: tuple[float, float, float, float], - dot_width_mm: float, -) -> tuple[CompressedArray, int, int, float] | None: - x0, y0, w_mm, h_mm = bbox - if w_mm <= 0 or h_mm <= 0: - return None - - px_per_mm = PX_PER_MM - width_px = round(w_mm * px_per_mm) - height_px = round(h_mm * px_per_mm) - - if width_px > MAX_TEXTURE_DIMENSION or height_px > MAX_TEXTURE_DIMENSION: - scale = min( - MAX_TEXTURE_DIMENSION / width_px, - MAX_TEXTURE_DIMENSION / height_px, - ) - px_per_mm *= scale - width_px = round(w_mm * px_per_mm) - height_px = round(h_mm * px_per_mm) - - if width_px <= 0 or height_px <= 0: - return None - - dot_width_px = dot_width_mm * px_per_mm - radius_px = max(0, int((dot_width_px - 1) / 2)) - - buffer = rasterize_scanlines( - ops, - width_px, - height_px, - (px_per_mm, px_per_mm), - origin_mm=(x0, y0), - radius_px=radius_px, - ) - if not isinstance(buffer, CompressedArray): - return None - - return buffer, width_px, height_px, px_per_mm - - -def _generate_texture_layers( - ops: Ops, - layer_infos: list[LayerInfo], - config: RenderConfig3D, -) -> list[TextureLayer]: - texture_layers: list[TextureLayer] = [] - dot_widths = config.laser_dot_widths_mm or {} - - for li in layer_infos: - if not li.has_scanlines: - continue - - layer_ops = ops.extract_range(li.cmd_start, li.cmd_end) - - is_rot = li.is_rotary - - if is_rot: - layer_ops = layer_ops.bake_visual_positions() - - bbox = layer_ops.scanline_bbox() - if bbox is None: - continue - - dot_width_mm = dot_widths.get(li.scanline_laser) - if dot_width_mm is None: - dot_width_mm = DEFAULT_DOT_WIDTH_MM - raster_result = _rasterize_scanlines(layer_ops, bbox, dot_width_mm) - if raster_result is None: - continue - - tex_buf, w_px, h_px, _actual_ppm = raster_result - x0, y0, bw, bh = bbox - - diameter = li.diameter - - if is_rot and diameter > 0: - tex_transform = np.eye(4, dtype=np.float32) - else: - tex_transform = config.world_to_visual - - model = np.eye(4, dtype=np.float32) - model[0, 0] = bw - model[1, 1] = bh - model[0, 3] = x0 - model[1, 3] = y0 - final_model = (tex_transform @ model).astype(np.float32) - - cyl_verts = None - if is_rot and diameter > 0: - cyl_verts = generate_cylinder_vertices( - grid_matrix=final_model, - diameter=diameter, - ) - - texture_layers.append( - TextureLayer( - power_texture=tex_buf, - width_px=w_px, - height_px=h_px, - model_matrix=final_model, - cylinder_vertices=cyl_verts, - rotary_diameter=diameter, - rotary_enabled=is_rot, - activation_cmd_idx=li.activation_cmd_idx, - laser_uid=li.scanline_laser, - ) - ) - - return texture_layers - # ── Spec building ───────────────────────────────────────────────── @@ -171,7 +48,6 @@ def _build_scene_spec(config: RenderConfig3D) -> tuple[list, dict]: def _wrap_compiled_scene( raw, - ops: Ops, config: RenderConfig3D, generation_id: int = 0, ) -> CompiledSceneArtifact: @@ -199,8 +75,6 @@ def _wrap_compiled_scene( ) ) - layer_infos = raw.layer_infos - texture_layers = _generate_texture_layers(ops, layer_infos, config) stock_w2v = ( config.stock_world_to_visual if config.stock_world_to_visual is not None @@ -211,7 +85,6 @@ def _wrap_compiled_scene( return CompiledSceneArtifact( generation_id=generation_id, vertex_layers=vertex_layers, - texture_layers=texture_layers, overlay_layers=overlay_layers, laser_uid_order=raw.laser_uid_order, stock_layers=stock_layers, @@ -232,8 +105,6 @@ def compile_scene( w2v, layer_configs = _build_scene_spec(config) raw = ops.compile_scene_3d(w2v, layer_configs) - artifact = _wrap_compiled_scene( - raw, ops, config, generation_id=generation_id - ) + artifact = _wrap_compiled_scene(raw, config, generation_id=generation_id) return artifact diff --git a/rayforge/simulator/scene3d/scene_compiler_runner.py b/rayforge/simulator/scene3d/scene_compiler_runner.py index e6756b027..29abb4cbe 100644 --- a/rayforge/simulator/scene3d/scene_compiler_runner.py +++ b/rayforge/simulator/scene3d/scene_compiler_runner.py @@ -73,7 +73,6 @@ def compile_stock_scene( return CompiledSceneArtifact( generation_id=0, vertex_layers=[], - texture_layers=[], overlay_layers=[], laser_uid_order=[], stock_layers=stock_layers, diff --git a/rayforge/simulator/scene3d/stock_compiler.py b/rayforge/simulator/scene3d/stock_compiler.py index 8f31eb46a..01d59d6c6 100644 --- a/rayforge/simulator/scene3d/stock_compiler.py +++ b/rayforge/simulator/scene3d/stock_compiler.py @@ -27,12 +27,18 @@ import logging import math +from typing import TYPE_CHECKING, NamedTuple import numpy as np from raygeo.mesh.build import build_prism_mesh +from raygeo.ops.material.grid import compute_power_uvs +from ...core.optical import absorption_for from .compiled_scene import StockLayer +if TYPE_CHECKING: + from raygeo.compressed_array import CompressedArray + logger = logging.getLogger(__name__) # Fallback thickness when the asset has none configured. @@ -133,7 +139,12 @@ def _build_cylinder_shell( j_vals = np.linspace(0.0, 1.0, rings + 1, dtype=np.float64) ii, jj = np.meshgrid(i_vals, j_vals, indexing="ij") - theta = 2.0 * math.pi * jj + # The angular parametrization starts at pi (the bottom of the + # roller) so the duplicated seam sits opposite the beam-home + # direction: the burn grid's unrolled domain is centered on the + # machine origin (y = 0 at the top), and a power-uv v of 0..1 + # then spans it without crossing the texture cut mid-surface. + theta = math.pi + 2.0 * math.pi * jj x = ii * length y = radius * np.sin(theta) z = radius * np.cos(theta) @@ -209,6 +220,126 @@ def _build_cylinder_shell( return positions, normals, uvs, indices +def _rotary_power_uvs( + diameter: float, + length: float, + power_grid: tuple[ + tuple[float, float], tuple[float, float], tuple[int, int] + ], + rings: int = CYLINDER_RINGS, + length_segments: int = CYLINDER_LENGTH_SEGMENTS, +) -> np.ndarray: + """Power UVs for a cylinder shell built by ``_build_cylinder_shell``. + + Derived from the same parametrization as the mesh so the vertex + order matches exactly (side vertices, then the two cap centers). + U maps axial position through the burn grid like the flat path; + V follows the angular parameter, with the duplicated seam mapping + to 0 and 1 so no quad interpolates across it. + """ + origin_mm, px_per_mm, size_px = power_grid + i_vals = np.linspace(0.0, 1.0, length_segments + 1, dtype=np.float64) + j_vals = np.linspace(0.0, 1.0, rings + 1, dtype=np.float64) + ii, jj = np.meshgrid(i_vals, j_vals, indexing="ij") + u = ((ii * length - origin_mm[0]) * px_per_mm[0]) / size_px[0] + side = np.stack([u.ravel(), jj.ravel()], axis=-1).astype(np.float32) + caps = np.zeros((2, 2), dtype=np.float32) + return np.concatenate([side, caps], axis=0) + + +class _BurnData(NamedTuple): + """A stock's folded burn surface map and its world placement.""" + + surface_map: CompressedArray + size_px: tuple[int, int] + aabb: tuple[float, float, float, float] + power_grid: tuple[ + tuple[float, float], tuple[float, float], tuple[int, int] + ] + wavelength_nm: float + max_power_watts: float + + +def _default_burn_response() -> dict: + """Default char-curve parameters (match the shader fallback). + + In the fluence (J/cm²) domain, calibrated against real-world + desktop diode behaviour: ~30 J/cm² (5 W at 10 %, 1000 mm/min) + leaves no mark, full power (~300 J/cm²) engraves solidly. + Char colors are black (carbonized material), not the laser color. + """ + return { + "char_threshold": 35.0, + "char_saturation": 125.0, + "char_color_low": (0.04, 0.03, 0.02), + "char_color_high": (0.01, 0.01, 0.01), + } + + +def _resolve_absorption( + absorption_dict: dict | None, wavelength_nm: float +) -> float: + """Absorption coefficient for the burn's wavelength band.""" + return absorption_for(wavelength_nm, absorption_dict) + + +def _parse_burn_spec(spec: dict) -> _BurnData | None: + """Parse a stock spec's optional ``burn`` entry. + + The presenter attaches it when the material fold produced a burn + surface map for this stock: + ``{"surface_map": CompressedArray, "origin_mm": (x, y), + "px_per_mm": (x, y), "size_px": (w, h)}``. + + Returns the parsed burn data (with the grid's world-mm AABB and + the ``power_grid`` argument for ``build_prism_mesh``), or ``None`` + when the entry is absent or invalid. + """ + burn = spec.get("burn") + if not isinstance(burn, dict): + return None + surface_map = burn.get("surface_map") + origin_mm = burn.get("origin_mm") + px_per_mm = burn.get("px_per_mm") + size_px = burn.get("size_px") + if ( + surface_map is None + or not isinstance(origin_mm, tuple) + or not isinstance(px_per_mm, tuple) + or not isinstance(size_px, tuple) + or len(origin_mm) != 2 + or len(px_per_mm) != 2 + or len(size_px) != 2 + or px_per_mm[0] <= 0 + or px_per_mm[1] <= 0 + or size_px[0] < 1 + or size_px[1] < 1 + ): + logger.warning( + "Ignoring invalid burn entry of stock %r", spec.get("name") + ) + return None + (ox, oy), (ppm_x, ppm_y), (w_px, h_px) = ( + origin_mm, + px_per_mm, + size_px, + ) + aabb = ( + float(ox), + float(oy), + float(ox) + float(w_px) / float(ppm_x), + float(oy) + float(h_px) / float(ppm_y), + ) + return _BurnData( + surface_map=surface_map, + size_px=(int(w_px), int(h_px)), + aabb=aabb, + power_grid=(origin_mm, px_per_mm, size_px), + wavelength_nm=float(burn.get("wavelength_nm") or 0.0), + max_power_watts=float(burn.get("max_power_watts") or 0.0), + ) + + def _compile_stock_spec( spec: dict, stock_w2v: np.ndarray ) -> StockLayer | None: @@ -220,6 +351,10 @@ def _compile_stock_spec( metallic = float(spec.get("metallic") or 0.0) fallback_rgba = _parse_rgba(spec.get("color")) tint_rgba = _parse_rgba_optional(spec.get("tint")) + burn_response = spec.get("burn_response") or _default_burn_response() + absorption_dict = spec.get("absorption") + if not isinstance(absorption_dict, dict): + absorption_dict = None if spec.get("kind") == "rotary": return _compile_rotary_stock_spec( @@ -230,6 +365,8 @@ def _compile_stock_spec( fallback_rgba=fallback_rgba, tint_rgba=tint_rgba, stock_w2v=stock_w2v, + burn_response=burn_response, + absorption_dict=absorption_dict, ) outers = [ @@ -251,10 +388,12 @@ def _compile_stock_spec( return None thickness = _positive_float(spec.get("thickness"), DEFAULT_THICKNESS_MM) + burn = _parse_burn_spec(spec) pos_parts: list[np.ndarray] = [] norm_parts: list[np.ndarray] = [] uv_parts: list[np.ndarray] = [] + power_uv_parts: list[np.ndarray] = [] idx_parts: list[np.ndarray] = [] base = 0 for outer in outers: @@ -274,15 +413,44 @@ def _compile_stock_spec( pos = np.asarray(mesh.positions, dtype=np.float32) if pos.shape[0] == 0: continue + norm = np.asarray(mesh.normals, dtype=np.float32) + uvs = np.asarray(mesh.uvs, dtype=np.float32) + ring_idx = np.asarray(mesh.indices, dtype=np.uint32) + puv: np.ndarray | None = None + if burn is not None: + puv = np.asarray( + compute_power_uvs( + pos.reshape(-1, 3), + burn.power_grid[0], + burn.power_grid[1], + burn.power_grid[2], + ), + dtype=np.float32, + ) pos_parts.append(pos) - norm_parts.append(np.asarray(mesh.normals, dtype=np.float32)) - uv_parts.append(np.asarray(mesh.uvs, dtype=np.float32)) - idx_parts.append(np.asarray(mesh.indices, dtype=np.uint32) + base) + norm_parts.append(norm) + uv_parts.append(uvs) + if puv is not None: + power_uv_parts.append(puv) + idx_parts.append(ring_idx + base) base += pos.shape[0] if not idx_parts: return None + if burn is not None and power_uv_parts: + arr = burn.surface_map.to_numpy() + logger.info( + "Stock %r: burn-in surface map applied (%d/%d px burned, aabb=%s)", + spec.get("name"), + int((arr > 0).sum()), + arr.size, + burn.aabb, + ) + + absorption = _resolve_absorption( + absorption_dict, burn.wavelength_nm if burn is not None else 0.0 + ) return StockLayer( positions=np.concatenate(pos_parts), normals=np.concatenate(norm_parts), @@ -295,6 +463,17 @@ def _compile_stock_spec( metallic=metallic, fallback_rgba=fallback_rgba, tint_rgba=tint_rgba, + power_texture=burn.surface_map if burn is not None else None, + power_size_px=burn.size_px if burn is not None else None, + power_aabb=burn.aabb if burn is not None else None, + power_uvs=( + np.concatenate(power_uv_parts) + if power_uv_parts + else np.empty((0, 2), dtype=np.float32) + ), + wavelength_nm=burn.wavelength_nm if burn is not None else 0.0, + absorption=absorption, + burn_response=burn_response, ) @@ -307,6 +486,8 @@ def _compile_rotary_stock_spec( fallback_rgba: tuple[float, float, float, float], tint_rgba: tuple[float, float, float, float] | None, stock_w2v: np.ndarray, + burn_response: dict, + absorption_dict: dict | None, ) -> StockLayer | None: """Compile a rotary stock spec into a cylinder shell layer.""" diameter = _positive_float(spec.get("diameter"), 0.0) @@ -317,6 +498,13 @@ def _compile_rotary_stock_spec( positions, normals, uvs, indices = _build_cylinder_shell( diameter, length, texture_size_mm ) + burn = _parse_burn_spec(spec) + power_uvs: np.ndarray = np.empty((0, 2), dtype=np.float32) + if burn is not None: + power_uvs = _rotary_power_uvs(diameter, length, burn.power_grid) + absorption = _resolve_absorption( + absorption_dict, burn.wavelength_nm if burn is not None else 0.0 + ) return StockLayer( positions=positions, normals=normals, @@ -330,6 +518,13 @@ def _compile_rotary_stock_spec( fallback_rgba=fallback_rgba, tint_rgba=tint_rgba, is_rotary=True, + power_texture=(burn.surface_map if burn is not None else None), + power_size_px=(burn.size_px if burn is not None else None), + power_aabb=(burn.aabb if burn is not None else None), + power_uvs=power_uvs, + wavelength_nm=(burn.wavelength_nm if burn is not None else 0.0), + absorption=absorption, + burn_response=burn_response, ) diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index ba9727e5d..ac3d7d14d 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -316,11 +316,6 @@ def register_actions(self): self.win.on_show_models_state_change, GLib.Variant.new_boolean(cv.show_models), ) - self._add_stateful_action( - "show_ops_underlay", - self.win.on_show_ops_underlay_state_change, - GLib.Variant.new_boolean(cv.show_ops_underlay), - ) self._add_stateful_action( "show_workpiece_image", self.win.on_show_workpiece_image_state_change, diff --git a/rayforge/ui_gtk/canvas2d/elements/stock.py b/rayforge/ui_gtk/canvas2d/elements/stock.py index ab267fb39..9779f75c2 100644 --- a/rayforge/ui_gtk/canvas2d/elements/stock.py +++ b/rayforge/ui_gtk/canvas2d/elements/stock.py @@ -3,6 +3,7 @@ import cairo from raygeo.geo import Matrix +from ....context import get_context from ....core.stock import StockItem from ....image.geo_renderer import geometry_to_cairo from ...canvas import CanvasElement @@ -105,41 +106,29 @@ def draw(self, ctx: cairo.Context): # Texture the stock with the material's texture, tiled so that # one image covers `texture_size_mm` world millimeters. material = self.data.material + if material is None: + material = get_context().material_mgr.get_default_material() texture_source = None texture_size_mm = None - if material: - texture_path = material.get_texture_path() - if texture_path is not None: - # Tinting is a material-level feature: the stock's color only - # applies when the material is tintable. - tint = ( - self.data.get_effective_rgba() - if material.appearance.tintable - else None + texture_path = material.get_texture_path() + if texture_path is not None: + tint = self.data.get_effective_rgba() + if tint is not None: + texture_source = tinted_texture_cairo_surface( + texture_path, tint ) - if tint is not None: - # Tintable material with a color: use the cached - # tinted copy of the texture surface (computed once - # per texture+tint, reused across redraws). - texture_source = tinted_texture_cairo_surface( - texture_path, tint - ) - else: - texture_source = load_texture_cairo_surface(texture_path) - texture_size_mm = material.appearance.texture_size_mm + else: + texture_source = load_texture_cairo_surface(texture_path) + texture_size_mm = material.appearance.texture_size_mm if texture_source is not None and texture_size_mm is not None: surface, _buffer = texture_source self._set_tiled_texture_source( ctx, surface, texture_size_mm, geo_width, geo_height ) - elif material: - # Use material color with 0.5 alpha + else: r, g, b, a = material.get_display_rgba(0.5) ctx.set_source_rgba(r, g, b, a) - else: - # Use default color when no material is assigned - ctx.set_source_rgba(0.5, 0.5, 0.5, 0.3) ctx.fill_preserve() diff --git a/rayforge/ui_gtk/doceditor/add_material_dialog.py b/rayforge/ui_gtk/doceditor/add_material_dialog.py index 4020687e1..80779f5d9 100644 --- a/rayforge/ui_gtk/doceditor/add_material_dialog.py +++ b/rayforge/ui_gtk/doceditor/add_material_dialog.py @@ -97,12 +97,6 @@ def __init__(self, material: Material | None = None, **kwargs): self.color_row.add_suffix(self.color_button) self.color_row.add_suffix(self._clear_color_button) - self.tintable_row = Adw.SwitchRow( - title=_("Tintable"), - subtitle=_("Allow tinting the texture with a color"), - ) - self.tintable_row.connect("notify::active", self._on_tintable_changed) - self.texture_scale_row = LengthSpinRow( _("Texture Scale"), _("Size one texture tile covers on the material"), @@ -130,7 +124,6 @@ def __init__(self, material: Material | None = None, **kwargs): group.add(self.texture_row) group.add(self.texture_scale_row) group.add(self.color_row) - group.add(self.tintable_row) group.add(self.roughness_row) group.add(self.metallic_row) @@ -201,19 +194,9 @@ def _update_color_row(self): self.color_row.set_subtitle(self._color) self._clear_color_button.set_visible(True) - def _on_tintable_changed(self, row: Adw.SwitchRow, pspec): - """Keep row sensitivity in sync when the tintable switch flips.""" - self._update_sensitivity() - def _update_sensitivity(self): - """ - The color row stays active while a texture is selected only when - the material is tintable; the texture scale is active whenever a - texture is chosen. - """ + """The texture scale is active whenever a texture is chosen.""" has_texture = self._texture_path is not None - color_active = (not has_texture) or self.tintable_row.get_active() - self.color_row.set_sensitive(color_active) self.texture_scale_row.set_sensitive(has_texture) def _on_choose_texture(self, button: Gtk.Button): @@ -278,9 +261,7 @@ def _populate_fields(self): self.name_entry.set_text(self.material.name) self.category_entry.set_text(self.material.category) - # Set the color (may be None when the material is tintable and - # the tint was unset). - self.tintable_row.set_active(bool(self.material.appearance.tintable)) + # Set the color (may be None when the tint is unset). self._color = self.material.appearance.color if self._color is not None and self._color.startswith("#"): # Try using GTK's built-in color parsing @@ -309,7 +290,6 @@ def get_material_data(self) -> dict[str, Any]: "name": self.get_name().strip(), "category": self.get_category().strip() or _("Custom"), "color": self.get_color_hex(), - "tintable": self.tintable_row.get_active(), "roughness": self.roughness_scale.get_value(), "metallic": self.metallic_scale.get_value(), "texture_size_mm": ( diff --git a/rayforge/ui_gtk/doceditor/material_list.py b/rayforge/ui_gtk/doceditor/material_list.py index 50d6d6c80..29bad69ff 100644 --- a/rayforge/ui_gtk/doceditor/material_list.py +++ b/rayforge/ui_gtk/doceditor/material_list.py @@ -235,7 +235,6 @@ def _update_material( material.name = data["name"] material.category = data["category"] material.appearance.color = data["color"] - material.appearance.tintable = bool(data.get("tintable", False)) material.appearance.roughness = float(data.get("roughness", 0.8)) material.appearance.metallic = float(data.get("metallic", 0.0)) material.appearance.texture_size_mm = float( @@ -303,7 +302,6 @@ def _add_material(self, data: dict, library: MaterialLibrary): category=data["category"], appearance=MaterialAppearance( color=data["color"], - tintable=bool(data.get("tintable", False)), roughness=float(data.get("roughness", 0.8)), metallic=float(data.get("metallic", 0.0)), texture_size_mm=float(data.get("texture_size_mm", 300.0)), diff --git a/rayforge/ui_gtk/doceditor/stock_properties_dialog.py b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py index d86d9f424..02bd6e737 100644 --- a/rayforge/ui_gtk/doceditor/stock_properties_dialog.py +++ b/rayforge/ui_gtk/doceditor/stock_properties_dialog.py @@ -91,8 +91,7 @@ def __init__( ) properties_group.add(self.material_row) - # Per-instance color row (only usable for tintable materials; for - # other materials the color comes from the material definition). + # Per-instance color row. self.color_row = Adw.ActionRow(title=_("Color")) color_dialog = Gtk.ColorDialog() color_dialog.set_with_alpha(False) @@ -216,19 +215,11 @@ def _on_clear_color(self, button: Gtk.Button): def _update_color_display(self): """Refresh the per-instance color row to match the stock item.""" - material = self.stock_item.material - tintable = material is not None and material.appearance.tintable - self.color_row.set_sensitive(tintable) - # Only show the clear button while a per-instance override exists. - self._clear_color_button.set_visible( - tintable and self.stock_item.color is not None - ) + self._clear_color_button.set_visible(self.stock_item.color is not None) effective = self.stock_item.get_effective_color() - if not tintable: - self.color_row.set_subtitle(_("Set by the material definition")) - elif self.stock_item.color == StockItem.COLOR_NONE: + if self.stock_item.color == StockItem.COLOR_NONE: self.color_row.set_subtitle(_("No color")) elif self.stock_item.color: self.color_row.set_subtitle( diff --git a/rayforge/ui_gtk/doceditor/workflow_row.py b/rayforge/ui_gtk/doceditor/workflow_row.py index d644e04a0..b781c701b 100644 --- a/rayforge/ui_gtk/doceditor/workflow_row.py +++ b/rayforge/ui_gtk/doceditor/workflow_row.py @@ -104,17 +104,13 @@ def _setup_drop_target(self): self.add_controller(self._drop_target) def _connect_machine(self): - machine = get_context().machine - if machine: - machine.changed.connect(self._on_machine_changed) + get_context().config.changed.connect(self._on_machine_changed) def _disconnect_machine(self): - machine = get_context().machine - if machine: - try: - machine.changed.disconnect(self._on_machine_changed) - except (TypeError, ValueError): - pass + try: + get_context().config.changed.disconnect(self._on_machine_changed) + except (TypeError, ValueError): + pass def _on_machine_changed(self, sender, **kwargs): self._rebuild() diff --git a/rayforge/ui_gtk/machine/head_preferences_page.py b/rayforge/ui_gtk/machine/head_preferences_page.py index 7b6e731ee..bf83b84b9 100644 --- a/rayforge/ui_gtk/machine/head_preferences_page.py +++ b/rayforge/ui_gtk/machine/head_preferences_page.py @@ -550,8 +550,8 @@ def _build_ui(self): self.cut_color_button = Gtk.ColorButton() self.cut_color_button.set_size_request(32, 32) self.cut_color_row = Adw.ActionRow( - title=_("Cut Color"), - subtitle=_("Color for cutting operations"), + title=_("Color"), + subtitle=_("Color used for operations of this laser"), activatable_widget=self.cut_color_button, ) self.cut_color_row.add_suffix(self.cut_color_button) @@ -560,19 +560,6 @@ def _build_ui(self): ) self.properties_group.add(self.cut_color_row) - self.raster_color_button = Gtk.ColorButton() - self.raster_color_button.set_size_request(32, 32) - self.raster_color_row = Adw.ActionRow( - title=_("Raster Color"), - subtitle=_("Color for engraving/raster operations"), - activatable_widget=self.raster_color_button, - ) - self.raster_color_row.add_suffix(self.raster_color_button) - self._handler_ids["raster_color"] = self.raster_color_button.connect( - "color-set", self._on_raster_color_changed - ) - self.properties_group.add(self.raster_color_row) - self.focal_distance_row = LengthSpinRow( _("Focal Distance"), _("Distance from the laser head to the work surface (Z offset)"), @@ -715,9 +702,6 @@ def set_head(self, head: LaserHead | None): self.name_row.handler_block(self._handler_ids["name"]) self.laser_type_row.handler_block(self._handler_ids["laser_type"]) self.cut_color_button.handler_block(self._handler_ids["cut_color"]) - self.raster_color_button.handler_block( - self._handler_ids["raster_color"] - ) self.name_row.set_text(head.name) self.tool_number_row.set_value(head.tool_number) @@ -727,7 +711,6 @@ def set_head(self, head: LaserHead | None): self.spot_size_x_row.set_value_in_base_units(spot_x) self.spot_size_y_row.set_value_in_base_units(spot_y) self._set_color_button(self.cut_color_button, head.cut_color) - self._set_color_button(self.raster_color_button, head.raster_color) self.focal_distance_row.set_value_in_base_units(head.focal_distance) self.frame_power_row.set_value(head.frame_power_percent * 100) self.frame_speed_row.set_value_in_base_units(head.frame_speed) @@ -753,9 +736,6 @@ def set_head(self, head: LaserHead | None): self.name_row.handler_unblock(self._handler_ids["name"]) self.laser_type_row.handler_unblock(self._handler_ids["laser_type"]) self.cut_color_button.handler_unblock(self._handler_ids["cut_color"]) - self.raster_color_button.handler_unblock( - self._handler_ids["raster_color"] - ) def _on_name_changed(self, entry_row): """Update the name of the selected laser.""" @@ -806,15 +786,10 @@ def _get_hex_color(self, button: Gtk.ColorButton) -> str: return f"#{r:02x}{g:02x}{b:02x}" def _on_cut_color_changed(self, button: Gtk.ColorButton): - """Update the cut color of the selected laser.""" + """Update the color of the selected laser.""" if self._head: self._head.set_cut_color(self._get_hex_color(button)) - def _on_raster_color_changed(self, button: Gtk.ColorButton): - """Update the raster color of the selected laser.""" - if self._head: - self._head.set_raster_color(self._get_hex_color(button)) - def _on_frame_speed_changed(self, spinrow): """Update the frame speed of the selected laser.""" if not self._head: diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index 018df68a0..0e44765bc 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -143,6 +143,8 @@ def __init__(self, **kwargs): self._saved_bottom_panel_visible = False self._old_doc = None # Track previous document for signal reconnection self.canvas3d: Canvas3D | None = None + self._canvas3d_vis_overlay: VisibilityOverlay | None = None + self._surface_vis_overlay: VisibilityOverlay | None = None self._canvas3d_time_overlay: TimeEstimateOverlay | None = None self._is_syncing_3d = False @@ -983,17 +985,6 @@ def on_show_grid_state_change( config.canvas_view.show_grid = is_visible config.changed.send(config) - def on_show_ops_underlay_state_change( - self, action: Gio.SimpleAction, value: GLib.Variant - ): - is_visible = value.get_boolean() - if self.canvas3d is not None: - self.canvas3d.set_show_ops_underlay(is_visible) - action.set_state(value) - config = get_context().config - config.canvas_view.show_ops_underlay = is_visible - config.changed.send(config) - def on_show_stock_state_change( self, action: Gio.SimpleAction, value: GLib.Variant ): @@ -1111,14 +1102,6 @@ def _sync_view_toggle_actions(self): GLib.Variant.new_boolean(cv.show_grid), ) - am.get_action("show_ops_underlay").set_state( - GLib.Variant.new_boolean(not cv.show_ops_underlay) - ) - self.on_show_ops_underlay_state_change( - am.get_action("show_ops_underlay"), - GLib.Variant.new_boolean(cv.show_ops_underlay), - ) - am.get_action("show_stock").set_state( GLib.Variant.new_boolean(not cv.show_stock) ) @@ -1284,6 +1267,18 @@ def on_doc_changed(self, sender, **kwargs): # Update button sensitivity and other state self._update_actions_and_ui() + # The stock and ops-underlay toggles are mutually exclusive: + # show the stock toggle only when the document has stock — + # flat stock items or rotary layers with a diameter. + has_stock = bool(doc.stock_items) or any( + layer.rotary_enabled and layer.rotary_diameter > 0 + for layer in doc.layers + ) + if self._surface_vis_overlay is not None: + self._surface_vis_overlay.set_stock_present(has_stock) + if self._canvas3d_vis_overlay is not None: + self._canvas3d_vis_overlay.set_stock_present(has_stock) + def _sync_element_selectability(self): """ Updates the 'selectable' property of StockElements on the canvas @@ -1521,7 +1516,6 @@ def _create_canvas3d(self, context, viewport: ViewportConfig): show_workpiece=False, show_models=True, show_grid=True, - show_ops_underlay=True, show_stock=True, show_workpiece_image=True, show_nogo_zones=bool(machine and machine.nogo_zones), @@ -1602,12 +1596,14 @@ def on_config_changed(self, sender, **kwargs): has_cameras = bool( config.machine and any(c.enabled for c in config.machine.cameras) ) - self._surface_vis_overlay.set_camera_visible(has_cameras) + if self._surface_vis_overlay is not None: + self._surface_vis_overlay.set_camera_visible(has_cameras) # Show/hide no-go zone toggle based on whether the machine has any has_nogo_zones = bool(config.machine and config.machine.nogo_zones) - self._surface_vis_overlay.set_nogo_visible(has_nogo_zones) - if self.canvas3d is not None: + if self._surface_vis_overlay is not None: + self._surface_vis_overlay.set_nogo_visible(has_nogo_zones) + if self._canvas3d_vis_overlay is not None: self._canvas3d_vis_overlay.set_nogo_visible(has_nogo_zones) self.surface.update_from_doc() diff --git a/rayforge/ui_gtk/settings/material_manager_page.py b/rayforge/ui_gtk/settings/material_manager_page.py index 9ca9e641d..0a89526f2 100644 --- a/rayforge/ui_gtk/settings/material_manager_page.py +++ b/rayforge/ui_gtk/settings/material_manager_page.py @@ -3,10 +3,14 @@ import logging from gettext import gettext as _ +from gi.repository import Adw + from ...context import get_context from ...core.material_library import MaterialLibrary from ..doceditor.material_library_list import LibraryListWidget from ..doceditor.material_list import MaterialListWidget +from ..doceditor.material_selector import MaterialRow +from ..shared.pref_rows.length_spin_row import LengthSpinRow from ..shared.preferences_page import TrackedPreferencesPage logger = logging.getLogger(__name__) @@ -29,6 +33,39 @@ def __init__(self): icon_name="material-symbolic", ) + defaults_group = Adw.PreferencesGroup( + title=_("New Stock Defaults"), + description=_( + "Material and thickness applied when a new stock or " + "rotary layer is created." + ), + ) + self.default_material_row = MaterialRow( + _("Default Stock Material"), + _("Material used when a new stock or rotary layer is created"), + on_select=self._on_default_material_selected, + ) + config = get_context().config + current_uid = config.default_stock_material_uid + if current_uid: + current = get_context().material_mgr.get_material_or_none( + current_uid + ) + self.default_material_row.set_material(current) + defaults_group.add(self.default_material_row) + + self.default_thickness_row = LengthSpinRow( + _("Default Stock Thickness"), + _("Thickness applied to new stock assets"), + upper=999, + value_in_base=config.default_stock_thickness_mm, + ) + self.default_thickness_row.value_changed.connect( + self._on_default_thickness_changed + ) + defaults_group.add(self.default_thickness_row) + self.add(defaults_group) + self.library_list_editor = LibraryListWidget( title=_("Material Libraries"), description=_( @@ -60,6 +97,23 @@ def __init__(self): self.library_list_editor.populate_and_select() + def _on_default_material_selected(self, material_uid: str | None): + """Persist the user's default stock material choice.""" + if material_uid is None: + return + get_context().config.set_default_stock_material(material_uid) + material = get_context().material_mgr.get_material_or_none( + material_uid + ) + if material is not None: + self.default_material_row.set_material(material) + + def _on_default_thickness_changed(self, row: LengthSpinRow): + """Persist the user's default stock thickness choice.""" + get_context().config.set_default_stock_thickness( + row.get_value_in_base_units() + ) + def _on_library_selected( self, sender, library: MaterialLibrary | None = None ): diff --git a/rayforge/ui_gtk/shared/texture_loader.py b/rayforge/ui_gtk/shared/texture_loader.py index 0e7d1544a..fbf0ea807 100644 --- a/rayforge/ui_gtk/shared/texture_loader.py +++ b/rayforge/ui_gtk/shared/texture_loader.py @@ -256,8 +256,8 @@ def tint_cairo_surface( The source surface (usually from the texture cache) is never modified; a new surface plus its backing buffer is returned so the - tiled 2D-canvas rendering can show tintable materials tinted. The - returned bytearray must be kept alive while the surface is used. + tiled 2D-canvas rendering can show tinted materials. The returned + bytearray must be kept alive while the surface is used. Colorization shifts every pixel to the tint hue while preserving the texture's per-pixel shading (``luma * tint``), in premultiplied space. diff --git a/rayforge/ui_gtk/shared/visibility_overlay.py b/rayforge/ui_gtk/shared/visibility_overlay.py index b52de1cc4..bcbff3497 100644 --- a/rayforge/ui_gtk/shared/visibility_overlay.py +++ b/rayforge/ui_gtk/shared/visibility_overlay.py @@ -32,7 +32,6 @@ def __init__( show_models=False, show_grid=False, show_tabs=False, - show_ops_underlay=False, show_stock=False, show_workpiece_image=False, show_nogo_zones=True, @@ -90,6 +89,7 @@ def __init__( ) self.append(self.workpiece_image_button) + self.stock_button: Gtk.ToggleButton | None = None if show_stock: self.stock_button = Gtk.ToggleButton() self.stock_button.set_child(get_icon("stock-symbolic")) @@ -154,20 +154,6 @@ def __init__( self.grid_button.set_action_name("win.show_grid") self.append(self.grid_button) - self.underlay_button = None - if show_ops_underlay: - self.underlay_button = Gtk.ToggleButton() - self.underlay_button.set_child(get_icon("ops-underlay-symbolic")) - self.underlay_button.set_active(True) - self.underlay_button.set_tooltip_text( - self._format_tooltip( - _("Toggle ops underlay visibility"), - "win.show_ops_underlay", - ) - ) - self.underlay_button.set_action_name("win.show_ops_underlay") - self.append(self.underlay_button) - self.travel_button = Gtk.ToggleButton() self.travel_button.set_child(get_icon("travel-path-symbolic")) self.travel_button.set_active(False) @@ -198,6 +184,16 @@ def set_camera_visible(self, visible: bool): def set_nogo_visible(self, visible: bool): self.nogo_button.set_visible(visible) + def set_stock_present(self, present: bool) -> None: + """Shows the stock toggle only when a stock exists in the doc. + + Engraving is visualized solely by the burned-in stock; there is + no floating ops underlay to replace, so the stock toggle is + simply hidden when no stock exists. + """ + if self.stock_button is not None: + self.stock_button.set_visible(present) + def _format_tooltip(self, text, action_name): if action_name in self._shortcuts: shortcut_str = self._shortcuts[action_name] diff --git a/rayforge/ui_gtk/sim3d/canvas3d.py b/rayforge/ui_gtk/sim3d/canvas3d.py index bdf9454bd..2ef78ad71 100644 --- a/rayforge/ui_gtk/sim3d/canvas3d.py +++ b/rayforge/ui_gtk/sim3d/canvas3d.py @@ -269,20 +269,17 @@ def _get_viewport(self) -> ViewportConfig: def _build_pick_scene(self) -> PickScene | None: """Assembles pickable geometry for cursor ray-casting. - Collects the visible scene items (stock, engrave texture, - workpiece base images, machine models) and hands them, with a - per-frame :class:`PickContext`, to the scene-item picker. Only - items whose category is currently visible contribute, so - picking always matches what is drawn. + Collects the visible scene items (stock, workpiece base images, + machine models) and hands them, with a per-frame + :class:`PickContext`, to the scene-item picker. Only items + whose category is currently visible contribute, so picking + always matches what is drawn. """ vis = self._presenter.visibility items: list[SceneItem] = [] artifact = self._presenter.compiled_artifact - if artifact is not None: - if vis.show_stock: - items.extend(artifact.stock_layers) - if vis.show_ops_underlay: - items.extend(artifact.texture_layers) + if artifact is not None and vis.show_stock: + items.extend(artifact.stock_layers) if vis.show_workpiece_image: items.extend(self._presenter.workpiece_images) if vis.show_models: @@ -372,6 +369,21 @@ def on_render(self, area, ctx) -> bool: self._scene.prepare(self._ctx) self._scene.render(self._ctx, None) + # Force the framebuffer alpha to 1.0 so the GLArea is + # always opaque. Blended render passes (ops lines, ring + # buffer, workpiece image) write sub-unity alpha to the + # framebuffer; without this fixup GTK composites those + # pixels with the widget background, making the scanline + # overlay colour depend on the theme instead of the stock + # behind it. Writing only the alpha channel (RGB masked + # off) with a clear leaves the rendered colours untouched. + saved_clear = GL.glGetFloatv(GL.GL_COLOR_CLEAR_VALUE) + GL.glColorMask(GL.GL_FALSE, GL.GL_FALSE, GL.GL_FALSE, GL.GL_TRUE) + GL.glClearColor(0.0, 0.0, 0.0, 1.0) + GL.glClear(GL.GL_COLOR_BUFFER_BIT) + GL.glColorMask(GL.GL_TRUE, GL.GL_TRUE, GL.GL_TRUE, GL.GL_TRUE) + GL.glClearColor(*saved_clear[:4]) + except Exception: logger.exception("OpenGL Render Error") return False @@ -394,9 +406,6 @@ def set_show_models(self, visible: bool): def set_show_grid(self, visible: bool): self._presenter.set_show_grid(visible) - def set_show_ops_underlay(self, visible: bool): - self._presenter.set_show_ops_underlay(visible) - def set_show_stock(self, visible: bool): self._presenter.set_show_stock(visible) diff --git a/rayforge/ui_gtk/sim3d/gl_utils.py b/rayforge/ui_gtk/sim3d/gl_utils.py index e120a2db6..edbdb483b 100644 --- a/rayforge/ui_gtk/sim3d/gl_utils.py +++ b/rayforge/ui_gtk/sim3d/gl_utils.py @@ -80,15 +80,14 @@ class ShaderSet: Bag of shaders passed to ``render``. Each renderer picks the program it needs (``main`` / ``text`` / - ``texture`` / ``stock``) instead of receiving a bespoke positional - ``shader`` argument. Fields default to ``None`` so partial - populations are valid during migration. + ``stock``) instead of receiving a bespoke positional ``shader`` + argument. Fields default to ``None`` so partial populations are + valid during migration. """ main: Shader | None = None main_lines: Shader | None = None text: Shader | None = None - texture: Shader | None = None background: Shader | None = None stock: Shader | None = None image: Shader | None = None diff --git a/rayforge/ui_gtk/sim3d/render_context/visibility.py b/rayforge/ui_gtk/sim3d/render_context/visibility.py index cbfb6eae3..97eaf3fcd 100644 --- a/rayforge/ui_gtk/sim3d/render_context/visibility.py +++ b/rayforge/ui_gtk/sim3d/render_context/visibility.py @@ -22,6 +22,5 @@ class SceneVisibility: show_grid: bool = True show_nogo_zones: bool = True show_models: bool = True - show_ops_underlay: bool = True show_stock: bool = True show_workpiece_image: bool = True diff --git a/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py b/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py index c778e3bb6..59faf84cf 100644 --- a/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py +++ b/rayforge/ui_gtk/sim3d/renderer/ring_buffer_renderer.py @@ -269,9 +269,12 @@ def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): # the trail on a cylinder) while the laser head model still # occludes it. Depth writes stay off so later geometry is # unaffected. + GL.glEnable(GL.GL_BLEND) + GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) GL.glDepthFunc(GL.GL_LEQUAL) GL.glDepthMask(GL.GL_FALSE) shader.set_float("uFragDepthBias", LINE_DEPTH_WINDOW_BIAS) set_line_width(line_width) GL.glBindVertexArray(self.vao) GL.glDrawArrays(GL.GL_LINES, 0, draw_count) + GL.glBindVertexArray(0) diff --git a/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py b/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py index 2e4c9c63c..d29f09f51 100644 --- a/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py +++ b/rayforge/ui_gtk/sim3d/renderer/scene_renderer.py @@ -35,7 +35,6 @@ SimpleShader, StockShader, TextShader, - TextureShader, ) from ..viewport import ViewportConfig from .axis_renderer_3d import AxisRenderer3D @@ -51,11 +50,6 @@ StockRenderer, prepare_stock_layer, ) -from .texture_renderer import ( - PreparedTextureLayer, - TextureArtifactRenderer, - prepare_texture_layer, -) from .workpiece_image_renderer import WorkpieceImageRenderer from .zone_renderer import ZoneRenderer @@ -164,41 +158,6 @@ def upload(self) -> None: self.renderer.update_from_overlay_layer_payload(positions, attrib) -@dataclass -class TextureUploadItem: - """Uploads the artifact's texture layers into the texture renderer.""" - - renderer: Optional["TextureArtifactRenderer"] - artifact: CompiledSceneArtifact - _prepared_layers: list[PreparedTextureLayer] | None = field( - default=None, init=False, repr=False - ) - - def prepare(self) -> None: - """Decompresses/mip-maps texture layers off the main thread.""" - if self.renderer is None: - return - prepared = [ - prepare_texture_layer( - tl, - self.artifact.laser_uid_order, - self.renderer.max_texture_size, - ) - for tl in self.artifact.texture_layers - ] - self._prepared_layers = prepared - - def upload(self) -> None: - if self.renderer is None: - return - prepared = self._prepared_layers - if prepared is None: - self.prepare() - prepared = self._prepared_layers - assert prepared is not None - self.renderer.upload_prepared(prepared) - - @dataclass class StockUploadItem: """Uploads the artifact's stock layers into the stock renderer.""" @@ -235,7 +194,6 @@ def __init__(self): super().__init__() self.main_shader: Shader | None = None self.text_shader: Shader | None = None - self.texture_shader: Shader | None = None self.background_shader: Shader | None = None self.stock_shader: Shader | None = None self.image_shader: Shader | None = None @@ -245,7 +203,6 @@ def __init__(self): self.background_renderer: BackgroundRenderer | None = ( BackgroundRenderer() ) - self.texture_renderer: TextureArtifactRenderer | None = None self.stock_renderer: StockRenderer | None = StockRenderer() self.workpiece_image_renderer: WorkpieceImageRenderer | None = ( WorkpieceImageRenderer() @@ -266,9 +223,7 @@ def __init__(self): self._viewport: ViewportConfig | None = None self._font_family: str | None = None - # Ordered list of (renderer, shader_keys) in draw order. The - # deferred ring passes come after the texture renderer so rings - # draw on top of the textures during playback. + # Ordered list of (renderer, shader_keys) in draw order. self.render_registry: list[tuple[BaseRenderer, tuple[str, ...]]] = [] def _rebuild_registry(self) -> None: @@ -289,16 +244,12 @@ def _rebuild_registry(self) -> None: for renderer in self.cylinder_renderers.values(): registry.append((renderer, ("main",))) - # Draw the ops and textures. The solid stock draws before the - # engrave quads (with a polygon offset) so the quads sit - # cleanly on the top face. Workpiece base images draw on the - # stock face but below the engrave texture and toolpaths. + # Draw the stock and ops. Workpiece base images draw on the + # stock face but below the toolpaths. if self.stock_renderer is not None: registry.append((self.stock_renderer, ("stock",))) if self.workpiece_image_renderer is not None: registry.append((self.workpiece_image_renderer, ("image",))) - if self.texture_renderer is not None: - registry.append((self.texture_renderer, ("texture",))) for renderer in self.ops_renderers: registry.append((renderer, ("main",))) for renderer in self.ring_renderers: @@ -344,7 +295,6 @@ def init_gl(self): self.main_shader = SimpleShader() self.line_shader = LineDepthBiasShader() self.text_shader = TextShader() - self.texture_shader = TextureShader() self.background_shader = BackgroundShader() self.stock_shader = StockShader() self.image_shader = ImageShader() @@ -352,7 +302,6 @@ def init_gl(self): main=self.main_shader, main_lines=self.line_shader, text=self.text_shader, - texture=self.texture_shader, background=self.background_shader, stock=self.stock_shader, image=self.image_shader, @@ -367,8 +316,6 @@ def init_gl(self): ) self.apply_extent_frame(viewport) self.axis_renderer.init_gl() - self.texture_renderer = TextureArtifactRenderer() - self.texture_renderer.init_gl() if self.stock_renderer: self.stock_renderer.init_gl() if self.workpiece_image_renderer: @@ -391,7 +338,6 @@ def init_gl(self): for renderer in ( self.axis_renderer, self.background_renderer, - self.texture_renderer, self.stock_renderer, self.workpiece_image_renderer, self.zone_renderer, @@ -405,7 +351,7 @@ def init_gl(self): def _cleanup_self(self): """Cleans up dynamically-rebuilt collections and shaders. - Static children (axis, background, texture, zone, laser) are + Static children (axis, background, zone, laser) are cleaned automatically by the base ``cleanup()`` walking ``_owned_renderers``. Only the rebuilt collections and the owned shaders need manual cleanup here. @@ -422,8 +368,6 @@ def _cleanup_self(self): self.main_shader.cleanup() if self.text_shader: self.text_shader.cleanup() - if self.texture_shader: - self.texture_shader.cleanup() if self.background_shader: self.background_shader.cleanup() if self.stock_shader: @@ -473,50 +417,15 @@ def update_axis_from_viewport(self, viewport: ViewportConfig) -> bool: return True def update_cylinders_from_doc(self, doc, viewport, machine): - """Reads chuck diameters from the assembly and rebuilds cylinders. + """Remove any wireframe cylinder renderers. - Layers whose diameter is covered by a rotary stock material - skip the wireframe — the solid stock shell replaces it. + Rotary layers now always render a solid stock shell (using the + default material when none is explicitly assigned), so the + wireframe placeholder is no longer needed. """ - desired_diameters: dict[float, bool] = {} - if machine and self.had_rotary_layers: - for layer in doc.layers: - if layer.rotary_enabled and layer.rotary_diameter > 0: - if layer.stock_material is not None: - continue - desired_diameters[layer.rotary_diameter] = True - - max_length = viewport.width_mm - if machine: - default_rm = machine.get_default_rotary_module() - if default_rm: - max_length = min(max_length, default_rm.max_workpiece_length) - for diameter, renderer in list(self.cylinder_renderers.items()): - if diameter not in desired_diameters: - renderer.cleanup() - del self.cylinder_renderers[diameter] - - grid_size = ( - self.axis_renderer.grid_size_mm if self.axis_renderer else 10.0 - ) - length_segments = max(1, round(max_length / grid_size)) - - for diameter in desired_diameters: - if diameter not in self.cylinder_renderers: - renderer = CylinderRenderer( - diameter=diameter, - length=max_length, - rings=24, - length_segments=length_segments, - ) - renderer.set_color((0.4, 0.6, 0.8, 0.25)) - renderer.init_gl() - self.cylinder_renderers[diameter] = renderer - logger.debug( - f"Initialized cylinder renderer: " - f"diameter={diameter}mm, length={max_length}mm" - ) + renderer.cleanup() + del self.cylinder_renderers[diameter] self._rebuild_registry() def update_zones_from_machine(self, machine): @@ -604,14 +513,6 @@ def update_color_luts(self, provider: ColorLutProvider | None): for renderer in self.ring_renderers: renderer.update_color_lut_from(provider) - if self.texture_renderer: - if provider.has_lasers: - logger.debug( - f"[COLOR_LUT] Using multi-laser 2D LUT " - f"({provider.num_lasers} lasers)" - ) - self.texture_renderer.update_color_lut_from(provider) - def update_from_artifact( self, artifact: CompiledSceneArtifact, show_travel_moves: bool ): @@ -642,8 +543,6 @@ def update_from_artifact( ring.ring_offsets = np.array([], dtype=np.int32) self.ring_renderers.append(ring) - if self.texture_renderer: - self.texture_renderer.update_from_artifact(artifact) if self.stock_renderer: self.stock_renderer.update_from_artifact(artifact) self._rebuild_registry() @@ -680,7 +579,6 @@ def prepare_chunked_upload( upload_items.append(OverlayLayerUploadItem(ring, ol)) break - upload_items.append(TextureUploadItem(self.texture_renderer, artifact)) upload_items.append(StockUploadItem(self.stock_renderer, artifact)) self._rebuild_registry() return upload_items @@ -690,13 +588,11 @@ def upload_chunk(self, item: UploadItem) -> None: item.upload() def clear_layers(self) -> None: - """Clears all per-layer ops/ring/texture GPU buffers.""" + """Clears all per-layer ops/ring GPU buffers.""" for renderer in self.ops_renderers: renderer.clear() for renderer in self.ring_renderers: renderer.clear() - if self.texture_renderer: - self.texture_renderer.clear() if self.stock_renderer: self.stock_renderer.clear() if self.workpiece_image_renderer: @@ -758,7 +654,6 @@ def render( for shader in ( self.main_shader, self.text_shader, - self.texture_shader, self.background_shader, self.stock_shader, self.image_shader, diff --git a/rayforge/ui_gtk/sim3d/renderer/stock_renderer.py b/rayforge/ui_gtk/sim3d/renderer/stock_renderer.py index 1c9cecb43..9d486e979 100644 --- a/rayforge/ui_gtk/sim3d/renderer/stock_renderer.py +++ b/rayforge/ui_gtk/sim3d/renderer/stock_renderer.py @@ -9,7 +9,7 @@ """ import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) -# Size of the split-sum BRDF integration LUT (32x32 per the plan). +# Size of the split-sum BRDF integration LUT. BRDF_LUT_SIZE = 32 # Decoded material textures to keep in the CPU-side cache. @@ -60,6 +60,17 @@ class PreparedStockLayer: texture_key: tuple[str, int, int] | None = None texture_pixels: np.ndarray | None = None is_rotary: bool = False + # Burn-in surface map (F32 fluence, world-y-up rows) decoded for + # GL upload, its size, and the per-vertex coordinates into it. + power_pixels: np.ndarray | None = None + power_size: tuple[int, int] | None = None + power_uvs: np.ndarray = field( + default_factory=lambda: np.empty((0, 2), dtype=np.float32) + ) + # Resolved absorption coefficient (0-1) for the shader's burn block. + absorption: float = 1.0 + # Resolved char-curve parameters for the shader's burn block. + burn_response: dict = field(default_factory=dict) @lru_cache(maxsize=MAX_CACHED_DECODES) @@ -103,7 +114,7 @@ def _texture_key( def prepare_stock_layer(layer: StockLayer) -> PreparedStockLayer: - """Expands indices and decodes the texture without touching GL. + """Expands indices and decodes the textures without touching GL. Runs in a worker thread; the result is uploaded with GL calls on the main thread. @@ -112,6 +123,11 @@ def prepare_stock_layer(layer: StockLayer) -> PreparedStockLayer: positions = layer.positions.reshape(-1, 3)[indices] normals = layer.normals.reshape(-1, 3)[indices] uvs = layer.uvs.reshape(-1, 2)[indices] + power_uvs = ( + layer.power_uvs.reshape(-1, 2)[indices] + if layer.power_uvs.size + else np.empty((0, 2), dtype=np.float32) + ) key = _texture_key(layer.texture_path) pixels: np.ndarray | None = None @@ -122,6 +138,14 @@ def prepare_stock_layer(layer: StockLayer) -> PreparedStockLayer: # Tinting happens on the GPU (per-instance shader uniform); the # decoded texture is shared and cached unchanged. + power_pixels: np.ndarray | None = None + power_size: tuple[int, int] | None = None + if layer.power_texture is not None and layer.power_size_px is not None: + decoded_burn = layer.power_texture.to_numpy() + if decoded_burn is not None and decoded_burn.size: + power_pixels = np.ascontiguousarray(decoded_burn) + power_size = layer.power_size_px + return PreparedStockLayer( positions=np.ascontiguousarray(positions, dtype=np.float32), normals=np.ascontiguousarray(normals, dtype=np.float32), @@ -134,6 +158,11 @@ def prepare_stock_layer(layer: StockLayer) -> PreparedStockLayer: texture_key=key if pixels is not None else None, texture_pixels=pixels, is_rotary=layer.is_rotary, + power_pixels=power_pixels, + power_size=power_size, + power_uvs=np.ascontiguousarray(power_uvs, dtype=np.float32), + absorption=getattr(layer, "absorption", 1.0), + burn_response=getattr(layer, "burn_response", {}), ) @@ -244,6 +273,79 @@ def _create_gl_texture(self, pixels: np.ndarray) -> int: GL.glBindTexture(GL.GL_TEXTURE_2D, 0) return texture_id + @staticmethod + def _power_mipmap_levels(data: np.ndarray) -> list[np.ndarray]: + """Builds a max-reduction mip pyramid of a fluence map. + + Down-sampling with the maximum (rather than the average) keeps + the scanline structure intact at every zoom level: when the + burn texture is minified (the stock grid is far denser than the + screen), each mip texel covers a block of fluence samples and + reports the hottest one, so engrave rows stay visible instead + of aliasing into flickering single pixels. + """ + levels = [data] + level = data + while max(level.shape) > 1: + h, w = level.shape + out = level + if h > 1: + out = out[: 2 * (h // 2)].reshape(h // 2, 2, w).max(axis=1) + if w > 1: + out = ( + out[:, : 2 * (w // 2)] + .reshape(out.shape[0], w // 2, 2) + .max(axis=2) + ) + levels.append(out) + level = out + return levels + + def _create_power_texture(self, pixels: np.ndarray) -> int: + """Uploads an F32 fluence burn map with max-reduced mipmaps. + + The buffer is world-y-up (row 0 = min y) and the mesh's + ``power_uvs`` v grows with y, so rows upload as-is. The + internal format is ``GL_R32F`` to carry the fluence's wide + dynamic range (J/cm²) without the 0–255 quantization of the + old R8 PWM path. Mipmaps are max-reduced (see + :meth:`_power_mipmap_levels`) so minifying the dense stock grid + keeps the scanline burn crisp instead of aliased. + """ + texture_id = GL.glGenTextures(1) + GL.glBindTexture(GL.GL_TEXTURE_2D, texture_id) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, + GL.GL_TEXTURE_MIN_FILTER, + GL.GL_LINEAR_MIPMAP_LINEAR, + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE + ) + GL.glTexParameteri( + GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE + ) + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 1) + for level, mip in enumerate(self._power_mipmap_levels(pixels)): + mh, mw = mip.shape + GL.glTexImage2D( + GL.GL_TEXTURE_2D, + level, + GL.GL_R32F, + mw, + mh, + 0, + GL.GL_RED, + GL.GL_FLOAT, + np.ascontiguousarray(mip, dtype=np.float32), + ) + GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 4) + GL.glBindTexture(GL.GL_TEXTURE_2D, 0) + return texture_id + def _create_instance_buffers( self, prepared: PreparedStockLayer ) -> tuple[int, list[int]]: @@ -254,12 +356,18 @@ def _create_instance_buffers( vbo_uv = GL.glGenBuffers(1) vbos = [vbo_pos, vbo_norm, vbo_uv] - GL.glBindVertexArray(vao) - for attr, vbo, data in ( + attributes = [ (0, vbo_pos, prepared.positions), (1, vbo_norm, prepared.normals), (2, vbo_uv, prepared.uvs), - ): + ] + if prepared.power_uvs.size: + vbo_power = GL.glGenBuffers(1) + vbos.append(vbo_power) + attributes.append((3, vbo_power, prepared.power_uvs)) + + GL.glBindVertexArray(vao) + for attr, vbo, data in attributes: GL.glBindBuffer(GL.GL_ARRAY_BUFFER, vbo) GL.glBufferData( GL.GL_ARRAY_BUFFER, data.nbytes, data, GL.GL_STATIC_DRAW @@ -274,10 +382,13 @@ def _create_instance_buffers( return vao, vbos def _delete_instance(self, instance: dict) -> None: - """Deletes one instance's VAO/VBOs.""" + """Deletes one instance's VAO/VBOs and burn texture.""" try: GL.glDeleteVertexArrays(1, [instance["vao"]]) GL.glDeleteBuffers(len(instance["vbos"]), instance["vbos"]) + power_texture_id = instance.get("power_texture_id") + if power_texture_id: + GL.glDeleteTextures([power_texture_id]) except GL.GLError: logger.exception("Error deleting stock instance buffers") @@ -318,6 +429,24 @@ def upload_prepared(self, prepared_layers: list[PreparedStockLayer]): prepared.texture_pixels ) self._texture_cache[prepared.texture_key] = texture_id + power_texture_id = 0 + if ( + prepared.power_pixels is not None + and prepared.power_size is not None + and prepared.power_uvs.size + ): + # Burn maps change with every fold, so they are not + # cached; they are a few hundred KB at the stock-grid + # budget. + power_texture_id = self._create_power_texture( + prepared.power_pixels + ) + logger.info( + "Stock renderer: uploaded burn texture %sx%s px (id %s)", + prepared.power_size[0], + prepared.power_size[1], + power_texture_id, + ) vao, vbos = self._create_instance_buffers(prepared) self.instances.append( { @@ -331,6 +460,9 @@ def upload_prepared(self, prepared_layers: list[PreparedStockLayer]): "tint_rgba": prepared.tint_rgba, "texture_id": texture_id, "is_rotary": prepared.is_rotary, + "power_texture_id": power_texture_id, + "absorption": prepared.absorption, + "burn_response": prepared.burn_response, } ) @@ -365,6 +497,8 @@ def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): GL.glActiveTexture(GL.GL_TEXTURE1) shader.set_int("uBrdfLut", 1) GL.glBindTexture(GL.GL_TEXTURE_2D, self._brdf_lut_texture) + GL.glActiveTexture(GL.GL_TEXTURE2) + shader.set_int("uPowerTexture", 2) GL.glDisable(GL.GL_BLEND) GL.glDepthMask(GL.GL_TRUE) @@ -388,7 +522,8 @@ def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs): GL.glDisable(GL.GL_POLYGON_OFFSET_FILL) def _draw_instance(self, shader, instance: dict) -> None: - if instance.get("is_rotary"): + is_rotary = bool(instance.get("is_rotary")) + if is_rotary: if self._cyl_mvp is None or self._cyl_model is None: return mvp = self._cyl_mvp @@ -398,6 +533,7 @@ def _draw_instance(self, shader, instance: dict) -> None: model = instance["transform"] shader.set_mat4("uMVP", mvp) shader.set_mat4("uModel", model) + shader.set_float("uRotary", 1.0 if is_rotary else 0.0) shader.set_vec4("uAlbedo", instance["fallback_rgba"]) shader.set_float("uRoughness", instance["roughness"]) shader.set_float("uMetallic", instance["metallic"]) @@ -415,6 +551,31 @@ def _draw_instance(self, shader, instance: dict) -> None: GL.glActiveTexture(GL.GL_TEXTURE0) GL.glBindTexture(GL.GL_TEXTURE_2D, texture_id if texture_id else 0) + power_texture_id = instance.get("power_texture_id", 0) + shader.set_float("uUsePowerTexture", 1.0 if power_texture_id else 0.0) + if power_texture_id: + GL.glActiveTexture(GL.GL_TEXTURE2) + GL.glBindTexture(GL.GL_TEXTURE_2D, power_texture_id) + br = instance.get("burn_response") or {} + shader.set_float( + "uAbsorption", float(instance.get("absorption", 1.0)) + ) + shader.set_float( + "uCharThreshold", float(br.get("char_threshold", 35.0)) + ) + shader.set_float( + "uCharSaturation", float(br.get("char_saturation", 125.0)) + ) + low = br.get("char_color_low", (0.04, 0.03, 0.02)) + high = br.get("char_color_high", (0.01, 0.01, 0.01)) + shader.set_vec3( + "uCharColorLow", (float(low[0]), float(low[1]), float(low[2])) + ) + shader.set_vec3( + "uCharColorHigh", + (float(high[0]), float(high[1]), float(high[2])), + ) + GL.glBindVertexArray(instance["vao"]) GL.glDrawArrays(GL.GL_TRIANGLES, 0, instance["vertex_count"]) GL.glBindVertexArray(0) diff --git a/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py b/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py deleted file mode 100644 index 42a686f7c..000000000 --- a/rayforge/ui_gtk/sim3d/renderer/texture_renderer.py +++ /dev/null @@ -1,596 +0,0 @@ -""" -A renderer for visualizing texture-based artifacts using GPU texture rendering. -""" - -import logging -import time -from dataclasses import dataclass -from typing import Any - -import numpy as np -from OpenGL import GL -from OpenGL.error import GLError - -from ....pipeline.artifact.base import TextureData -from ....simulator.scene3d import CompiledSceneArtifact, TextureLayer -from ...shared.color_lut_provider import ColorLutProvider -from ..gl_utils import ShaderSet -from ..render_context import RenderContext -from .base import BaseRenderer - -logger = logging.getLogger(__name__) - - -@dataclass -class PreparedTextureLayer: - """Texture data ready for GL upload, built off the main thread.""" - - mips: list[np.ndarray] - model_matrix: np.ndarray - rotary_enabled: bool = False - rotary_diameter: float = 0.0 - cylinder_vertices: np.ndarray | None = None - laser_index: int = 0 - - -def _downsample_texture( - data: np.ndarray, new_height: int, new_width: int -) -> np.ndarray: - """Downsamples texture data using nearest-neighbor sampling.""" - h, w = data.shape - y_step = h / new_height - x_step = w / new_width - y_coords = (np.arange(new_height) * y_step).astype(int) - x_coords = (np.arange(new_width) * x_step).astype(int) - return data[y_coords][:, x_coords].astype(np.uint8) - - -def _build_mipmap_levels( - power_data: np.ndarray, max_texture_size: int -) -> list[np.ndarray]: - """Downsamples if needed and builds the mip pyramid.""" - height, width = power_data.shape - if width > max_texture_size or height > max_texture_size: - scale = min( - max_texture_size / width, - max_texture_size / height, - ) - new_width = int(width * scale) - new_height = int(height * scale) - logger.warning( - f"Texture size {width}x{height} exceeds max " - f"{max_texture_size}, downsampling to " - f"{new_width}x{new_height}" - ) - power_data = _downsample_texture(power_data, new_height, new_width) - return TextureArtifactRenderer._build_mipmaps(power_data) - - -def prepare_texture_layer( - tl: "TextureLayer", - laser_uid_order: list[str] | None, - max_texture_size: int, -) -> PreparedTextureLayer: - """Decompresses and mip-maps a texture layer without touching GL. - - Runs in a worker thread; the result is uploaded with GL calls on - the main thread. - """ - laser_index = 0 - if tl.laser_uid and laser_uid_order and tl.laser_uid in laser_uid_order: - laser_index = laser_uid_order.index(tl.laser_uid) - - power_data = tl.power_texture.to_numpy() - mips = _build_mipmap_levels(power_data, max_texture_size) - return PreparedTextureLayer( - mips=mips, - model_matrix=tl.model_matrix, - rotary_enabled=tl.rotary_enabled, - rotary_diameter=tl.rotary_diameter, - cylinder_vertices=tl.cylinder_vertices, - laser_index=laser_index, - ) - - -class TextureArtifactRenderer(BaseRenderer): - """ - Renders texture-based artifacts as textured quads for high-performance - visualization. - - This renderer uses a single quad with a texture containing power values, - allowing for instant rendering of complex raster operations that would - otherwise require millions of individual lines. - """ - - visibility_key = "show_ops_underlay" - - def __init__(self): - """Initializes the TextureArtifactRenderer.""" - super().__init__() - self.vao: int = 0 - self.vbo: int = 0 - self.texture: int = 0 - self.color_lut_texture: int = 0 - self.is_initialized: bool = False - self.max_texture_size: int = 0 - self.instances: list[dict[str, Any]] = [] - self.cylinder_vao: int = 0 - self.cylinder_vbo: int = 0 - self._num_laser_luts: int = 1 - self._flat_mvp: np.ndarray | None = None - self._cyl_mvp: np.ndarray | None = None - - def prepare(self, ctx: RenderContext) -> None: - """Caches the per-frame MVP matrices for the texture quads.""" - self._flat_mvp = ctx.camera.mvp_ui - self._cyl_mvp = ctx.kinematics.cylinder_mesh_mvp() - - def init_gl(self): - """ - Initializes OpenGL resources for rendering textured quads. - - Creates the VAO/VBO for a quad and OpenGL Textures for the texture - data and color lookup table (LUT). - """ - if self.is_initialized: - return - - max_size = GL.GLint() - GL.glGetIntegerv(GL.GL_MAX_TEXTURE_SIZE, max_size) - self.max_texture_size = max_size.value - logger.debug(f"OpenGL max texture size: {self.max_texture_size}") - - self.vbo = self._create_vbo() - self.vao = self._create_vao() - self.texture = self._create_texture() - self.color_lut_texture = self._create_texture() - - # Define quad vertices (position, texture coordinates) - # fmt: off - quad_vertices = np.array( - [ - # Position (x, y, z) Texture Coords (s, t) - 0.0, 0.0, 0.0, 0.0, 1.0, # Bottom-left - 1.0, 0.0, 0.0, 1.0, 1.0, # Bottom-right - 1.0, 1.0, 0.0, 1.0, 0.0, # Top-right - 0.0, 1.0, 0.0, 0.0, 0.0, # Top-left - ], - dtype=np.float32, - ) - # fmt: on - - # Upload vertex data - GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.vbo) - GL.glBufferData( - GL.GL_ARRAY_BUFFER, - quad_vertices.nbytes, - quad_vertices, - GL.GL_STATIC_DRAW, - ) - - # Set up vertex attributes - GL.glBindVertexArray(self.vao) - - # Position attribute (location 0) - GL.glVertexAttribPointer( - 0, 3, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(0) - ) - GL.glEnableVertexAttribArray(0) - - # Texture coordinate attribute (location 1) - GL.glVertexAttribPointer( - 1, 2, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(3 * 4) - ) - GL.glEnableVertexAttribArray(1) - - GL.glBindVertexArray(0) - - # Set up 2D texture for power data - GL.glBindTexture(GL.GL_TEXTURE_2D, self.texture) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE - ) - GL.glBindTexture(GL.GL_TEXTURE_2D, 0) - - # Set up 2D texture (with height=1) for color LUT for compatibility - # with the sampler2D in the shader. - GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE - ) - GL.glBindTexture(GL.GL_TEXTURE_2D, 0) - - self.cylinder_vbo = self._create_vbo() - self.cylinder_vao = self._create_vao() - - self.is_initialized = True - logger.debug("TextureArtifactRenderer initialized") - - def _cleanup_self(self): - """Cleans up OpenGL resources specific to this renderer.""" - if not self.is_initialized: - return - - try: - self.clear() - self.is_initialized = False - except GLError as e: - logger.warning(f"TextureArtifactRenderer cleanup warning: {e}") - - @staticmethod - def _max_reduce(data: np.ndarray) -> np.ndarray: - """Halves a power map taking the 2x2 block maximum. - - Level sizes must follow the GL floor-halving rule - (``max(1, size // 2)``) exactly: any level whose dimensions - deviate (e.g. from padding odd edges before halving) makes the - whole mip chain inconsistent, and the driver then treats the - texture as incomplete - every lookup including ``textureSize`` - returns zero and the texture renders fully transparent. Odd - trailing rows/columns are therefore dropped, not merged. - """ - h, w = data.shape - out = data - if h > 1: - out = out[: 2 * (h // 2)].reshape(h // 2, 2, w).max(axis=1) - if w > 1: - out = ( - out[:, : 2 * (w // 2)] - .reshape(out.shape[0], w // 2, 2) - .max(axis=2) - ) - return out - - @classmethod - def _build_mipmaps(cls, data: np.ndarray) -> list[np.ndarray]: - """Builds a max-reduction mip pyramid of a power map. - - Down-sampling with the maximum (rather than the average) keeps - the scanline structure intact at every zoom level, so the shader - can pick a mip level that matches its texel footprint instead of - aliasing between rows when the texture is minified. - """ - mips = [data] - level = data - # Reduce until BOTH dimensions reach 1: stopping when only the - # smaller one hits 1 (e.g. at shape (1, 2)) leaves the mip - # chain without its final 1x1 level, which makes the texture - # incomplete and every lookup - texelFetch included - return - # black. - while max(level.shape) > 1: - level = cls._max_reduce(level) - mips.append(level) - return mips - - def clear(self): - """Clears all instances and their associated textures.""" - if not self.is_initialized: - return - # This needs to be called on the GL thread. - textures_to_delete = [ - instance["texture_id"] for instance in self.instances - ] - if textures_to_delete: - GL.glDeleteTextures(textures_to_delete) - self.instances.clear() - - def add_instance( - self, - texture_data: TextureData, - final_model_matrix: np.ndarray, - rotary_enabled: bool = False, - rotary_diameter: float = 25.0, - cylinder_vertices: np.ndarray | None = None, - laser_index: int = 0, - ): - """Adds a texture artifact to be rendered in the next frame. - - Prepares the mip pyramid and uploads synchronously. The chunked - upload path prepares in a worker thread and calls - ``upload_prepared`` from the main thread instead. - """ - if not self.is_initialized: - return - - mips = _build_mipmap_levels( - texture_data.power_texture_data, self.max_texture_size - ) - prepared = PreparedTextureLayer( - mips=mips, - model_matrix=final_model_matrix, - rotary_enabled=rotary_enabled, - rotary_diameter=rotary_diameter, - cylinder_vertices=cylinder_vertices, - laser_index=laser_index, - ) - self._upload_prepared_instance(prepared) - - def _upload_prepared_instance( - self, prepared: PreparedTextureLayer - ) -> None: - """Uploads one prepared texture layer's mips into a GL texture.""" - texture_id = GL.glGenTextures(1) - GL.glBindTexture(GL.GL_TEXTURE_2D, texture_id) - # NEAREST_MIPMAP_NEAREST keeps the texture mipmap complete so - # texelFetch() can read the explicit LOD the shader computes - # from the texel footprint (fixes minification moire banding). - GL.glTexParameteri( - GL.GL_TEXTURE_2D, - GL.GL_TEXTURE_MIN_FILTER, - GL.GL_NEAREST_MIPMAP_NEAREST, - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE - ) - GL.glTexParameteri( - GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE - ) - - GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 1) - for level, mip in enumerate(prepared.mips): - mh, mw = mip.shape - GL.glTexImage2D( - GL.GL_TEXTURE_2D, - level, - GL.GL_R8, - mw, - mh, - 0, - GL.GL_RED, - GL.GL_UNSIGNED_BYTE, - mip, - ) - GL.glPixelStorei(GL.GL_UNPACK_ALIGNMENT, 4) - GL.glBindTexture(GL.GL_TEXTURE_2D, 0) - - instance_data = { - "texture_id": texture_id, - "model_matrix": prepared.model_matrix, - "rotary_enabled": prepared.rotary_enabled, - "rotary_diameter": prepared.rotary_diameter, - "laser_index": prepared.laser_index, - "max_mip": len(prepared.mips) - 1, - } - - if prepared.rotary_enabled and prepared.cylinder_vertices is not None: - instance_data["cylinder_vertices"] = prepared.cylinder_vertices - - self.instances.append(instance_data) - - def upload_prepared( - self, prepared_layers: list[PreparedTextureLayer] - ) -> None: - """Uploads prepared texture layers, clearing existing instances.""" - self.clear() - for prepared in prepared_layers: - self._upload_prepared_instance(prepared) - - def add_instance_from_texture_layer( - self, - tl: TextureLayer, - laser_uid_order: list[str] | None = None, - ): - """Adds a texture instance from a compiled texture layer.""" - laser_index = 0 - if ( - tl.laser_uid - and laser_uid_order - and tl.laser_uid in laser_uid_order - ): - laser_index = laser_uid_order.index(tl.laser_uid) - power_data = tl.power_texture.to_numpy() - tex_data = TextureData( - power_texture_data=power_data, - dimensions_mm=(0.0, 0.0), - position_mm=(0.0, 0.0), - ) - self.add_instance( - tex_data, - tl.model_matrix, - rotary_enabled=tl.rotary_enabled, - rotary_diameter=tl.rotary_diameter, - cylinder_vertices=tl.cylinder_vertices, - laser_index=laser_index, - ) - - def update_from_artifact(self, artifact: CompiledSceneArtifact): - """Clears existing instances and uploads the artifact's layers.""" - self.clear() - for tl in artifact.texture_layers: - self.add_instance_from_texture_layer(tl, artifact.laser_uid_order) - - def update_color_lut(self, lut_data: np.ndarray, num_lasers: int = 1): - """ - Updates the color lookup table texture, now using GL_TEXTURE_2D. - """ - if not self.is_initialized: - return - - self._num_laser_luts = num_lasers - lut_data = np.ascontiguousarray(lut_data, dtype=np.float32) - - if lut_data.ndim == 3: - width, height = lut_data.shape[1], lut_data.shape[0] - else: - width, height = lut_data.shape[0], 1 - - GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) - GL.glTexImage2D( - GL.GL_TEXTURE_2D, - 0, - GL.GL_RGBA32F, - width, - height, - 0, - GL.GL_RGBA, - GL.GL_FLOAT, - lut_data, - ) - GL.glBindTexture(GL.GL_TEXTURE_2D, 0) - - def update_color_lut_from(self, provider: ColorLutProvider): - """Updates the colour LUT from a shared ColorLutProvider.""" - self.update_color_lut(provider.engrave_lut_2d(), provider.num_lasers) - - def render(self, ctx: RenderContext, shaders: ShaderSet, **kwargs) -> None: - """ - Renders all texture instances: flat quads first, then the - cylinder-mapped (rotary) ones. - - Args: - ctx: The current render context; carries the reached count. - shaders: The shader set; the ``texture`` program is used. - """ - if not self.is_initialized or not self.instances: - return - - shader = shaders.texture - if shader is None: - return - - pending_alpha = 0.3 - self._draw_flat(shader, pending_alpha) - self._draw_cylinder(shader, pending_alpha) - - def _draw_flat( - self, - shader, - pending_alpha: float = 0.3, - ): - """Draws all flat (non-rotary) texture instances.""" - if self._flat_mvp is None: - return - - GL.glEnable(GL.GL_BLEND) - GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) - # Fill depth across the whole raster quad (including the - # zero-power gaps) so occluders behind it cannot show through. - GL.glDepthMask(GL.GL_TRUE) - GL.glDepthFunc(GL.GL_LEQUAL) - shader.use() - - GL.glActiveTexture(GL.GL_TEXTURE0) - shader.set_int("uTexture", 0) - GL.glActiveTexture(GL.GL_TEXTURE1) - shader.set_int("uColorLUT", 1) - shader.set_int("uNumLaserLUTs", self._num_laser_luts) - GL.glBindVertexArray(self.vao) - - for i, instance in enumerate(self.instances): - if instance["rotary_enabled"]: - continue - - shader.set_float("uAlpha", pending_alpha) - shader.set_float("uMaxMip", float(instance.get("max_mip", 0))) - - shader.set_int("uLaserIndex", instance.get("laser_index", 0)) - - final_mvp = self._flat_mvp @ instance["model_matrix"] - shader.set_mat4("uMVP", final_mvp) - - GL.glActiveTexture(GL.GL_TEXTURE1) - GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) - GL.glActiveTexture(GL.GL_TEXTURE0) - GL.glBindTexture(GL.GL_TEXTURE_2D, instance["texture_id"]) - - GL.glDrawArrays(GL.GL_TRIANGLE_FAN, 0, 4) - - def _draw_cylinder( - self, - shader, - pending_alpha: float = 0.3, - ): - """Draws all texture instances mapped onto a cylinder.""" - if self._cyl_mvp is None: - return - - t_cyl_start = time.perf_counter() - - GL.glEnable(GL.GL_BLEND) - GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) - GL.glDepthMask(GL.GL_TRUE) - GL.glDepthFunc(GL.GL_LEQUAL) - shader.use() - - GL.glActiveTexture(GL.GL_TEXTURE0) - shader.set_int("uTexture", 0) - GL.glActiveTexture(GL.GL_TEXTURE1) - shader.set_int("uColorLUT", 1) - shader.set_int("uNumLaserLUTs", self._num_laser_luts) - - num_rotary = 0 - - for i, instance in enumerate(self.instances): - if not instance["rotary_enabled"]: - continue - num_rotary += 1 - - shader.set_float("uAlpha", pending_alpha) - shader.set_float("uMaxMip", float(instance.get("max_mip", 0))) - - shader.set_int("uLaserIndex", instance.get("laser_index", 0)) - - vertices = instance.get("cylinder_vertices") - if vertices is None: - continue - - vertex_count = len(vertices) // 5 - - GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.cylinder_vbo) - GL.glBufferData( - GL.GL_ARRAY_BUFFER, - vertices.nbytes, - vertices, - GL.GL_DYNAMIC_DRAW, - ) - - GL.glBindVertexArray(self.cylinder_vao) - GL.glVertexAttribPointer( - 0, 3, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(0) - ) - GL.glEnableVertexAttribArray(0) - GL.glVertexAttribPointer( - 1, 2, GL.GL_FLOAT, GL.GL_FALSE, 5 * 4, GL.GLvoidp(3 * 4) - ) - GL.glEnableVertexAttribArray(1) - GL.glBindVertexArray(0) - - # Draw using the full Scene Matrix, so it correctly - # inherits WCS and _model_matrix. - shader.set_mat4("uMVP", self._cyl_mvp) - - GL.glActiveTexture(GL.GL_TEXTURE1) - GL.glBindTexture(GL.GL_TEXTURE_2D, self.color_lut_texture) - GL.glActiveTexture(GL.GL_TEXTURE0) - GL.glBindTexture(GL.GL_TEXTURE_2D, instance["texture_id"]) - - GL.glBindVertexArray(self.cylinder_vao) - GL.glDrawArrays(GL.GL_TRIANGLES, 0, vertex_count) - - t_cyl_elapsed = (time.perf_counter() - t_cyl_start) * 1000 - if t_cyl_elapsed > 5: - logger.info( - f"[TEX3D] render_cylinder took {t_cyl_elapsed:.1f}ms " - f"(rotary={num_rotary})" - ) diff --git a/rayforge/ui_gtk/sim3d/scene_presenter.py b/rayforge/ui_gtk/sim3d/scene_presenter.py index 2e343c780..3b95b9c31 100644 --- a/rayforge/ui_gtk/sim3d/scene_presenter.py +++ b/rayforge/ui_gtk/sim3d/scene_presenter.py @@ -19,15 +19,16 @@ from raygeo.ops import Ops from ...context import RayforgeContext +from ...core.optical import material_burn_response from ...core.workpiece import WorkPiece from ...machine.kinematic_mapping import ( KinematicMapping, build_layer_assembly, resolve_layer_rotary, ) -from ...machine.models.laser import LaserHead from ...pipeline.artifact.handle import BaseArtifactHandle from ...pipeline.artifact.job import JobArtifact +from ...pipeline.artifact.material_state import MaterialStateArtifact from ...shared.tasker import Task, task_mgr from ...simulator.op_player import OpPlayer, build_snapshots from ...simulator.scene3d import ( @@ -211,6 +212,9 @@ def __init__( self._workpiece_image_task: Task | None = None self._workpiece_image_generation = 0 self._workpiece_images: list[WorkpieceImage] = [] + # Folded material state per stock uid (burn surface map data), + # updated from Pipeline.material_state_ready. + self._material_states: dict[str, dict] = {} def connect(self): """Subscribe to the pipeline and upload events that drive the scene. @@ -228,6 +232,14 @@ def connect(self): pipeline.job_generation_finished.connect( self._on_job_generation_finished ) + pipeline.material_state_ready.connect( + self._on_material_state_ready + ) + # The pipeline may have already produced material states before + # this presenter connected (the 3D canvas realizes after the + # job settles); pick those up so they are not lost. + if self._refresh_material_states(): + self.update_scene_from_doc() def disconnect(self): """Unsubscribe from pipeline and upload events.""" @@ -240,6 +252,9 @@ def disconnect(self): pipeline.job_generation_finished.disconnect( self._on_job_generation_finished ) + pipeline.material_state_ready.disconnect( + self._on_material_state_ready + ) @property def doc(self) -> "Doc": @@ -321,13 +336,6 @@ def set_show_models(self, visible: bool) -> None: self.visibility.show_models = visible self._request_render() - def set_show_ops_underlay(self, visible: bool) -> None: - """Sets the ops-underlay visibility and requests a re-render.""" - if self.visibility.show_ops_underlay == visible: - return - self.visibility.show_ops_underlay = visible - self._request_render() - def set_show_stock(self, visible: bool) -> None: """Sets stock visibility and requests a re-render.""" if self.visibility.show_stock == visible: @@ -414,6 +422,118 @@ def _on_upload_complete(self, sender=None, **_kwargs): if self._compiled_artifact and self._op_player: self._scene.extract_playback_offsets(self._compiled_artifact) + @staticmethod + def _add_material_optical(spec: dict, material) -> None: + """Stamp the material's resolved optical fields onto a stock spec. + + Adds the absorption dict and burn-response char curve parameters + so the stock compiler/renderer can wire them into the shader's + physical burn block. The wavelength is read from the burn entry + at compile time (it travels with the surface map); here we only + resolve the material-side response. + """ + appearance = getattr(material, "appearance", None) + absorption = None + if appearance is not None: + absorption = appearance.extra.get("absorption") + if isinstance(absorption, dict): + spec["absorption"] = absorption + spec["burn_response"] = material_burn_response(material) + + def _store_material_state( + self, stock_uid: str, stock_name: str, handle + ) -> bool: + """Resolve a material-state artifact handle into the burn dict. + + Returns ``True`` if ``_material_states`` changed (i.e. a scene + update should be triggered), ``False`` otherwise (duplicate or + no change). + """ + try: + artifact = self._context.artifact_store.get(handle) + except RuntimeError: + logger.exception("Failed to resolve material state artifact") + return False + if not isinstance(artifact, MaterialStateArtifact): + return False + state = artifact.material_state + if state.surface_map is not None and state.grid is not None: + grid = state.grid + burn = { + "handle_key": handle.key, + "surface_map": state.surface_map, + "origin_mm": tuple(grid.origin_mm), + "px_per_mm": tuple(grid.px_per_mm), + "size_px": tuple(grid.size_px), + "wavelength_nm": float(state.wavelength_nm), + "max_power_watts": float(state.max_power_watts), + } + previous = self._material_states.get(stock_uid) + if previous is not None and previous["handle_key"] == handle.key: + return False + self._material_states[stock_uid] = burn + logger.info( + "Burn surface map ready for stock %r (grid %sx%s px)", + stock_name, + grid.size_px[0], + grid.size_px[1], + ) + return True + if stock_uid in self._material_states: + self._material_states.pop(stock_uid, None) + return True + return False + + def _refresh_material_states(self) -> bool: + """Pick up any material states the Pipeline already computed. + + The pipeline can finish (and deliver a fold's material state) + before this presenter connects (the 3D canvas realizes after + the job settles). Relying on the live signal alone would miss + that state, so re-read the Pipeline's stored handles and drop + any that are no longer present. Returns whether anything + changed. + """ + pipeline = self._doc_editor.pipeline + if pipeline is None: + return False + handles = getattr(pipeline, "_material_state_handles", {}) or {} + changed = False + seen: set[str] = set() + hosts: list[tuple[str, str]] = [ + (item.uid, item.name) for item in self.doc.stock_items + ] + hosts.extend( + (layer.uid, layer.name) + for layer in self.doc.layers + if layer.rotary_enabled + ) + for uid, name in hosts: + if uid not in handles: + continue + seen.add(uid) + if self._store_material_state(uid, name, handles[uid]): + changed = True + for uid in list(self._material_states): + if uid not in seen: + self._material_states.pop(uid, None) + changed = True + return changed + + def _on_material_state_ready(self, sender, **kwargs): + """Store a material host's folded burn surface map and recompile. + + The fold emits during the pipeline run; the stock specs pick + the burn data up on the next scene compile, which reuses the + current job handle when the pipeline is still running. + """ + item = kwargs.get("item") + handle = kwargs.get("handle") + if item is None or handle is None: + return + if self._store_material_state(item.uid, item.name, handle): + self.update_scene_from_doc() + def _build_op_player_async(self): ops = self._get_ops_for_playback() time_ops = self._get_time_ops_for_playback() @@ -567,8 +687,6 @@ def update_renderers_from_artifact(self): for renderer in self._scene.ring_renderers: renderer.clear() renderer.ring_offsets = np.array([], dtype=np.int32) - if self._scene.texture_renderer: - self._scene.texture_renderer.clear() self._request_render() return @@ -651,45 +769,32 @@ def _build_stock_specs( continue material = item.material - if material is not None: - appearance = material.appearance - texture_path = material.get_texture_path() - else: - appearance = None - texture_path = None - - specs.append( - { - "name": item.name, - "thickness": ( - float(thickness) if thickness is not None else None - ), - "outers": outers, - "holes": holes, - "texture_path": ( - str(texture_path) if texture_path is not None else None - ), - "texture_size_mm": float( - appearance.texture_size_mm - if appearance is not None - else 300.0 - ), - "roughness": float( - appearance.roughness if appearance is not None else 0.8 - ), - "metallic": float( - appearance.metallic if appearance is not None else 0.0 - ), - "color": appearance.color - if appearance is not None - else None, - # Tinting is a material-level feature: the stock's color - # only applies when the material is tintable. - "tint": item.get_effective_color() - if (appearance is not None and appearance.tintable) - else None, - } - ) + if material is None: + material = self._context.material_mgr.get_default_material() + appearance = material.appearance + texture_path = material.get_texture_path() + + spec = { + "name": item.name, + "thickness": ( + float(thickness) if thickness is not None else None + ), + "outers": outers, + "holes": holes, + "texture_path": ( + str(texture_path) if texture_path is not None else None + ), + "texture_size_mm": float(appearance.texture_size_mm), + "roughness": float(appearance.roughness), + "metallic": float(appearance.metallic), + "color": appearance.color, + "tint": item.get_effective_color(), + } + self._add_material_optical(spec, material) + burn = self._material_states.get(item.uid) + if burn is not None: + spec["burn"] = dict(burn) + specs.append(spec) if machine is not None: specs.extend(self._build_rotary_stock_specs(viewport, machine)) @@ -698,11 +803,10 @@ def _build_stock_specs( def _build_rotary_stock_specs( self, viewport: "ViewportConfig", machine ) -> list[dict]: - """Collect rotary layers with a stock material into specs. + """Collect rotary layers into stock specs. - The axial length matches the wireframe cylinder: the work area - width capped by the default rotary module's maximum workpiece - length. + The axial length matches the work area width capped by the + default rotary module's maximum workpiece length. """ specs: list[dict] = [] max_length = viewport.width_mm @@ -717,24 +821,27 @@ def _build_rotary_stock_specs( continue material = layer.stock_material if material is None: - continue + material = self._context.material_mgr.get_default_material() appearance = material.appearance texture_path = material.get_texture_path() - specs.append( - { - "name": _("{layer} stock").format(layer=layer.name), - "kind": "rotary", - "diameter": float(layer.rotary_diameter), - "length": float(max_length), - "texture_path": ( - str(texture_path) if texture_path is not None else None - ), - "texture_size_mm": float(appearance.texture_size_mm), - "roughness": float(appearance.roughness), - "metallic": float(appearance.metallic), - "color": appearance.color, - } - ) + spec = { + "name": _("{layer} stock").format(layer=layer.name), + "kind": "rotary", + "diameter": float(layer.rotary_diameter), + "length": float(max_length), + "texture_path": ( + str(texture_path) if texture_path is not None else None + ), + "texture_size_mm": float(appearance.texture_size_mm), + "roughness": float(appearance.roughness), + "metallic": float(appearance.metallic), + "color": appearance.color, + } + self._add_material_optical(spec, material) + burn = self._material_states.get(layer.uid) + if burn is not None: + spec["burn"] = dict(burn) + specs.append(spec) return specs def update_scene_from_doc(self): @@ -744,12 +851,15 @@ def update_scene_from_doc(self): """ if not self._get_gl_initialized(): return - if not self._scene.texture_renderer: - return t_update_start = time.perf_counter() logger.debug("Canvas3D: Updating scene from document.") + # Folded material states may arrive after the last update (or + # before this presenter connected); re-read the Pipeline's + # stored states so the stock specs always carry the burn. + self._refresh_material_states() + # Theme/color updates only need to happen once per theme change if self._theme_resolver.theme_is_dirty: self._theme_resolver.update_theme_and_colors() @@ -785,10 +895,15 @@ def update_scene_from_doc(self): if machine: if has_z_axis: + # Has-Z machines render content at its authored Z + # (plus WCS Z), faithful to the coordinates. world_to_visual = self._compute_world_to_visual( viewport, machine ) else: + # No-Z machines author everything at z=0 (the stock + # top in the toolpath convention), so lift content + # onto the stock top in visual space. wcs = viewport.wcs_offset_mm content_z = wcs[2] + stock_top_z world_to_visual = self._compute_world_to_visual( @@ -810,13 +925,6 @@ def update_scene_from_doc(self): viewport, machine, z_offset=0.0 ) - laser_dot_widths_mm: dict[str, float] = {} - if machine: - for head in machine.heads: - if isinstance(head, LaserHead): - spot_x, _spot_y = LaserHead.get_spot_size(head) - laser_dot_widths_mm[head.uid] = spot_x - layer_configs: dict[str, LayerRenderConfig] = {} for layer in self.doc.layers: axis_position = 0.0 @@ -855,7 +963,6 @@ def update_scene_from_doc(self): stock_top_z=stock_top_z, has_z_axis=has_z_axis, layer_configs=layer_configs, - laser_dot_widths_mm=laser_dot_widths_mm, stock_specs=stock_specs, ) diff --git a/rayforge/ui_gtk/sim3d/shader/__init__.py b/rayforge/ui_gtk/sim3d/shader/__init__.py index f3d6d5222..b7b731444 100644 --- a/rayforge/ui_gtk/sim3d/shader/__init__.py +++ b/rayforge/ui_gtk/sim3d/shader/__init__.py @@ -8,7 +8,6 @@ from .simple_shader import LineDepthBiasShader, SimpleShader from .stock_shader import StockShader from .text_shader import TextShader -from .texture_shader import TextureShader __all__ = ( "BackgroundShader", @@ -18,5 +17,4 @@ "SimpleShader", "StockShader", "TextShader", - "TextureShader", ) diff --git a/rayforge/ui_gtk/sim3d/shader/stock_shader.py b/rayforge/ui_gtk/sim3d/shader/stock_shader.py index 8cfa2d77d..916559c34 100644 --- a/rayforge/ui_gtk/sim3d/shader/stock_shader.py +++ b/rayforge/ui_gtk/sim3d/shader/stock_shader.py @@ -25,18 +25,23 @@ layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; layout (location = 2) in vec2 aUV; +layout (location = 3) in vec2 aPowerUV; uniform mat4 uMVP; uniform mat4 uModel; out vec3 vNormal; +out vec3 vLocalNormal; out vec3 vWorldPos; out vec2 vUV; +out vec2 vPowerUV; void main() { vWorldPos = (uModel * vec4(aPos, 1.0)).xyz; // The stock transform is a pure translation (world -> visual), so // the upper-left 3x3 safely maps normals without a transpose of // the inverse. vNormal = normalize(mat3(uModel) * aNormal); + vLocalNormal = aNormal; vUV = aUV; + vPowerUV = aPowerUV; gl_Position = uMVP * vec4(aPos, 1.0); } """ @@ -44,8 +49,10 @@ STOCK_FRAGMENT_SHADER = """ out vec4 FragColor; in vec3 vNormal; +in vec3 vLocalNormal; in vec3 vWorldPos; in vec2 vUV; +in vec2 vPowerUV; uniform vec3 uCameraPos; uniform vec3 uLightDir; uniform vec3 uLightColor; @@ -61,6 +68,16 @@ uniform float uAlpha; uniform sampler2D uTexture; uniform sampler2D uBrdfLut; +uniform sampler2D uPowerTexture; +uniform float uUsePowerTexture; +uniform float uRotary; +// Physical burn model uniforms (set per-stock from the material's +// absorption and burn_response fields; see physical-burn.md step 4). +uniform float uAbsorption; // 0–1 absorption coefficient +uniform float uCharThreshold; // fluence below which no char +uniform float uCharSaturation; // fluence for full char +uniform vec3 uCharColorLow; // warm scorch at low char +uniform vec3 uCharColorHigh; // near-black char at full burn uniform vec3 uAmbientSky; uniform vec3 uAmbientGround; uniform vec3 uTint; @@ -129,6 +146,18 @@ return mix(uAmbientGround, uAmbientSky, 0.5 + 0.5 * n.z); } +// Burn transfer: the folded surface map carries laser fluence +// (J/cm²). The absorbed fluence (fluence × uAbsorption) drives a +// char curve clamped between uCharThreshold (no char below) and +// uCharSaturation (full char above). Near-zero fluence (laser off) +// stays at 0. +float burn_transfer(vec2 uv) { + float fluence = texture(uPowerTexture, uv).r; + float absorbed = fluence * uAbsorption; + float span = max(uCharSaturation - uCharThreshold, 1e-4); + return clamp((absorbed - uCharThreshold) / span, 0.0, 1.0); +} + void main() { vec3 n = normalize(vNormal); vec3 v = normalize(uCameraPos - vWorldPos); @@ -147,6 +176,59 @@ ); float roughness = clamp(uRoughness, 0.045, 1.0); + + // Laser burn-in: the folded surface map chars the material. Power + // maps carry the laser PWM fraction (often 0.2-0.6 for engraving); + // a noise floor plus a sub-unity exponent maps that range to + // mostly-charred, and the char also absorbs light, so the lit + // result is attenuated beyond the albedo mix. Near-zero power + // (image blacks, laser off) does not char. + // + // The burn is confined to the engraved faces, tested on the local + // normal so cylinder kinematics don't affect the test. Flat stock + // is engraved on its top face (local normal +z); rotary stock on + // its lateral surface (normals radial to the local x axis), not + // the end caps. + float burn = 0.0; + vec3 char_albedo = albedo; + bool burn_face = uRotary > 0.5 + ? abs(vLocalNormal.x) < 0.5 + : vLocalNormal.z > 0.5; + if (uUsePowerTexture > 0.5 && burn_face) { + vec2 ts = 1.0 / vec2(textureSize(uPowerTexture, 0)); + + burn = burn_transfer(vPowerUV); + + // Char colour ramps from warm scorch at low char to near-black + // char at full burn. The ramp endpoints are material-driven + // uniforms (uCharColorLow, uCharColorHigh) so per-material + // tuning comes from the YAML, not GLSL constants. Full burn is + // black (carbonized material), never lighter. + vec3 char_color = mix(uCharColorLow, uCharColorHigh, burn); + char_albedo = mix(albedo, char_color, burn); + roughness = clamp(mix(roughness, 0.55, burn), 0.045, 1.0); + + // Soft heat-affected halo: a wider warm fringe around the + // sharp char, strongest at the burn boundary. The halo reads + // raw fluence (before the absorption/threshold shaping) so a + // low-absorption material (e.g. clear acrylic under a blue + // diode) still shows a faint heat fringe even when the char + // itself is suppressed. + float halo = 0.0; + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + halo += texture( + uPowerTexture, vPowerUV + vec2(float(x), float(y)) * ts + ).r; + } + } + float halo_span = max(uCharSaturation - uCharThreshold, 1e-4); + halo = clamp((halo / 9.0 - uCharThreshold) / halo_span, 0.0, 1.0); + halo = pow(halo, 0.8) * (1.0 - burn); + char_albedo = mix(char_albedo, uCharColorLow * 1.3, 0.45 * halo); + } + albedo = char_albedo; + vec3 f0 = mix(vec3(0.04), albedo, uMetallic); vec3 color = vec3(0.0); @@ -192,8 +274,21 @@ color += diffuse_ibl + specular_ibl; - // Tonemap and encode for the display-ready framebuffer. - vec3 mapped = color / (color + vec3(1.0)); + // Charred material absorbs light overall, not just diffuse + // albedo — attenuate the lit result by the burn factor. Kept mild + // so the bump-driven self-shadowing (from the perturbed normal) + // does most of the "carved groove" shading. + color *= 1.0 - 0.25 * burn; + + // Tonemap and encode for the display-ready framebuffer. ACES + // Filmic (Narkowicz 2015) keeps darks dark and rolls off + // highlights, unlike Reinhard which lifts midtones and washed the + // dark stock out toward grey. + vec3 mapped = clamp( + (color * (2.51 * color + 0.03)) + / (color * (2.43 * color + 0.59) + 0.14), + 0.0, 1.0 + ); FragColor = vec4(linear_to_srgb(mapped), uAlbedo.a * uAlpha); } """ @@ -210,7 +305,7 @@ def reset_uniforms(self) -> None: self.use() self.set_vec3("uCameraPos", (0.0, 0.0, 0.0)) self.set_vec3("uLightDir", (0.5, 0.8, 1.0)) - self.set_vec3("uLightColor", (1.2, 1.17, 1.12)) + self.set_vec3("uLightColor", (1.0, 0.97, 0.93)) self.set_vec3("uLightDir2", (-0.6, -0.4, 0.3)) self.set_vec3("uLightColor2", (0.25, 0.28, 0.33)) self.set_vec3("uPointLightPos", (0.0, 0.0, 0.0)) @@ -223,9 +318,17 @@ def reset_uniforms(self) -> None: self.set_float("uAlpha", 1.0) self.set_int("uTexture", 0) self.set_int("uBrdfLut", 1) + self.set_int("uPowerTexture", 2) + self.set_float("uUsePowerTexture", 0.0) + self.set_float("uRotary", 0.0) + self.set_float("uAbsorption", 1.0) + self.set_float("uCharThreshold", 35.0) + self.set_float("uCharSaturation", 125.0) + self.set_vec3("uCharColorLow", (0.04, 0.03, 0.02)) + self.set_vec3("uCharColorHigh", (0.01, 0.01, 0.01)) self.set_vec3("uTint", (1.0, 1.0, 1.0)) self.set_float("uUseTint", 0.0) - self.set_vec3("uAmbientSky", (0.34, 0.39, 0.47)) - self.set_vec3("uAmbientGround", (0.20, 0.18, 0.16)) + self.set_vec3("uAmbientSky", (0.18, 0.20, 0.24)) + self.set_vec3("uAmbientGround", (0.10, 0.09, 0.08)) self.set_mat4("uModel", np.eye(4, dtype=np.float32)) self.set_mat4("uMVP", np.eye(4, dtype=np.float32)) diff --git a/rayforge/ui_gtk/sim3d/shader/texture_shader.py b/rayforge/ui_gtk/sim3d/shader/texture_shader.py deleted file mode 100644 index 7d72a4952..000000000 --- a/rayforge/ui_gtk/sim3d/shader/texture_shader.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Two-source texture shader with a per-laser LUT recolour pass. -""" - -from .base import Shader - -TEXTURE_VERTEX_SHADER = """ -layout (location = 0) in vec3 aPos; -layout (location = 1) in vec2 aTexCoord; - -uniform mat4 uMVP; - -out vec2 vTexCoord; - -void main() { - gl_Position = uMVP * vec4(aPos, 1.0); - vTexCoord = aTexCoord; -} -""" - -TEXTURE_FRAGMENT_SHADER = """ -in vec2 vTexCoord; -out vec4 FragColor; - -uniform sampler2D uTexture; -uniform sampler2D uColorLUT; -uniform int uNumLaserLUTs; -uniform int uLaserIndex; -uniform float uAlpha; -uniform float uMaxMip; - -void main() { - // Select the mip level that matches the texel footprint so the - // raster rows do not alias into a moire banding pattern when the - // texture is minified (zoomed out). - ivec2 texSize = textureSize(uTexture, 0); - vec2 p = vTexCoord * vec2(texSize); - vec2 ddx = abs(dFdx(p)); - vec2 ddy = abs(dFdy(p)); - float footprint = max(max(ddx.x, ddx.y), max(ddy.x, ddy.y)); - float lod = clamp(log2(max(footprint, 1.0)), 0.0, uMaxMip); - int li = int(lod + 0.5); - ivec2 ls = textureSize(uTexture, li); - - vec2 tc = vTexCoord * vec2(ls) - 0.5; - ivec2 base = ivec2(floor(tc)); - float power = 0.0; - for (int dy = 0; dy <= 1; dy++) { - for (int dx = 0; dx <= 1; dx++) { - ivec2 idx = clamp( - base + ivec2(dx, dy), - ivec2(0), - ls - ivec2(1) - ); - power = max(power, texelFetch(uTexture, idx, li).r); - } - } - - if (power <= 0.0) { - // Write depth for the gaps between scanlines so geometry drawn - // behind the raster (e.g. rotary module models) cannot bleed - // through the texture's zero-power pixels and band the preview. - // Contribute no colour (alpha 0). - FragColor = vec4(0.0, 0.0, 0.0, 0.0); - return; - } - - float lutY = (float(uLaserIndex) + 0.5) - / float(max(uNumLaserLUTs, 1)); - float lutX = power; - vec4 color = texture(uColorLUT, vec2(lutX, lutY)); - - FragColor = vec4(color.rgb, color.a * uAlpha); -} -""" - - -class TextureShader(Shader): - """Texture shader used by the texture-artifact renderer.""" - - def __init__(self): - super().__init__(TEXTURE_VERTEX_SHADER, TEXTURE_FRAGMENT_SHADER) - - def reset_uniforms(self) -> None: - """Sets every uniform this shader reads to its idle value.""" - self.use() - self.set_int("uTexture", 0) - self.set_int("uColorLUT", 1) - self.set_int("uNumLaserLUTs", 1) - self.set_int("uLaserIndex", 0) - self.set_float("uAlpha", 1.0) - self.set_float("uMaxMip", 0.0) diff --git a/rayforge/ui_gtk/sim3d/theme_resolver.py b/rayforge/ui_gtk/sim3d/theme_resolver.py index 1108df606..9b0993da5 100644 --- a/rayforge/ui_gtk/sim3d/theme_resolver.py +++ b/rayforge/ui_gtk/sim3d/theme_resolver.py @@ -68,7 +68,7 @@ def update_theme_and_colors(self): """ Resolves the ColorSet and updates other theme-dependent elements. """ - if not self._scene.axis_renderer or not self._scene.texture_renderer: + if not self._scene.axis_renderer or not self._scene.stock_renderer: return service = get_context().theme diff --git a/requirements.txt b/requirements.txt index 866af5d80..ea6a57728 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -raygeo==1.45.1 +raygeo==1.46.0 aiohttp==3.14.3 asyncudp==0.11.0 blinker==1.9.0 diff --git a/scripts/snapshot_memory.py b/scripts/snapshot_memory.py index e3bb40908..659571aab 100644 --- a/scripts/snapshot_memory.py +++ b/scripts/snapshot_memory.py @@ -299,11 +299,6 @@ def _measure_scene_presenter(presenter: ScenePresenter) -> OwnerReport: _claim_compressed(r, f"{label}.zero_power_verts", vl.zero_power_verts) _claim_array(r, f"{label}.powered_cmd_offsets", vl.powered_cmd_offsets) _claim_array(r, f"{label}.travel_cmd_offsets", vl.travel_cmd_offsets) - for i, tl in enumerate(art.texture_layers): - label = f"TextureLayer[{i}]" - _claim_compressed(r, f"{label}.power_texture", tl.power_texture) - _claim_array(r, f"{label}.model_matrix", tl.model_matrix) - _claim_array(r, f"{label}.cylinder_vertices", tl.cylinder_vertices) for i, ol in enumerate(art.overlay_layers): label = f"OverlayLayer[{i}]" _claim_compressed(r, f"{label}.positions", ol.positions) diff --git a/tests/assets/bee.ryp b/tests/assets/bee.ryp index f6da518f0..6278c28ff 100644 Binary files a/tests/assets/bee.ryp and b/tests/assets/bee.ryp differ diff --git a/tests/config/config.yaml b/tests/config/config.yaml index cfe3d5487..3b233d1a0 100644 --- a/tests/config/config.yaml +++ b/tests/config/config.yaml @@ -21,13 +21,14 @@ canvas_view: show_grid: true show_models: true show_nogo_zones: true - show_ops_underlay: false show_stock: true show_tabs: true show_travel_lines: false show_workpiece_image: false show_workpieces: true check_for_app_updates: true +default_stock_material_uid: null +default_stock_thickness_mm: 18.0 import_dpi: 96.0 language: null last_opened_project: tests/assets/rotary.ryp diff --git a/tests/config/machines/2f8f7281-9982-4473-a9b0-d72ff48630c2.yaml b/tests/config/machines/2f8f7281-9982-4473-a9b0-d72ff48630c2.yaml index d510b55a5..0cdda4026 100644 --- a/tests/config/machines/2f8f7281-9982-4473-a9b0-d72ff48630c2.yaml +++ b/tests/config/machines/2f8f7281-9982-4473-a9b0-d72ff48630c2.yaml @@ -87,6 +87,7 @@ machine: frame_speed: 0 laser_type: diode max_power: 1000 + max_power_watts: 40.0 max_pulse_width: 500 max_pwm_frequency: 5000 min_pulse_width: 5 @@ -118,6 +119,7 @@ machine: - 1.0 type: LaserHead uid: d9277039-5ccc-456a-b3a6-5054b3c363b0 + wavelength_nm: 455.0 home_on_start: false hookmacros: {} machine_hours: @@ -154,6 +156,7 @@ machine: reverse_x_axis: false reverse_y_axis: false reverse_z_axis: false + reviewed_profile_hash: null rotary_enabled_default: false rotary_modules: - axis: A @@ -185,8 +188,10 @@ machine: - 0.0 - 1.0 uid: bc50197d-9f95-44c2-be99-18f66026e5b1 + schema_version: 1 single_axis_homing_enabled: true soft_limits: null + source_profile_id: null speeds: acceleration: 1000 max_cut_speed: 1000 diff --git a/tests/config/machines/a2c84b52-eaed-40d5-91ba-3b54370e5c3c.yaml b/tests/config/machines/a2c84b52-eaed-40d5-91ba-3b54370e5c3c.yaml index 439f6fa95..c14539969 100644 --- a/tests/config/machines/a2c84b52-eaed-40d5-91ba-3b54370e5c3c.yaml +++ b/tests/config/machines/a2c84b52-eaed-40d5-91ba-3b54370e5c3c.yaml @@ -72,6 +72,7 @@ machine: frame_speed: 0 laser_type: diode max_power: 1000 + max_power_watts: 5.0 max_pulse_width: 500 max_pwm_frequency: 5000 min_pulse_width: 5 @@ -103,6 +104,7 @@ machine: - 1.0 type: LaserHead uid: 46606ac3-7eb1-4979-a379-d44fc2b03911 + wavelength_nm: 455.0 home_on_start: true hookmacros: {} machine_hours: @@ -125,6 +127,7 @@ machine: reviewed_profile_hash: null rotary_enabled_default: false rotary_modules: [] + schema_version: 1 single_axis_homing_enabled: true soft_limits: null source_profile_id: null diff --git a/tests/core/test_library_manager.py b/tests/core/test_library_manager.py index 4ffb8ae7b..2398347c6 100644 --- a/tests/core/test_library_manager.py +++ b/tests/core/test_library_manager.py @@ -2,9 +2,12 @@ import tempfile from pathlib import Path +from unittest.mock import patch +import pytest import yaml +from rayforge.core.config import Config from rayforge.core.library_manager import LibraryManager from rayforge.core.material import Material @@ -527,3 +530,269 @@ def test_manager_user_library_with_id(self): metadata = yaml.safe_load(f) assert metadata["id"] == lib_id assert metadata["name"] == "Test Library" + + +class TestGetDefaultMaterial: + """Tests for LibraryManager.get_default_material.""" + + def test_returns_bundled_material_when_no_libraries(self): + """With no libraries, falls back to the bundled oak.""" + with tempfile.TemporaryDirectory() as temp_dir: + manager = LibraryManager(Path(temp_dir)) + manager.load_all_libraries() + + mat = manager.get_default_material() + + assert mat is not None + assert mat.uid == "oak" + + def test_load_bundled_material_directly(self): + """_load_bundled_material loads oak from resources.""" + with tempfile.TemporaryDirectory() as temp_dir: + manager = LibraryManager(Path(temp_dir)) + + mat = manager._load_bundled_material() + + assert mat is not None + assert mat.uid == "oak" + + def test_prefers_library_material_over_bundled(self): + """When oak exists in a registered library, it is used.""" + with tempfile.TemporaryDirectory() as temp_dir: + user_dir = Path(temp_dir) + lib_dir = user_dir / "test_lib" + lib_dir.mkdir() + meta = {"name": "Test", "id": "test-lib"} + with open(lib_dir / "__library__.yaml", "w") as f: + yaml.dump(meta, f) + mat_data = { + "uid": "oak", + "name": "Library Oak", + "category": "Wood", + } + with open(lib_dir / "oak.yaml", "w") as f: + yaml.dump(mat_data, f) + + manager = LibraryManager(user_dir) + manager.load_all_libraries() + + mat = manager.get_default_material() + + assert mat.uid == "oak" + assert mat.name == "Library Oak" + + def test_prefers_configured_default(self): + """When config has a default UID, it takes priority.""" + with tempfile.TemporaryDirectory() as temp_dir: + user_dir = Path(temp_dir) + lib_dir = user_dir / "test_lib" + lib_dir.mkdir() + meta = {"name": "Test", "id": "test-lib"} + with open(lib_dir / "__library__.yaml", "w") as f: + yaml.dump(meta, f) + mat_data = { + "uid": "cherry", + "name": "Cherry", + "category": "Wood", + } + with open(lib_dir / "cherry.yaml", "w") as f: + yaml.dump(mat_data, f) + + manager = LibraryManager(user_dir) + manager.load_all_libraries() + + fake_config = type( + "FakeConfig", + (), + {"default_stock_material_uid": "cherry"}, + )() + with patch( + "rayforge.core.library_manager.get_context" + ) as mock_ctx: + mock_ctx.return_value.config = fake_config + mat = manager.get_default_material() + + assert mat.uid == "cherry" + + def test_falls_back_to_bundled_when_configured_missing(self): + """When the configured UID is unavailable, falls back to oak.""" + with tempfile.TemporaryDirectory() as temp_dir: + manager = LibraryManager(Path(temp_dir)) + manager.load_all_libraries() + + fake_config = type( + "FakeConfig", + (), + {"default_stock_material_uid": "nonexistent"}, + )() + with patch( + "rayforge.core.library_manager.get_context" + ) as mock_ctx: + mock_ctx.return_value.config = fake_config + mat = manager.get_default_material() + + assert mat.uid == "oak" + + def test_falls_back_to_first_material_if_no_oak(self): + """When no oak is anywhere, returns the first available material.""" + with tempfile.TemporaryDirectory() as temp_dir: + user_dir = Path(temp_dir) + lib_dir = user_dir / "test_lib" + lib_dir.mkdir() + meta = {"name": "Test", "id": "test-lib"} + with open(lib_dir / "__library__.yaml", "w") as f: + yaml.dump(meta, f) + mat_data = { + "uid": "custom_mat", + "name": "Custom", + "category": "test", + } + with open(lib_dir / "custom_mat.yaml", "w") as f: + yaml.dump(mat_data, f) + + manager = LibraryManager(user_dir) + manager.load_all_libraries() + + with patch.object( + manager, "_load_bundled_material", return_value=None + ): + fake_config = type( + "FakeConfig", + (), + {"default_stock_material_uid": None}, + )() + with patch( + "rayforge.core.library_manager.get_context" + ) as mock_ctx: + mock_ctx.return_value.config = fake_config + mat = manager.get_default_material() + + assert mat.uid == "custom_mat" + + def test_raises_when_no_materials(self): + """Raises RuntimeError when no materials are available at all.""" + with tempfile.TemporaryDirectory() as temp_dir: + manager = LibraryManager(Path(temp_dir)) + manager.load_all_libraries() + + with patch.object( + manager, "_load_bundled_material", return_value=None + ): + fake_config = type( + "FakeConfig", + (), + {"default_stock_material_uid": None}, + )() + with patch( + "rayforge.core.library_manager.get_context" + ) as mock_ctx: + mock_ctx.return_value.config = fake_config + with pytest.raises(RuntimeError): + manager.get_default_material() + + +class TestConfigDefaultMaterial: + """Tests for Config default stock settings.""" + + def test_material_defaults_to_none(self): + config = Config() + assert config.default_stock_material_uid is None + + def test_thickness_defaults_to_18(self): + config = Config() + assert config.default_stock_thickness_mm == 18.0 + + def test_set_material_updates_value(self): + config = Config() + config.set_default_stock_material("oak") + assert config.default_stock_material_uid == "oak" + + def test_set_thickness_updates_value(self): + config = Config() + config.set_default_stock_thickness(5.0) + assert config.default_stock_thickness_mm == 5.0 + + def test_set_material_emits_changed_signal(self): + config = Config() + fired = [] + + def on_changed(sender, **kwargs): + fired.append(True) + + config.changed.connect(on_changed) + config.set_default_stock_material("oak") + assert len(fired) == 1 + + def test_set_thickness_emits_changed_signal(self): + config = Config() + fired = [] + + def on_changed(sender, **kwargs): + fired.append(True) + + config.changed.connect(on_changed) + config.set_default_stock_thickness(5.0) + assert len(fired) == 1 + + def test_set_material_no_op_on_same_value(self): + config = Config() + config.set_default_stock_material("oak") + fired = [] + + def on_changed(sender, **kwargs): + fired.append(True) + + config.changed.connect(on_changed) + config.set_default_stock_material("oak") + assert len(fired) == 0 + + def test_set_thickness_no_op_on_same_value(self): + config = Config() + config.set_default_stock_thickness(5.0) + fired = [] + + def on_changed(sender, **kwargs): + fired.append(True) + + config.changed.connect(on_changed) + config.set_default_stock_thickness(5.0) + assert len(fired) == 0 + + def test_to_dict_includes_material_uid(self): + config = Config() + config.set_default_stock_material("cherry") + data = config.to_dict() + assert data["default_stock_material_uid"] == "cherry" + + def test_to_dict_includes_thickness(self): + config = Config() + config.set_default_stock_thickness(12.5) + data = config.to_dict() + assert data["default_stock_thickness_mm"] == 12.5 + + def test_to_dict_includes_none_material(self): + config = Config() + data = config.to_dict() + assert data["default_stock_material_uid"] is None + + def test_from_dict_loads_material_uid(self): + config = Config.from_dict( + {"default_stock_material_uid": "walnut"}, + lambda mid: None, + ) + assert config.default_stock_material_uid == "walnut" + + def test_from_dict_loads_thickness(self): + config = Config.from_dict( + {"default_stock_thickness_mm": 6.0}, + lambda mid: None, + ) + assert config.default_stock_thickness_mm == 6.0 + + def test_from_dict_defaults_material_to_none(self): + config = Config.from_dict({}, lambda mid: None) + assert config.default_stock_material_uid is None + + def test_from_dict_defaults_thickness_to_18(self): + config = Config.from_dict({}, lambda mid: None) + assert config.default_stock_thickness_mm == 18.0 diff --git a/tests/core/test_material.py b/tests/core/test_material.py index 685305507..a7606a7ed 100644 --- a/tests/core/test_material.py +++ b/tests/core/test_material.py @@ -283,37 +283,10 @@ def test_appearance_defaults(self): assert appearance.texture_size_mm == 300.0 assert appearance.roughness == 0.8 assert appearance.metallic == 0.0 - assert appearance.tintable is False - - def test_appearance_tintable_round_trip(self): - """Tintable survives from_dict/to_dict, color may be unset.""" - data = { - "color": "#1A1A1A", - "pattern": "solid", - "tintable": True, - "texture": "abs.png", - "texture_size_mm": 300, - "roughness": 0.8, - "metallic": 0.0, - } - appearance = MaterialAppearance.from_dict(data) - - assert appearance.tintable is True - assert appearance.to_dict() == data - - def test_appearance_tintable_false_not_serialized(self): - """Tintable=False is the default and is not written to yaml.""" - appearance = MaterialAppearance(tintable=False) - - data = appearance.to_dict() - - assert "tintable" not in data def test_appearance_unset_color_not_serialized(self): """A None color ('not tinted') is omitted from the yaml output.""" - appearance = MaterialAppearance( - color=None, tintable=True, texture="abs.png" - ) + appearance = MaterialAppearance(color=None, texture="abs.png") data = appearance.to_dict() @@ -323,32 +296,21 @@ def test_appearance_unset_color_not_serialized(self): def test_appearance_from_dict_null_color(self): """A yaml entry with `color:` (null) parses to an unset color.""" appearance = MaterialAppearance.from_dict( - {"color": None, "pattern": "solid", "tintable": True} + {"color": None, "pattern": "solid"} ) assert appearance.color is None def test_get_tint_rgba(self): - """Tint RGBA is returned only for tintable materials with a color.""" - assert MaterialAppearance( - color="#FF0000", tintable=True - ).get_tint_rgba() == ( + """Tint RGBA is returned only when a color is set.""" + assert MaterialAppearance(color="#FF0000").get_tint_rgba() == ( 1.0, 0.0, 0.0, 1.0, ) - assert MaterialAppearance(color="#FF0000").get_tint_rgba() is None - assert ( - MaterialAppearance(color=None, tintable=True).get_tint_rgba() - is None - ) - assert ( - MaterialAppearance( - color="not-a-color", tintable=True - ).get_tint_rgba() - is None - ) + assert MaterialAppearance(color=None).get_tint_rgba() is None + assert MaterialAppearance(color="not-a-color").get_tint_rgba() is None def test_appearance_round_trip(self): """Test that new appearance fields survive from_dict/to_dict.""" @@ -394,16 +356,13 @@ def test_appearance_from_dict_defaults(self): def test_appearance_unset_color_round_trip(self): """An unset color survives to_dict/from_dict as None.""" - appearance = MaterialAppearance( - color=None, tintable=True, texture="abs.png" - ) + appearance = MaterialAppearance(color=None, texture="abs.png") data = appearance.to_dict() assert "color" not in data reloaded = MaterialAppearance.from_dict(data) assert reloaded.color is None - assert reloaded.tintable is True assert reloaded.texture == "abs.png" def test_appearance_from_dict_partial(self): diff --git a/tests/core/test_optical.py b/tests/core/test_optical.py new file mode 100644 index 000000000..749b70359 --- /dev/null +++ b/tests/core/test_optical.py @@ -0,0 +1,130 @@ +"""Tests for the physical optical model (absorption + burn response).""" + +import pytest + +from rayforge.core.material import Material, MaterialAppearance +from rayforge.core.optical import ( + DEFAULT_ABSORPTION, + DEFAULT_BAND_ABSORPTION, + absorption_for, + burn_response_for, + material_absorption, + material_burn_response, + wavelength_to_band, +) + + +class TestWavelengthToBand: + def test_blue_band(self): + assert wavelength_to_band(455.0) == "blue" + assert wavelength_to_band(445.0) == "blue" + assert wavelength_to_band(400.0) == "blue" + + def test_ir_band(self): + assert wavelength_to_band(1064.0) == "ir" + + def test_co2_band(self): + assert wavelength_to_band(10600.0) == "co2" + assert wavelength_to_band(9300.0) == "co2" + + def test_zero_or_negative_falls_back_to_blue(self): + assert wavelength_to_band(0.0) == "blue" + assert wavelength_to_band(-1.0) == "blue" + + +class TestAbsorptionFor: + def test_none_dict_returns_full_absorption(self): + assert absorption_for(455.0, None) == DEFAULT_ABSORPTION + + def test_missing_band_returns_neutral(self): + assert absorption_for(455.0, {"ir": 0.2}) == DEFAULT_BAND_ABSORPTION + + def test_present_band_returns_value(self): + assert absorption_for(455.0, {"blue": 0.9}) == 0.9 + + def test_clamps_to_unit_range(self): + assert absorption_for(455.0, {"blue": 2.0}) == 1.0 + assert absorption_for(455.0, {"blue": -0.5}) == 0.0 + + def test_non_numeric_falls_back_to_neutral(self): + bad: dict[str, float] = {"blue": "high"} # type: ignore[dict-item] + assert absorption_for(455.0, bad) == DEFAULT_BAND_ABSORPTION + + def test_co2_wavelength_maps_to_co2_band(self): + assert absorption_for(10600.0, {"blue": 0.1, "co2": 0.92}) == 0.92 + + +class TestBurnResponseFor: + def test_none_returns_defaults(self): + br = burn_response_for(None) + assert br["char_threshold"] == 35.0 + assert br["char_saturation"] == 125.0 + assert br["char_color_low"] == (0.04, 0.03, 0.02) + + def test_partial_override(self): + br = burn_response_for({"char_threshold": 10.0}) + assert br["char_threshold"] == 10.0 + assert br["char_saturation"] == 125.0 # default kept + + def test_clamps_thresholds(self): + br = burn_response_for( + {"char_threshold": -1.0, "char_saturation": 500.0} + ) + assert br["char_threshold"] == 0.0 + assert br["char_saturation"] == 500.0 + + def test_non_degenerate_ramp_enforced(self): + br = burn_response_for( + {"char_threshold": 35.0, "char_saturation": 40.0} + ) + assert br["char_saturation"] > br["char_threshold"] + + def test_color_from_hex(self): + br = burn_response_for({"char_color_low": "#ff0000"}) + assert br["char_color_low"] == (1.0, 0.0, 0.0) + + def test_color_from_floats(self): + br = burn_response_for({"char_color_low": [0.1, 0.2, 0.3]}) + assert br["char_color_low"] == (0.1, 0.2, 0.3) + + def test_color_from_0_255_ints_normalized(self): + br = burn_response_for({"char_color_low": [128, 64, 32]}) + assert br["char_color_low"] == pytest.approx( + (128 / 255, 64 / 255, 32 / 255) + ) + + def test_invalid_color_falls_back_to_grey(self): + br = burn_response_for({"char_color_low": "not-a-color"}) + assert br["char_color_low"] == (0.5, 0.5, 0.5) + + +class TestMaterialOptical: + def _material(self, extra=None): + appearance = MaterialAppearance() + appearance.extra = extra or {} + return Material(uid="t", appearance=appearance) + + def test_material_without_optical_data_uses_full_absorption(self): + m = self._material() + assert material_absorption(m, 455.0) == DEFAULT_ABSORPTION + + def test_material_with_absorption(self): + m = self._material({"absorption": {"blue": 0.3, "co2": 0.9}}) + assert material_absorption(m, 455.0) == 0.3 + assert material_absorption(m, 10600.0) == 0.9 + # Missing ir band → neutral default + assert material_absorption(m, 1064.0) == DEFAULT_BAND_ABSORPTION + + def test_material_burn_response_defaults(self): + m = self._material() + br = material_burn_response(m) + assert "char_threshold" in br + assert "char_color_low" in br + + def test_material_burn_response_override(self): + m = self._material( + {"burn_response": {"char_threshold": 0.3, "char_saturation": 0.9}} + ) + br = material_burn_response(m) + assert br["char_threshold"] == 0.3 + assert br["char_saturation"] == 0.9 diff --git a/tests/core/test_stock.py b/tests/core/test_stock.py index 8520dcbca..b3320bfff 100644 --- a/tests/core/test_stock.py +++ b/tests/core/test_stock.py @@ -177,7 +177,7 @@ def test_stock_item_get_effective_color(mock_asset): """Effective color resolution: override, no-color, then default.""" material = Material( uid="acrylic", - appearance=MaterialAppearance(color="#1A1A1A", tintable=True), + appearance=MaterialAppearance(color="#1A1A1A"), ) mock_asset.material = material item = StockItem(stock_asset_uid="asset-123") @@ -198,33 +198,11 @@ def test_stock_item_get_effective_color(mock_asset): assert item.get_effective_color() == "#00ff00" -def test_stock_item_get_effective_color_non_tintable(mock_asset): - """ - Stock-level color resolution is tinting-agnostic: it resolves the - material's color even for non-tintable materials. The tintable gate - lives in the render layer, not in the stock model. - """ - material = Material( - uid="oak", - appearance=MaterialAppearance(color="#A0522D", tintable=False), - ) - mock_asset.material = material - item = StockItem(stock_asset_uid="asset-123") - mock_doc = MagicMock() - mock_doc.doc = mock_doc - mock_doc.get_asset_by_uid.return_value = mock_asset - item.parent = mock_doc - - assert item.get_effective_color() == "#a0522d" - item.set_color("#00FF00") - assert item.get_effective_color() == "#00ff00" - - def test_stock_item_get_effective_rgba(mock_asset): """Effective color parses to a render-ready RGBA tuple.""" material = Material( uid="acrylic", - appearance=MaterialAppearance(color="#FF0000", tintable=True), + appearance=MaterialAppearance(color="#FF0000"), ) mock_asset.material = material item = StockItem(stock_asset_uid="asset-123") diff --git a/tests/machine/models/test_laser.py b/tests/machine/models/test_laser.py index c3fb06b3e..57cefbea2 100644 --- a/tests/machine/models/test_laser.py +++ b/tests/machine/models/test_laser.py @@ -151,6 +151,20 @@ def test_laser_roundtrip_serialization(): assert new_laser.uid == original_laser.uid +def test_laser_none_colors_coerce_to_magenta(): + """A laser must never hold a None operation colour. + + An unset (None) colour — which can arrive via imported profiles — + coerces to magenta so the scanline/ops rendering never falls back + to the theme foreground (grey in light mode). + """ + laser = Laser.from_dict({"uid": "l1", "cut_color": None}) + assert laser.cut_color == "#ff00ff" + + laser.set_cut_color(None) + assert laser.cut_color == "#ff00ff" + + def test_backward_compatibility_old_format(): """Test that old format data (without _percent fields) is converted correctly. diff --git a/tests/pipeline/artifact/test_material_state_artifact.py b/tests/pipeline/artifact/test_material_state_artifact.py new file mode 100644 index 000000000..f4dfee36f --- /dev/null +++ b/tests/pipeline/artifact/test_material_state_artifact.py @@ -0,0 +1,85 @@ +"""Tests for :mod:`rayforge.pipeline.artifact.material_state`.""" + +from raygeo.ops.material.fold import fold_effects +from raygeo.ops.material.spec import ( + GridBudget, + MaterialFoldSpec, + PrismaticStock, +) + +from rayforge.pipeline.artifact import ( + MaterialStateArtifact, + MaterialStateArtifactHandle, + create_handle_from_dict, +) +from rayforge.pipeline.artifact.store import ArtifactStore + + +def _make_empty_state(): + """Build a real (empty) raygeo ``MaterialState`` for tests.""" + stock = PrismaticStock( + polygons=[[(0, 0), (100, 0), (100, 80), (0, 80)]], + thickness=18.0, + ) + spec = MaterialFoldSpec(stock=stock, entries=[], grid=GridBudget()) + return fold_effects(spec) + + +def test_artifact_type_property(): + artifact = MaterialStateArtifact( + material_state=_make_empty_state(), + stock_uid="stock-1", + generation_id=1, + ) + assert artifact.artifact_type == "MaterialStateArtifact" + + +def test_artifact_preserves_state(): + state = _make_empty_state() + artifact = MaterialStateArtifact( + material_state=state, + stock_uid="stock-1", + generation_id=7, + ) + assert artifact.material_state is state + assert artifact.stock_uid == "stock-1" + assert artifact.generation_id == 7 + + +def test_handle_roundtrip_via_store(): + """put → get → release preserves the wrapped MaterialState.""" + store = ArtifactStore() + state = _make_empty_state() + artifact = MaterialStateArtifact( + material_state=state, + stock_uid="stock-abc", + generation_id=3, + ) + handle = store.put(artifact, "material") + assert isinstance(handle, MaterialStateArtifactHandle) + assert handle.stock_uid == "stock-abc" + assert handle.generation_id == 3 + + retrieved = store.get(handle) + assert isinstance(retrieved, MaterialStateArtifact) + assert retrieved.stock_uid == "stock-abc" + assert retrieved.generation_id == 3 + assert retrieved.material_state.profile == state.profile + store.release(handle) + + +def test_handle_serialization(): + """Handle ``to_dict``/``create_handle_from_dict`` round-trips the + stock uid.""" + original = MaterialStateArtifactHandle( + key="test_material_123", + handle_class_name="MaterialStateArtifactHandle", + artifact_type_name="MaterialStateArtifact", + generation_id=1, + stock_uid="stock-xyz", + ) + handle_dict = original.to_dict() + reconstructed = create_handle_from_dict(handle_dict) + assert isinstance(reconstructed, MaterialStateArtifactHandle) + assert original == reconstructed + assert reconstructed.stock_uid == "stock-xyz" diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index 04c95fe04..636452418 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -28,6 +28,7 @@ from raygeo.ops.assembly.contour import ContourSpec from raygeo.ops.axis import Axis from raygeo.ops.convert import Encoder, GcodeSpec +from raygeo.ops.material.spec import CylinderStock, MaterialFoldSpec from raygeo.ops.part import Part from raygeo.pipeline.execute import execute_stages from raygeo.pipeline.request import NodeRequest @@ -40,6 +41,7 @@ from rayforge.core.stock_asset import StockAsset from rayforge.core.workpiece import WorkPiece from rayforge.machine.models.dialect.grbl import GRBL_DIALECT +from rayforge.machine.models.laser import LaserHead from rayforge.machine.models.machine import Machine, Origin from rayforge.machine.models.machine_panel import PanelOrientation from rayforge.machine.models.rotary_module import RotaryMode, RotaryModule @@ -50,7 +52,9 @@ job_encode_key, job_key, job_machinexform_key, + parse_stock_key, step_key, + stock_key, workpiece_key, ) @@ -546,6 +550,33 @@ def test_compute_token_changes_on_machine_arc_tolerance( assert before_t != after_t +def test_compute_token_changes_on_laser_power_change( + contour_step_class, test_machine_and_config +): + """Changing the machine's laser optical power invalidates the + compute cache. + + The burn fluence model scales with ``max_power_watts``; without + folding it into the compute token, the pipeline would keep serving + the pre-change burn until an unrelated edit (e.g. a workpiece + resize) bumped the token. + """ + machine, context = test_machine_and_config + step = contour_step_class.create(context, name="cut") + wp = WorkPiece(name="wp") + doc = _make_doc(step, wp) + + laser = next(h for h in machine.heads if isinstance(h, LaserHead)) + before = IntentBuilder(machine=machine).build(doc) + wpk = workpiece_key(wp.uid, step.uid) + before_t = next(n.version_token for n in before if n.key == wpk) + + laser.max_power_watts = 55.0 + after = IntentBuilder(machine=machine).build(doc) + after_t = next(n.version_token for n in after if n.key == wpk) + assert before_t != after_t + + def test_contour_compute_node_executes_through_raygeo( contour_step_class, test_machine_and_config ): @@ -1646,3 +1677,398 @@ def test_panel_rotation_does_not_change_gcode( assert len(native_coords) >= 4 assert rotated_coords == native_coords + + +# ---------------------------------------------------------------------- +# Stock fold nodes +# ---------------------------------------------------------------------- + + +def _wp_with_geometry(name: str = "wp") -> WorkPiece: + """A WorkPiece whose world geometry overlaps the stock's + ``(0,0)-(100,100)`` rect: a 50×50 mm piece at the origin.""" + wp = WorkPiece(name=name) + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(1, 0) + geo.line_to(1, 1) + geo.line_to(0, 1) + geo.close_path() + # The boundaries are a 1x1 normalized box; scale it to 50×50 mm + # via the workpiece matrix so its world rect overlaps the stock. + wp._boundaries_cache = geo + wp.matrix = Matrix.scale(50.0, 50.0) + return wp + + +def _make_doc_with_stock_and_wp( + step: _TestStep, + wp: WorkPiece, + stock_visible: bool = True, + thickness: float = 18.0, +) -> tuple[Doc, StockItem]: + """Build a Doc with *step*, *wp*, and a 100×80 mm stock item that + overlaps the workpiece, returning ``(doc, stock_item)``.""" + doc = _make_doc(step, wp) + asset = StockAsset(name="sheet", geometry=None) + asset.set_thickness(thickness) + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(100, 0) + geo.line_to(100, 80) + geo.line_to(0, 80) + geo.close_path() + asset.geometry = geo + doc.add_asset(asset) + item = StockItem(stock_asset_uid=asset.uid, name="sheet") + item.visible = stock_visible + doc.add_child(item) + return doc, item + + +def test_stock_key_roundtrip(): + """``stock_key``/``parse_stock_key`` are inverse.""" + key = stock_key("abc-123") + assert key == "stock:abc-123" + assert parse_stock_key(key) == "abc-123" + assert parse_stock_key("workpiece:x:y") is None + assert parse_stock_key("stock:") is None + assert parse_stock_key("stock:a:b") is None + + +def test_builds_one_fold_node_per_visible_stock(isolated_machine): + """Two visible stock items → two ``stock:{uid}`` fold nodes.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp) + # Add a second stock item. + asset2 = StockAsset(name="sheet2") + asset2.set_thickness(5.0) + geo2 = Geometry() + geo2.move_to(0, 0) + geo2.line_to(50, 0) + geo2.line_to(50, 50) + geo2.line_to(0, 50) + geo2.close_path() + asset2.geometry = geo2 + doc.add_asset(asset2) + item2 = StockItem(stock_asset_uid=asset2.uid, name="sheet2") + doc.add_child(item2) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + keys = [n.key for n in nodes] + assert stock_key(item.uid) in keys + assert stock_key(item2.uid) in keys + + +def test_no_fold_node_for_invisible_stock(isolated_machine): + """An invisible stock item emits no fold node.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp, stock_visible=False) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + keys = [n.key for n in nodes] + assert stock_key(item.uid) not in keys + + +def test_fold_node_token_stability(isolated_machine): + """Building twice with unchanged inputs yields equal fold tokens.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp) + + before = IntentBuilder(machine=isolated_machine).build(doc) + after = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(item.uid) + before_t = next(n.version_token for n in before if n.key == sk) + after_t = next(n.version_token for n in after if n.key == sk) + assert before_t == after_t + + +def test_fold_node_token_changes_on_stock_move(isolated_machine): + """Moving the stock changes its fold node token.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp) + sk = stock_key(item.uid) + + before = IntentBuilder(machine=isolated_machine).build(doc) + # Small move keeps the stock overlapping the workpiece so the fold + # node is still emitted, while changing the token. + item.matrix = Matrix.translation(10.0, 0.0) + after = IntentBuilder(machine=isolated_machine).build(doc) + before_t = next(n.version_token for n in before if n.key == sk) + after_t = next(n.version_token for n in after if n.key == sk) + assert before_t != after_t + + +def test_fold_node_token_changes_on_thickness_change(isolated_machine): + """Changing the stock thickness changes its fold node token.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp, thickness=18.0) + sk = stock_key(item.uid) + + before = IntentBuilder(machine=isolated_machine).build(doc) + asset = item.stock_asset + assert asset is not None + asset.set_thickness(5.0) + after = IntentBuilder(machine=isolated_machine).build(doc) + before_t = next(n.version_token for n in before if n.key == sk) + after_t = next(n.version_token for n in after if n.key == sk) + assert before_t != after_t + + +def test_fold_node_no_intersecting_workpieces_skipped(isolated_machine): + """A stock item with no intersecting workpiece emits no fold node.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp) + # Move the stock far away from the workpiece. + item.matrix = Matrix.translation(10000.0, 10000.0) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + keys = [n.key for n in nodes] + assert stock_key(item.uid) not in keys + + +def test_fold_node_is_material_fold_stage(isolated_machine): + """The fold node's stage is a ``MaterialFoldSpec`` (passed directly, + not wrapped in a ``StageSpec`` — pipeline is domain-free).""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc, item = _make_doc_with_stock_and_wp(step, wp) + sk = stock_key(item.uid) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + fold_node = next(n for n in nodes if n.key == sk) + assert isinstance(fold_node.stage, MaterialFoldSpec) + + +def test_fold_node_entries_cover_intersecting_workpieces(isolated_machine): + """The fold spec's entries list every intersecting workpiece compute + node; non-intersecting workpieces are absent.""" + step = _TestStep(name="s1") + wp_far = _wp_with_geometry("wp_far") + wp_far.matrix = Matrix.translation(10000.0, 10000.0) + wp_near = _wp_with_geometry("wp_near") + doc, item = _make_doc_with_stock_and_wp(step, wp_near) + doc.active_layer.add_child(wp_far) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(item.uid) + fold_node = next(n for n in nodes if n.key == sk) + spec = fold_node.stage + source_keys = {e.source_key for e in spec.entries} + near_key = workpiece_key(wp_near.uid, step.uid) + far_key = workpiece_key(wp_far.uid, step.uid) + assert near_key in source_keys + assert far_key not in source_keys + + +def test_fold_node_includes_image_workpiece_without_geometry( + isolated_machine, +): + """An image/raster workpiece (no vector geometry) still contributes + a fold entry when its size-based world AABB intersects the stock. + + Raster workpieces carry no ``boundaries`` geometry, so + ``get_world_geometry`` returns ``None``; the fold wiring must fall + back to the workpiece's ``size`` transformed into world space, + otherwise scanline burns never reach the stock. + """ + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + wp.set_size(50.0, 50.0) + doc, item = _make_doc_with_stock_and_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(item.uid) + fold_node = next(n for n in nodes if n.key == sk) + assert isinstance(fold_node.stage, MaterialFoldSpec) + source_keys = {e.source_key for e in fold_node.stage.entries} + assert workpiece_key(wp.uid, step.uid) in source_keys + + +def test_fold_node_skips_image_workpiece_outside_stock(isolated_machine): + """An image workpiece whose world AABB does not intersect the + stock is still excluded from the fold.""" + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + wp.set_size(50.0, 50.0) + wp.matrix = Matrix.translation(10000.0, 10000.0) + doc, item = _make_doc_with_stock_and_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + keys = [n.key for n in nodes] + assert stock_key(item.uid) not in keys + + +def test_existing_node_count_unchanged_with_stock(isolated_machine): + """Adding a stock item does not change the count of workpiece/step/ + job nodes — fold nodes are additive.""" + step = _TestStep(name="s1") + wp1 = _wp_with_geometry("wp1") + wp2 = _wp_with_geometry("wp2") + doc_no_stock = _make_doc(step, wp1, wp2) + + # Build a separate doc with its own workpieces + a stock item, so + # workpieces are not re-parented between docs. + step2 = _TestStep(name="s1") + wp1b = _wp_with_geometry("wp1") + wp2b = _wp_with_geometry("wp2") + doc_with_stock, _item = _make_doc_with_stock_and_wp(step2, wp1b) + doc_with_stock.active_layer.add_child(wp2b) + + no_stock = IntentBuilder(machine=isolated_machine).build(doc_no_stock) + with_stock = IntentBuilder(machine=isolated_machine).build(doc_with_stock) + + non_stock_keys = [ + n.key for n in with_stock if not n.key.startswith("stock:") + ] + assert len(non_stock_keys) == len(no_stock) + + +# ---------------------------------------------------------------------- +# Rotary layer fold nodes +# ---------------------------------------------------------------------- + + +def _with_default_rotary(machine) -> RotaryModule: + module = RotaryModule() + module.max_workpiece_length = 300.0 + machine.rotary_modules[module.uid] = module + machine.default_rotary_module_uid = module.uid + return module + + +def _make_doc_with_rotary_wp(step: _TestStep, wp: WorkPiece) -> Doc: + """A Doc whose active layer is rotary-enabled with a workpiece in + the unrolled domain.""" + doc = _make_doc(step, wp) + doc.active_layer.rotary_enabled = True + doc.active_layer.rotary_diameter = 50.0 + return doc + + +def test_rotary_layer_emits_fold_node(isolated_machine): + """A rotary layer with an intersecting workpiece emits a fold node + keyed by the layer's uid.""" + _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc = _make_doc_with_rotary_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(doc.active_layer.uid) + assert sk in [n.key for n in nodes] + + +def test_rotary_fold_node_uses_cylinder_stock(isolated_machine): + """The rotary fold node's stage is a ``MaterialFoldSpec`` against + a ``CylinderStock`` spanning the module's max workpiece length.""" + module = _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc = _make_doc_with_rotary_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(doc.active_layer.uid) + spec = next(n for n in nodes if n.key == sk).stage + assert isinstance(spec, MaterialFoldSpec) + assert isinstance(spec.stock, CylinderStock) + assert spec.stock.diameter == 50.0 + assert spec.stock.length == module.max_workpiece_length + near_key = workpiece_key(wp.uid, step.uid) + assert {e.source_key for e in spec.entries} == {near_key} + + +def test_rotary_fold_node_skipped_without_module(isolated_machine): + """Without a default rotary module, no rotary fold node is + emitted even when a layer is rotary-enabled.""" + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc = _make_doc_with_rotary_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(doc.active_layer.uid) + assert sk not in [n.key for n in nodes] + + +def test_rotary_fold_node_includes_far_workpieces(isolated_machine): + """Workpieces far from the unrolled domain still contribute fold + entries: image workpieces may not resolve world geometry, and the + fold's raster sampling is harmless for misses.""" + _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = _wp_with_geometry() + wp.matrix = Matrix.translation(10000.0, 10000.0) + doc = _make_doc_with_rotary_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + sk = stock_key(doc.active_layer.uid) + spec = next(n for n in nodes if n.key == sk).stage + assert {e.source_key for e in spec.entries} == { + workpiece_key(wp.uid, step.uid) + } + + +def test_flat_stock_node_not_emitted_for_rotary_layer(isolated_machine): + """Rotary layers don't emit flat-stock keys and vice versa.""" + _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = _wp_with_geometry() + doc = _make_doc_with_rotary_wp(step, wp) + + nodes = IntentBuilder(machine=isolated_machine).build(doc) + stock_keys = [n.key for n in nodes if n.key.startswith("stock:")] + assert stock_keys == [stock_key(doc.active_layer.uid)] + + +def test_rotary_fold_executes_end_to_end( + contour_step_class, test_machine_and_config +): + """A rotary layer's fold node runs through ``execute_stages`` and + produces a cylindrical material state.""" + machine, context = test_machine_and_config + module = RotaryModule() + module.max_workpiece_length = 300.0 + machine.rotary_modules[module.uid] = module + machine.default_rotary_module_uid = module.uid + + step = contour_step_class.create(context, name="cut") + geo = Geometry() + geo.move_to(0.0, 0.0) + geo.line_to(10.0, 0.0) + geo.line_to(10.0, 10.0) + geo.line_to(0.0, 10.0) + geo.close_path() + wp = WorkPiece(name="rect") + wp._edited_boundaries = geo + wp.set_size(50.0, 30.0) + doc = _make_doc(step, wp) + doc.active_layer.rotary_enabled = True + doc.active_layer.rotary_diameter = 50.0 + + nodes = IntentBuilder(machine=machine, generation_id=1).build(doc) + sk = stock_key(doc.active_layer.uid) + + completed = [] + + def on_completed(node): + completed.append(node) + + assert sk in [n.key for n in nodes], [n.key for n in nodes] + + execute_stages(nodes, on_completed) + + fold_result = next(c for c in completed if c.key == sk) + assert fold_result.error is None, fold_result.error + state = fold_result.output + assert state.profile == "cylindrical" + assert state.surface_map is not None + assert state.grid is not None + # Arc axis centered on the machine origin (diameter 50). + assert state.grid.origin_mm[0] == 0.0 + assert state.grid.origin_mm[1] == pytest.approx(-np.pi * 25.0) diff --git a/tests/pipeline/test_intent_controller.py b/tests/pipeline/test_intent_controller.py index d185a0f9b..b67699601 100644 --- a/tests/pipeline/test_intent_controller.py +++ b/tests/pipeline/test_intent_controller.py @@ -10,16 +10,22 @@ from collections.abc import Callable from typing import Any, ClassVar, Optional +from raygeo.geo import Geometry, Matrix + from rayforge.core.doc import Doc from rayforge.core.step import Step +from rayforge.core.stock import StockItem +from rayforge.core.stock_asset import StockAsset from rayforge.core.workpiece import WorkPiece from rayforge.machine.models.machine import Machine from rayforge.machine.models.machine_panel import PanelOrientation +from rayforge.machine.models.rotary_module import RotaryModule from rayforge.pipeline.intent_builder import ( IntentBuilder, job_encode_key, job_key, step_key, + stock_key, workpiece_key, ) from rayforge.pipeline.intent_controller import ( @@ -832,3 +838,218 @@ def test_disconnect_prevents_further_rebuilds(isolated_machine): wp.updated.send(wp) assert tm.delayed == [] + + +# ---------------------------------------------------------------------- +# Stock fold reattachment +# ---------------------------------------------------------------------- + + +def _make_doc_with_stock( + step: _TestStep, wp: WorkPiece +) -> tuple[Doc, StockItem]: + """Build a Doc with a stock item overlapping *wp*; return + ``(doc, stock_item)``.""" + # Give the workpiece a 50×50 mm world geometry at the origin so it + # overlaps the stock (AABB intersection drives fold-node emission). + geo_wp = Geometry() + geo_wp.move_to(0, 0) + geo_wp.line_to(1, 0) + geo_wp.line_to(1, 1) + geo_wp.line_to(0, 1) + geo_wp.close_path() + wp._boundaries_cache = geo_wp + wp.matrix = Matrix.scale(50.0, 50.0) + + doc = _make_doc(step, wp) + asset = StockAsset(name="sheet") + asset.set_thickness(18.0) + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(100, 0) + geo.line_to(100, 80) + geo.line_to(0, 80) + geo.close_path() + asset.geometry = geo + doc.add_asset(asset) + item = StockItem(stock_asset_uid=asset.uid, name="sheet") + doc.add_child(item) + return doc, item + + +def _make_controller_with_stock(monkeypatch, machine, step, wp, stock_item): + """Build a controller whose key map includes ``stock:{uid}``.""" + doc = stock_item.parent + tm = ImmediateMainThreadTaskManager() + ctrl = IntentController(doc, tm, machine=machine) + ctrl.connect() + # Build once so the key map is populated; run_intent is stubbed + # so no real pipeline work happens. + monkeypatch.setattr( + "rayforge.pipeline.intent_controller.run_intent", + lambda *a, **kw: None, + ) + wp.updated.send(wp) + tm.fire_latest() + # Clear any rebuild-finished main-thread calls queued above so the + # stock tests only see their own. + tm.main_thread_calls.clear() + return ctrl + + +def test_stock_key_in_reattach_map(monkeypatch, isolated_machine): + """A ``stock:{uid}`` key is mapped to its StockItem in the + reattachment map.""" + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + _doc, item = _make_doc_with_stock(step, wp) + ctrl = _make_controller_with_stock( + monkeypatch, isolated_machine, step, wp, item + ) + sk = stock_key(item.uid) + assert sk in ctrl._key_to_item + assert ctrl._key_to_item[sk] is item + ctrl.shutdown() + + +def test_material_state_ready_emitted_for_current_generation( + monkeypatch, isolated_machine +): + """A current-generation ``stock:`` result emits + ``material_state_ready`` with the stock item and output.""" + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + _doc, item = _make_doc_with_stock(step, wp) + ctrl = _make_controller_with_stock( + monkeypatch, isolated_machine, step, wp, item + ) + + received: list = [] + ctrl.material_state_ready.connect( + lambda _sender, *, item, output, generation_id: received.append( + (item, output, generation_id) + ), + weak=False, + ) + + sk = stock_key(item.uid) + node = _StubNode(key=sk, generation_id=ctrl.generation_id, output="state") + ctrl._on_completed(node) + assert len(received) == 1 + assert received[0][0] is item + assert received[0][1] == "state" + assert received[0][2] == ctrl.generation_id + ctrl.shutdown() + + +def test_superseded_stock_generation_discarded(monkeypatch, isolated_machine): + """A stale ``stock:`` result (older generation) is discarded.""" + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + _doc, item = _make_doc_with_stock(step, wp) + ctrl = _make_controller_with_stock( + monkeypatch, isolated_machine, step, wp, item + ) + + received: list = [] + ctrl.material_state_ready.connect( + lambda _sender, *, item, output, generation_id: received.append( + (item, output, generation_id) + ), + weak=False, + ) + + sk = stock_key(item.uid) + stale = _StubNode(key=sk, generation_id=0, output="stale") + ctrl._on_completed(stale) + assert received == [] + ctrl.shutdown() + + +# ---------------------------------------------------------------------- +# Rotary layer fold reattachment +# ---------------------------------------------------------------------- + + +def _make_doc_with_rotary_layer(step: _TestStep, wp: WorkPiece): + """Build a Doc whose layer is rotary-enabled with a workpiece in + the unrolled domain; return ``(doc, layer)``.""" + geo_wp = Geometry() + geo_wp.move_to(0, 0) + geo_wp.line_to(1, 0) + geo_wp.line_to(1, 1) + geo_wp.line_to(0, 1) + geo_wp.close_path() + wp._boundaries_cache = geo_wp + wp.matrix = Matrix.scale(50.0, 50.0) + + doc = _make_doc(step, wp) + layer = doc.active_layer + layer.rotary_enabled = True + layer.rotary_diameter = 50.0 + return doc, layer + + +def _make_controller(monkeypatch, machine, doc): + tm = ImmediateMainThreadTaskManager() + ctrl = IntentController(doc, tm, machine=machine) + ctrl.connect() + monkeypatch.setattr( + "rayforge.pipeline.intent_controller.run_intent", + lambda *a, **kw: None, + ) + return ctrl, tm + + +def _with_default_rotary(machine): + module = RotaryModule() + module.max_workpiece_length = 300.0 + machine.rotary_modules[module.uid] = module + machine.default_rotary_module_uid = module.uid + return module + + +def test_rotary_layer_key_maps_to_layer(monkeypatch, isolated_machine): + """A ``stock:{layer_uid}`` key is mapped to its Layer.""" + _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + doc, layer = _make_doc_with_rotary_layer(step, wp) + ctrl, tm = _make_controller(monkeypatch, isolated_machine, doc) + wp.updated.send(wp) + tm.fire_latest() + + sk = stock_key(layer.uid) + assert sk in ctrl._key_to_item + assert ctrl._key_to_item[sk] is layer + ctrl.shutdown() + + +def test_material_state_ready_emitted_for_rotary_layer( + monkeypatch, isolated_machine +): + """A ``stock:{layer_uid}`` result emits ``material_state_ready`` + with the layer as the item.""" + _with_default_rotary(isolated_machine) + step = _TestStep(name="s1") + wp = WorkPiece(name="wp") + doc, layer = _make_doc_with_rotary_layer(step, wp) + ctrl, tm = _make_controller(monkeypatch, isolated_machine, doc) + wp.updated.send(wp) + tm.fire_latest() + tm.main_thread_calls.clear() + + received: list = [] + ctrl.material_state_ready.connect( + lambda _sender, *, item, output, generation_id: received.append( + (item, output, generation_id) + ), + weak=False, + ) + sk = stock_key(layer.uid) + node = _StubNode(key=sk, generation_id=ctrl.generation_id, output="state") + ctrl._on_completed(node) + assert len(received) == 1 + assert received[0][0] is layer + assert received[0][1] == "state" + ctrl.shutdown() diff --git a/tests/simulator/test_compiled_scene.py b/tests/simulator/test_compiled_scene.py index 0fc6d22c5..d77658068 100644 --- a/tests/simulator/test_compiled_scene.py +++ b/tests/simulator/test_compiled_scene.py @@ -5,7 +5,6 @@ from rayforge.simulator.scene3d.compiled_scene import ( CompiledSceneArtifact, ScanlineOverlayLayer, - TextureLayer, VertexLayer, ) @@ -16,10 +15,6 @@ def _ca_f32(data): ) -def _ca_u8_2d(data): - return CompressedArray.from_uint8_2d(np.asarray(data, dtype=np.uint8)) - - def _make_vertex_layer(n_powered=10, n_travel=5, n_zero=3): attrib = np.zeros((n_powered, 4), dtype=np.float32) attrib[:, 0] = np.random.rand(n_powered) @@ -34,19 +29,6 @@ def _make_vertex_layer(n_powered=10, n_travel=5, n_zero=3): ) -def _make_texture_layer(with_cylinder=True): - kw = {} - if with_cylinder: - kw["cylinder_vertices"] = np.random.rand(64, 3).astype(np.float32) - return TextureLayer( - power_texture=_ca_u8_2d(np.random.randint(0, 255, (32, 48))), - width_px=48, - height_px=32, - model_matrix=np.eye(4, dtype=np.float32), - **kw, - ) - - def _make_overlay_layer(n_cmds=5, n_verts=20): attrib = np.zeros((n_verts, 4), dtype=np.float32) attrib[:, 0] = np.random.rand(n_verts) @@ -72,7 +54,6 @@ def test_vertex_layers_only(self): artifact = CompiledSceneArtifact( generation_id=42, vertex_layers=[_make_vertex_layer(), _make_vertex_layer()], - texture_layers=[], overlay_layers=[], ) store = ArtifactStore() @@ -80,7 +61,6 @@ def test_vertex_layers_only(self): assert loaded.generation_id == 42 assert len(loaded.vertex_layers) == 2 - assert len(loaded.texture_layers) == 0 assert len(loaded.overlay_layers) == 0 for orig, restored in zip( @@ -111,41 +91,11 @@ def test_vertex_layers_only(self): store.release(handle) - def test_texture_layers_with_optionals(self): - artifact = CompiledSceneArtifact( - generation_id=1, - vertex_layers=[], - texture_layers=[ - _make_texture_layer(with_cylinder=True), - _make_texture_layer(with_cylinder=False), - ], - overlay_layers=[], - ) - store = ArtifactStore() - handle, loaded = _roundtrip(artifact, store, "test_tl") - - assert len(loaded.texture_layers) == 2 - - tl0 = loaded.texture_layers[0] - assert tl0.width_px == 48 - assert tl0.height_px == 32 - assert tl0.cylinder_vertices is not None - np.testing.assert_array_equal( - artifact.texture_layers[0].power_texture.to_numpy(), - tl0.power_texture.to_numpy(), - ) - - tl1 = loaded.texture_layers[1] - assert tl1.cylinder_vertices is None - - store.release(handle) - def test_overlay_layers(self): overlay = _make_overlay_layer() artifact = CompiledSceneArtifact( generation_id=7, vertex_layers=[], - texture_layers=[], overlay_layers=[overlay], ) store = ArtifactStore() @@ -168,7 +118,6 @@ def test_empty_artifact(self): artifact = CompiledSceneArtifact( generation_id=0, vertex_layers=[], - texture_layers=[], overlay_layers=[], ) store = ArtifactStore() @@ -176,7 +125,6 @@ def test_empty_artifact(self): assert loaded.generation_id == 0 assert len(loaded.vertex_layers) == 0 - assert len(loaded.texture_layers) == 0 assert len(loaded.overlay_layers) == 0 store.release(handle) @@ -193,7 +141,6 @@ def test_empty_vertex_layer(self): artifact = CompiledSceneArtifact( generation_id=0, vertex_layers=[vl], - texture_layers=[], overlay_layers=[], ) store = ArtifactStore() @@ -218,7 +165,6 @@ def test_large_arrays(self): artifact = CompiledSceneArtifact( generation_id=0, vertex_layers=[big_vl], - texture_layers=[], overlay_layers=[], ) store = ArtifactStore() @@ -235,7 +181,6 @@ def test_multiple_layer_types(self): artifact = CompiledSceneArtifact( generation_id=99, vertex_layers=[_make_vertex_layer()], - texture_layers=[_make_texture_layer()], overlay_layers=[_make_overlay_layer()], ) store = ArtifactStore() @@ -243,7 +188,6 @@ def test_multiple_layer_types(self): assert loaded.generation_id == 99 assert len(loaded.vertex_layers) == 1 - assert len(loaded.texture_layers) == 1 assert len(loaded.overlay_layers) == 1 store.release(handle) diff --git a/tests/simulator/test_render_config.py b/tests/simulator/test_render_config.py index b530c608b..5f7a4bea9 100644 --- a/tests/simulator/test_render_config.py +++ b/tests/simulator/test_render_config.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from raygeo.compressed_array import CompressedArray from rayforge.simulator.scene3d.render_config import ( LayerRenderConfig, @@ -95,26 +96,6 @@ def test_none_layer_configs(self): restored = RenderConfig3D.from_dict(config.to_dict()) assert restored.layer_configs is None - def test_laser_dot_widths_round_trip(self): - config = RenderConfig3D( - world_to_visual=np.eye(4, dtype=np.float32), - world_to_cyl_local=np.eye(4, dtype=np.float32), - laser_dot_widths_mm={"head1": 0.1, "head2": 0.3}, - ) - restored = RenderConfig3D.from_dict(config.to_dict()) - assert restored.laser_dot_widths_mm == { - "head1": 0.1, - "head2": 0.3, - } - - def test_laser_dot_widths_none_when_absent(self): - config = RenderConfig3D( - world_to_visual=np.eye(4, dtype=np.float32), - world_to_cyl_local=np.eye(4, dtype=np.float32), - ) - restored = RenderConfig3D.from_dict(config.to_dict()) - assert restored.laser_dot_widths_mm is None - def test_missing_field_raises(self): with pytest.raises(KeyError): RenderConfig3D.from_dict({"world_to_visual": b"\x00" * 64}) @@ -144,3 +125,31 @@ def test_defaults_has_z_axis_true(self): assert restored.has_z_axis is True assert restored.stock_top_z == 0.0 assert restored.stock_world_to_visual is None + + def test_stock_specs_burn_round_trip(self): + """The burn entry (with its CompressedArray) passes through + to_dict/from_dict unchanged — the config dict crosses the + compile-thread boundary in-process.""" + burn = { + "surface_map": CompressedArray.from_float32_2d( + np.full((4, 4), 255.0, dtype=np.float32) + ), + "origin_mm": (1.0, 2.0), + "px_per_mm": (10.0, 10.0), + "size_px": (100, 50), + } + config = RenderConfig3D( + world_to_visual=np.eye(4, dtype=np.float32), + world_to_cyl_local=np.eye(4, dtype=np.float32), + stock_specs=[{"name": "oak", "burn": burn}], + ) + restored = RenderConfig3D.from_dict(config.to_dict()) + assert restored.stock_specs is not None + spec = restored.stock_specs[0] + restored_burn = spec["burn"] + np.testing.assert_array_equal( + restored_burn["surface_map"].to_numpy(), + burn["surface_map"].to_numpy(), + ) + assert restored_burn["origin_mm"] == (1.0, 2.0) + assert restored_burn["size_px"] == (100, 50) diff --git a/tests/simulator/test_scene_compiler.py b/tests/simulator/test_scene_compiler.py index 8e86d3097..9c62054f1 100644 --- a/tests/simulator/test_scene_compiler.py +++ b/tests/simulator/test_scene_compiler.py @@ -172,47 +172,6 @@ def test_empty_job_markers_only(self): assert len(artifact.overlay_layers) == 0 -class TestTextureLineWidth: - """End-to-end: the generated texture scales with the laser dot width.""" - - def _scanline_ops(self, head_uid="head1"): - ops = Ops() - ops.job_start() - ops.layer_start("layer1") - ops.set_head(head_uid) - for y in range(0, 30, 5): - ops.move_to(0.0, y, 0.0) - ops.scan_to(20.0, y, 0.0, bytearray([255] * 20)) - ops.layer_end("layer1") - ops.job_end() - return ops - - def _compile(self, dot_width=None, head_uid="head1"): - config = make_test_config( - layer_configs={"layer1": make_flat_layer_config()}, - ) - if dot_width is not None: - config.laser_dot_widths_mm = {head_uid: dot_width} - return compile_scene(self._scanline_ops(head_uid), config) - - @staticmethod - def _interior_thickness(texture): - tex = np.asarray(texture) - col = np.argwhere(tex[:, 0].astype(bool)) - runs = np.split(col[:, 0], np.where(np.diff(col[:, 0]) > 1)[0] + 1) - return max(len(run) for run in runs) - - def test_texture_line_width_uses_default_without_config(self): - artifact = self._compile() - assert len(artifact.texture_layers) == 1 - assert ( - self._interior_thickness( - artifact.texture_layers[0].power_texture.to_numpy() - ) - > 1 - ) - - class TestCompileMultiLayer: def test_flat_and_rotary_separated(self): ops_flat = Ops() @@ -413,3 +372,6 @@ def test_multi_layer_cumulative_offsets(self): ov_pos = ol.positions.to_numpy().reshape(-1, 3) assert ov_pos.shape[0] == 4 + + +# ── Burn-linked LUT suppression ───────────────────────────────── diff --git a/tests/simulator/test_stock_compiler.py b/tests/simulator/test_stock_compiler.py index e51003337..834fa333a 100644 --- a/tests/simulator/test_stock_compiler.py +++ b/tests/simulator/test_stock_compiler.py @@ -10,9 +10,12 @@ import numpy as np import pytest +from raygeo.compressed_array import CompressedArray from rayforge.simulator.scene3d.compiled_scene import StockLayer -from rayforge.simulator.scene3d.stock_compiler import compile_stock_layers +from rayforge.simulator.scene3d.stock_compiler import ( + compile_stock_layers, +) RECT_OUTER = [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)] @@ -311,3 +314,136 @@ def test_compile_stock_layers_preserves_panel_transform( corner = layer.transform @ np.array([100.0, 50.0, 0.0, 1.0]) # ROTATED_RIGHT presents world (100, 50) at panel (50, 300). assert corner[:2] == pytest.approx([50.0, 300.0]) + + +# ── Burn surface map ──────────────────────────────────────────── + + +def _burn_entry( + origin_mm=(0.0, 0.0), + px_per_mm=(50.0, 50.0), + size_px=(100, 50), + fill=0, +): + w, h = size_px + pixels = np.full((h, w), fill, dtype=np.float32) + return { + "surface_map": CompressedArray.from_float32_2d(pixels), + "origin_mm": origin_mm, + "px_per_mm": px_per_mm, + "size_px": size_px, + } + + +def test_compile_stock_layers_burn_spec(): + spec = { + "name": "oak stock", + "thickness": 18.0, + "outers": [RECT_OUTER], + "holes": [], + "burn": _burn_entry(), + } + layers = compile_stock_layers([spec], _identity()) + assert len(layers) == 1 + layer = layers[0] + assert layer.power_texture is not None + assert layer.power_size_px == (100, 50) + # Grid: 100 px at 50 px/mm = 2 mm wide, 50 px = 1 mm tall. + assert layer.power_aabb == pytest.approx((0.0, 0.0, 2.0, 1.0)) + assert layer.power_uvs.shape == (layer.positions.shape[0], 2) + assert layer.power_uvs.dtype == np.float32 + # UVs are normalized into the grid; the stock (200x100 mm) is far + # larger than the 2x1 mm grid, so most UVs exceed 1 (clamped by + # GL_CLAMP_TO_EDGE at draw time). The (0, 0) corner maps to 0. + assert layer.power_uvs.min() == pytest.approx(0.0) + + +def test_compile_stock_layers_without_burn_has_no_power_data(): + spec = { + "name": "oak stock", + "thickness": 18.0, + "outers": [RECT_OUTER], + "holes": [], + } + layers = compile_stock_layers([spec], _identity()) + assert len(layers) == 1 + layer = layers[0] + assert layer.power_texture is None + assert layer.power_size_px is None + assert layer.power_aabb is None + assert layer.power_uvs.shape == (0, 2) + + +def test_compile_stock_layers_invalid_burn_ignored(): + spec = { + "name": "oak stock", + "thickness": 18.0, + "outers": [RECT_OUTER], + "holes": [], + "burn": {"surface_map": None}, + } + layers = compile_stock_layers([spec], _identity()) + assert len(layers) == 1 + assert layers[0].power_texture is None + + +def test_compile_rotary_stock_burn_spec(): + """A rotary spec with a burn entry compiles power data onto the + shell; the grid covers the unrolled axial x circumference domain.""" + diameter, length = 50.0, 200.0 + # Grid over the full unrolled domain at 10 px/mm. + size_px = ( + int(length * 10), + int(math.pi * diameter * 10), + ) + spec = _rotary_spec( + burn=_burn_entry( + origin_mm=(0.0, 0.0), + px_per_mm=(10.0, 10.0), + size_px=size_px, + ) + ) + layer = compile_stock_layers([spec], _identity())[0] + assert layer.is_rotary is True + assert layer.power_texture is not None + assert layer.power_size_px == size_px + assert layer.power_aabb is not None + assert layer.power_aabb[0] == pytest.approx(0.0) + assert layer.power_aabb[1] == pytest.approx(0.0) + assert layer.power_aabb[2] == pytest.approx(length) + assert layer.power_aabb[3] == pytest.approx(size_px[1] / 10.0) + puv = layer.power_uvs + assert puv.shape == (layer.positions.shape[0], 2) + assert puv.min() >= 0.0 and puv.max() <= 1.0 + + from rayforge.simulator.scene3d.stock_compiler import ( + CYLINDER_LENGTH_SEGMENTS, + CYLINDER_RINGS, + ) + + shell_verts = (CYLINDER_LENGTH_SEGMENTS + 1) * (CYLINDER_RINGS + 1) + pos = layer.positions.reshape(-1, 3) + # Axial ends map through the grid to u = 0 and u = 1. + shell_pos = pos[:shell_verts] + shell_puv = puv[:shell_verts] + at_start = shell_puv[np.isclose(shell_pos[:, 0], 0.0)] + at_end = shell_puv[np.isclose(shell_pos[:, 0], length)] + assert np.allclose(at_start[:, 0], 0.0) + assert np.allclose(at_end[:, 0], 1.0) + # The duplicated seam (at -z, theta = pi) maps to v = 0 and v = 1. + radius = 25.0 + on_seam = (shell_pos[:, 2] < -(radius - 1e-3)) & ( + np.abs(shell_pos[:, 1]) < 1e-3 + ) + vs = sorted(shell_puv[on_seam][:, 1]) + assert len(vs) > 0 + assert vs[0] == pytest.approx(0.0, abs=1e-6) + assert vs[-1] == pytest.approx(1.0) + + +def test_compile_rotary_stock_without_burn_has_no_power_data(): + layer = compile_stock_layers([_rotary_spec()], _identity())[0] + assert layer.power_texture is None + assert layer.power_size_px is None + assert layer.power_aabb is None + assert layer.power_uvs.shape == (0, 2) diff --git a/tests/ui_gtk/doceditor/test_material_ui.py b/tests/ui_gtk/doceditor/test_material_ui.py index 138fb2483..5b4cdd263 100644 --- a/tests/ui_gtk/doceditor/test_material_ui.py +++ b/tests/ui_gtk/doceditor/test_material_ui.py @@ -147,24 +147,15 @@ def test_edit_mode_prefills(self, tmp_path): assert data["texture"] == tmp_path / "wood.webp" assert dialog.texture_scale_row.get_sensitive() is True - def test_color_row_disabled_with_texture_when_not_tintable(self): - """Color is disabled with a texture unless the material is tintable.""" + def test_texture_scale_sensitivity_follows_texture(self): + """The texture scale row is only active when a texture is set.""" dialog = AddMaterialDialog() - dialog._texture_path = Path("/tmp/wood.webp") - dialog.tintable_row.set_active(False) - dialog._update_sensitivity() - - assert dialog.color_row.get_sensitive() is False - assert dialog.texture_scale_row.get_sensitive() is True + assert dialog.texture_scale_row.get_sensitive() is False - def test_color_row_active_with_texture_when_tintable(self): - """Tintable materials keep the color row usable with a texture.""" - dialog = AddMaterialDialog() dialog._texture_path = Path("/tmp/wood.webp") - dialog.tintable_row.set_active(True) dialog._update_sensitivity() - assert dialog.color_row.get_sensitive() is True + assert dialog.texture_scale_row.get_sensitive() is True def test_unset_color_reports_none(self): """Unsetting the color marks the material as not tinted.""" @@ -175,22 +166,14 @@ def test_unset_color_reports_none(self): assert dialog.get_color_hex() is None assert dialog.get_material_data()["color"] is None - def test_tintable_switch_round_trips(self): - """The tintable switch value is reported in the material data.""" - dialog = AddMaterialDialog() - dialog.tintable_row.set_active(True) - - assert dialog.get_material_data()["tintable"] is True - - def test_edit_mode_prefills_tintable_and_unset_color(self, tmp_path): - """Edit mode restores tintable and an unset ('not tinted') color.""" + def test_edit_mode_prefills_unset_color(self, tmp_path): + """Edit mode restores an unset ('not tinted') color.""" _write_webp(tmp_path / "wood.webp") material = Material( uid="tex_mat", name="ABS", appearance=MaterialAppearance( color=None, - tintable=True, texture="wood.webp", texture_size_mm=300, ), @@ -199,7 +182,4 @@ def test_edit_mode_prefills_tintable_and_unset_color(self, tmp_path): dialog = AddMaterialDialog(material=material) - assert dialog.tintable_row.get_active() is True assert dialog.get_material_data()["color"] is None - assert dialog.get_material_data()["tintable"] is True - assert dialog.color_row.get_sensitive() is True diff --git a/tests/ui_gtk/shared/test_color_lut_provider.py b/tests/ui_gtk/shared/test_color_lut_provider.py index afbb62384..3d4bd1d8a 100644 --- a/tests/ui_gtk/shared/test_color_lut_provider.py +++ b/tests/ui_gtk/shared/test_color_lut_provider.py @@ -124,7 +124,6 @@ def test_from_machine_filters_non_laser_heads(): head1 = LaserHead() head2 = LaserHead() head2.set_cut_color("#00ff00") - head2.set_raster_color("#00ff00") machine = MagicMock(heads=[head1, MagicMock(uid="spindle"), head2]) provider = ColorLutProvider.from_machine(machine, _theme_color_set()) diff --git a/tests/ui_gtk/shared/test_theme_service.py b/tests/ui_gtk/shared/test_theme_service.py index cd2f54acc..28cbb87a4 100644 --- a/tests/ui_gtk/shared/test_theme_service.py +++ b/tests/ui_gtk/shared/test_theme_service.py @@ -67,7 +67,6 @@ def test_set_machine_resolves_laser_sets(ui_context_initializer): service, widget = ThemeColorService(), MagicMock() laser = LaserHead() laser.set_cut_color("#ff0000") - laser.set_raster_color("#00ff00") machine = MagicMock(heads=[laser, MagicMock(uid="spindle")]) with pytest.MonkeyPatch.context() as mp: diff --git a/tests/ui_gtk/shared/test_visibility_overlay.py b/tests/ui_gtk/shared/test_visibility_overlay.py index 2e83bba3d..b7d69b290 100644 --- a/tests/ui_gtk/shared/test_visibility_overlay.py +++ b/tests/ui_gtk/shared/test_visibility_overlay.py @@ -21,6 +21,7 @@ def test_2d_overlay_buttons(ui_context_initializer): assert overlay.workpiece_button.get_action_name() == "win.show_workpieces" assert overlay.camera_button.get_action_name() == "win.toggle_camera_view" assert overlay.tabs_button.get_action_name() == "win.show_tabs" + assert overlay.stock_button is not None assert overlay.stock_button.get_action_name() == "win.show_stock" assert overlay.travel_button.get_action_name() == "win.toggle_travel_view" assert overlay.nogo_button.get_action_name() == "win.show_nogo_zones" @@ -33,15 +34,13 @@ def test_3d_overlay_buttons(ui_context_initializer): show_workpiece=False, show_models=True, show_grid=True, - show_ops_underlay=True, show_stock=True, show_workpiece_image=True, ) assert getattr(overlay, "workpiece_button", None) is None assert overlay.models_button.get_action_name() == "win.show_models" assert overlay.grid_button.get_action_name() == "win.show_grid" - assert overlay.underlay_button is not None - assert overlay.underlay_button.get_action_name() == "win.show_ops_underlay" + assert overlay.stock_button is not None assert overlay.stock_button.get_action_name() == "win.show_stock" assert overlay.workpiece_image_button is not None assert ( @@ -66,3 +65,14 @@ def test_set_nogo_visible(ui_context_initializer): assert not overlay.nogo_button.get_visible() overlay.set_nogo_visible(True) assert overlay.nogo_button.get_visible() + + +@pytest.mark.ui +def test_set_stock_present_toggles_button_visibility(ui_context_initializer): + overlay = VisibilityOverlay(show_stock=True) + assert overlay.stock_button is not None + assert overlay.stock_button.get_visible() + overlay.set_stock_present(False) + assert not overlay.stock_button.get_visible() + overlay.set_stock_present(True) + assert overlay.stock_button.get_visible() diff --git a/tests/ui_gtk/sim3d/renderer/test_ring_buffer_renderer.py b/tests/ui_gtk/sim3d/renderer/test_ring_buffer_renderer.py index 636d12f4f..452a673cc 100644 --- a/tests/ui_gtk/sim3d/renderer/test_ring_buffer_renderer.py +++ b/tests/ui_gtk/sim3d/renderer/test_ring_buffer_renderer.py @@ -76,6 +76,8 @@ def test_render_publishes_ring_exec_count(): shader = MagicMock() with ( + patch("OpenGL.GL.glEnable"), + patch("OpenGL.GL.glBlendFunc"), patch("OpenGL.GL.glBindVertexArray"), patch("OpenGL.GL.glBindTexture"), patch("OpenGL.GL.glActiveTexture"), @@ -102,6 +104,8 @@ def test_render_noop_when_empty(): shader = MagicMock() with ( + patch("OpenGL.GL.glEnable"), + patch("OpenGL.GL.glBlendFunc"), patch("OpenGL.GL.glBindVertexArray"), patch("OpenGL.GL.glBindTexture"), patch("OpenGL.GL.glActiveTexture"), diff --git a/tests/ui_gtk/sim3d/renderer/test_scene_renderer_init.py b/tests/ui_gtk/sim3d/renderer/test_scene_renderer_init.py index af3226d0d..bfa48b668 100644 --- a/tests/ui_gtk/sim3d/renderer/test_scene_renderer_init.py +++ b/tests/ui_gtk/sim3d/renderer/test_scene_renderer_init.py @@ -56,11 +56,9 @@ def test_scene_renderer_constructs_children(): assert scene.model_renderers == [] assert scene.had_rotary_layers is False assert scene.axis_renderer is None - assert scene.texture_renderer is None assert scene.zone_renderer is None assert scene.main_shader is None assert scene.text_shader is None - assert scene.texture_shader is None assert scene.background_shader is None assert scene.stock_shader is None assert scene.shader_set is None @@ -84,9 +82,6 @@ def test_scene_renderer_init_gl_creates_children(): patch( "rayforge.ui_gtk.sim3d.renderer.scene_renderer.TextShader" ) as mock_text, - patch( - "rayforge.ui_gtk.sim3d.renderer.scene_renderer.TextureShader" - ) as mock_texture, patch( "rayforge.ui_gtk.sim3d.renderer.scene_renderer.BackgroundShader" ) as mock_background, @@ -99,10 +94,6 @@ def test_scene_renderer_init_gl_creates_children(): patch( "rayforge.ui_gtk.sim3d.renderer.scene_renderer.AxisRenderer3D" ) as mock_axis, - patch( - "rayforge.ui_gtk.sim3d.renderer.scene_renderer." - "TextureArtifactRenderer" - ) as mock_tex, patch( "rayforge.ui_gtk.sim3d.renderer.scene_renderer.ZoneRenderer" ) as mock_zone, @@ -116,7 +107,6 @@ def test_scene_renderer_init_gl_creates_children(): scene.init_gl() assert scene.axis_renderer is mock_axis.return_value - assert scene.texture_renderer is mock_tex.return_value assert scene.zone_renderer is mock_zone.return_value assert scene.background_shader is mock_background.return_value assert scene.stock_shader is mock_stock.return_value @@ -124,16 +114,13 @@ def test_scene_renderer_init_gl_creates_children(): assert scene.shader_set.main is mock_simple.return_value assert scene.shader_set.main_lines is mock_line.return_value assert scene.shader_set.text is mock_text.return_value - assert scene.shader_set.texture is mock_texture.return_value assert scene.shader_set.background is mock_background.return_value assert scene.shader_set.stock is mock_stock.return_value assert scene.shader_set.image is mock_image.return_value mock_axis.return_value.init_gl.assert_called_once() - mock_tex.return_value.init_gl.assert_called_once() mock_zone.return_value.init_gl.assert_called_once() mock_simple.assert_called_once() mock_text.assert_called_once() - mock_texture.assert_called_once() mock_background.assert_called_once() mock_stock.assert_called_once() mock_image.assert_called_once() @@ -145,7 +132,6 @@ def test_scene_renderer_init_gl_creates_children(): scene.zone_renderer, scene.stock_renderer, scene.workpiece_image_renderer, - scene.texture_renderer, scene.laser_beam_renderer, ] @@ -201,7 +187,7 @@ def test_update_axis_from_viewport_swaps_child_registration(): assert new_axis in scene._owned_renderers -def test_render_registry_orders_rings_after_texture(): +def test_render_registry_orders_rings_after_ops(): scene = SceneRenderer() ops = MagicMock() @@ -210,7 +196,6 @@ def test_render_registry_orders_rings_after_texture(): scene.ring_renderers = [ring] scene.cylinder_renderers = {25.0: MagicMock()} scene.model_renderers = [MagicMock()] - scene.texture_renderer = MagicMock() scene._rebuild_registry() @@ -218,12 +203,8 @@ def test_render_registry_orders_rings_after_texture(): assert ring in renderers assert ops in renderers ring_index = renderers.index(ring) - texture_index = renderers.index(scene.texture_renderer) ops_index = renderers.index(ops) - # The ring trail always draws on top of the toolpath and the raster. - assert ring_index > texture_index - # The toolpath draws above the raster texture but below the trail. - assert ops_index > texture_index + # The ring trail always draws on top of the toolpath. assert ops_index < ring_index diff --git a/tests/ui_gtk/sim3d/renderer/test_stock_renderer.py b/tests/ui_gtk/sim3d/renderer/test_stock_renderer.py index d6d69eaec..0027ced67 100644 --- a/tests/ui_gtk/sim3d/renderer/test_stock_renderer.py +++ b/tests/ui_gtk/sim3d/renderer/test_stock_renderer.py @@ -213,7 +213,6 @@ def test_update_from_artifact(renderer): artifact = CompiledSceneArtifact( generation_id=1, vertex_layers=[], - texture_layers=[], overlay_layers=[], stock_layers=[_make_layer(), _make_layer()], ) diff --git a/tests/ui_gtk/sim3d/renderer/test_texture_renderer.py b/tests/ui_gtk/sim3d/renderer/test_texture_renderer.py deleted file mode 100644 index 2eeacac53..000000000 --- a/tests/ui_gtk/sim3d/renderer/test_texture_renderer.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Tests for the TextureArtifactRenderer class. -""" - -from unittest.mock import MagicMock, patch - -import numpy as np -import pytest -from OpenGL import GL - -from rayforge.core.color import ColorSet -from rayforge.pipeline.artifact.base import TextureData -from rayforge.ui_gtk.sim3d.gl_utils import ShaderSet -from rayforge.ui_gtk.sim3d.render_context import ( - CameraContext, - KinematicsContext, - RenderContext, -) -from rayforge.ui_gtk.sim3d.renderer.texture_renderer import ( - TextureArtifactRenderer, -) - - -def _make_ctx(): - return RenderContext( - camera=CameraContext(color_set=ColorSet()), - kinematics=KinematicsContext(mvp_ui=np.eye(4, dtype=np.float32)), - ) - - -def _init_renderer(renderer): - with ( - patch.object(renderer, "_create_vbo", return_value=1), - patch.object(renderer, "_create_vao", return_value=1), - patch.object(renderer, "_create_texture", return_value=1), - patch("OpenGL.GL.glBindBuffer"), - patch("OpenGL.GL.glBufferData"), - patch("OpenGL.GL.glBindVertexArray"), - patch("OpenGL.GL.glVertexAttribPointer"), - patch("OpenGL.GL.glEnableVertexAttribArray"), - patch("OpenGL.GL.glTexParameteri"), - patch("OpenGL.GL.glPixelStorei"), - patch("OpenGL.GL.glTexImage2D"), - patch("OpenGL.GL.glBindTexture"), - patch("OpenGL.GL.glGenTextures", side_effect=[1, 2]), - patch( - "OpenGL.GL.glGetIntegerv", - side_effect=lambda name, out: setattr(out, "value", 8192), - ), - ): - renderer.init_gl() - return renderer - - -@pytest.mark.ui -def test_build_mipmaps_max_reduces(): - """The power-map mip pyramid must down-sample with the 2x2 maximum - so scanline rows survive minification instead of aliasing away.""" - data = np.arange(25, dtype=np.uint8).reshape(5, 5) % 7 - mips = TextureArtifactRenderer._build_mipmaps(data) - # Level sizes follow the GL floor-halving rule (max(1, size // 2)), - # not ceil: an inconsistent chain would leave the texture incomplete - # and every lookup black. - assert [m.shape for m in mips] == [(5, 5), (2, 2), (1, 1)] - for level in range(1, len(mips)): - prev = mips[level - 1] - cur = mips[level] - for i in range(cur.shape[0]): - for j in range(cur.shape[1]): - block = prev[2 * i : 2 * i + 2, 2 * j : 2 * j + 2] - assert cur[i, j] == block.max() - - -@pytest.mark.ui -def test_render_writes_depth_for_texture_quad(): - """The texture must write depth across its whole quad (including - zero-power gaps) so occluders behind it cannot band the preview.""" - renderer = _init_renderer(TextureArtifactRenderer()) - renderer.prepare(_make_ctx()) - - power_texture = np.full((4, 4), 128, dtype=np.uint8) - with ( - patch("OpenGL.GL.glGenTextures", return_value=1), - patch("OpenGL.GL.glBindTexture"), - patch("OpenGL.GL.glTexParameteri"), - patch("OpenGL.GL.glPixelStorei"), - patch("OpenGL.GL.glTexImage2D"), - ): - renderer.add_instance( - TextureData( - power_texture_data=power_texture, - dimensions_mm=(10.0, 10.0), - position_mm=(0.0, 0.0), - ), - np.eye(4, dtype=np.float32), - ) - - shader = MagicMock() - with ( - patch("OpenGL.GL.glBindVertexArray"), - patch("OpenGL.GL.glEnable"), - patch("OpenGL.GL.glBlendFunc"), - patch("OpenGL.GL.glDepthMask") as mock_depth_mask, - patch("OpenGL.GL.glDepthFunc") as mock_depth_func, - patch("OpenGL.GL.glActiveTexture"), - patch("OpenGL.GL.glBindTexture"), - patch("OpenGL.GL.glDrawArrays"), - ): - renderer.render(_make_ctx(), ShaderSet(texture=shader)) - - assert GL.GL_TRUE in [c.args[0] for c in mock_depth_mask.call_args_list] - assert GL.GL_LEQUAL in [c.args[0] for c in mock_depth_func.call_args_list] - - -@pytest.mark.ui -def test_texture_coordinates_orientation(): - """ - Test that texture coordinates are set up correctly. - - The texture coordinates use OpenGL's standard convention with T=1.0 at - the bottom and T=0.0 at the top. The actual fix for Y displacement is - in the depth.py file where the texture data is filled with flipped Y - coordinates to match the flipped Y coordinates used in the operations. - """ - # Define the expected quad vertices (original orientation) - # fmt: off - expected_vertices = np.array( - [ - # Position (x, y, z) Texture Coords (s, t) - 0.0, 0.0, 0.0, 0.0, 1.0, # Bottom-left - 1.0, 0.0, 0.0, 1.0, 1.0, # Bottom-right - 1.0, 1.0, 0.0, 1.0, 0.0, # Top-right - 0.0, 1.0, 0.0, 0.0, 0.0, # Top-left - ], - dtype=np.float32, - ) - # fmt: on - - # Check texture coordinates (last 2 attributes of each vertex) - # Bottom-left vertex (index 0): should have T=1.0 - assert expected_vertices[4] == 1.0, ( - f"Bottom-left T coordinate should be 1.0, got {expected_vertices[4]}" - ) - - # Top-left vertex (index 3): should have T=0.0 - assert expected_vertices[19] == 0.0, ( - f"Top-left T coordinate should be 0.0, got {expected_vertices[19]}" - ) - - # Bottom-right vertex (index 1): should have T=1.0 - assert expected_vertices[9] == 1.0, ( - f"Bottom-right T coordinate should be 1.0, got {expected_vertices[9]}" - ) - - # Top-right vertex (index 2): should have T=0.0 - assert expected_vertices[14] == 0.0, ( - f"Top-right T coordinate should be 0.0, got {expected_vertices[14]}" - ) - - -@pytest.mark.ui -def test_add_instance_with_different_sizes(): - """ - Test that texture instances are added correctly with different transforms. - - This test verifies that the renderer correctly stores the pre-computed - world transformation matrix passed to it. The renderer itself does not - perform scaling based on artifact dimensions; it relies on the caller - to provide the final model matrix for the unit quad. - """ - # Create a dummy 1x1 power texture, its content doesn't matter for - # this test - power_texture = np.zeros((1, 1), dtype=np.uint8) - - # Create a renderer instance - renderer = TextureArtifactRenderer() - - # Mock all OpenGL calls - with ( - patch.object(renderer, "_create_vbo", return_value=1), - patch.object(renderer, "_create_vao", return_value=1), - patch.object(renderer, "_create_texture", return_value=1), - patch("OpenGL.GL.glBindBuffer"), - patch("OpenGL.GL.glBufferData"), - patch("OpenGL.GL.glBindVertexArray"), - patch("OpenGL.GL.glVertexAttribPointer"), - patch("OpenGL.GL.glEnableVertexAttribArray"), - patch("OpenGL.GL.glTexParameteri"), - patch("OpenGL.GL.glPixelStorei"), - patch("OpenGL.GL.glTexImage2D"), - patch("OpenGL.GL.glBindTexture"), - patch("OpenGL.GL.glGenTextures", side_effect=[1, 2]), - patch( - "OpenGL.GL.glGetIntegerv", - side_effect=lambda name, out: setattr(out, "value", 8192), - ), - ): - # Initialize the renderer - renderer.init_gl() - - # --- Test Case 1: A 100x100mm artifact at (10, 20) --- - texture_data_1 = TextureData( - power_texture_data=power_texture, - dimensions_mm=(100.0, 100.0), - position_mm=(10.0, 20.0), - ) - # The caller is responsible for creating this matrix (T * S). - scale_mat1 = np.diag([100.0, 100.0, 1.0, 1.0]) - translate_mat1 = np.identity(4) - translate_mat1[:3, 3] = [10.0, 20.0, 0.0] - world_transform_1 = (translate_mat1 @ scale_mat1).astype(np.float32) - - renderer.add_instance(texture_data_1, world_transform_1) - - # Check that an instance was added - assert len(renderer.instances) == 1 - # Check that the stored model matrix is the one we passed in - stored_matrix_1 = renderer.instances[0]["model_matrix"] - assert np.allclose(stored_matrix_1, world_transform_1), ( - "Stored matrix does not match provided matrix for instance 1." - ) - - # --- Test Case 2: A 200x50mm artifact at (0, 0) --- - texture_data_2 = TextureData( - power_texture_data=power_texture, - dimensions_mm=(200.0, 50.0), - position_mm=(0.0, 0.0), - ) - # Create the corresponding world transform matrix (just scaling). - world_transform_2 = np.diag([200.0, 50.0, 1.0, 1.0]).astype(np.float32) - - renderer.add_instance(texture_data_2, world_transform_2) - - # Check that a second instance was added - assert len(renderer.instances) == 2 - # Check the model matrix for the large artifact - stored_matrix_2 = renderer.instances[1]["model_matrix"] - assert np.allclose(stored_matrix_2, world_transform_2), ( - "Stored matrix does not match provided matrix for instance 2." - ) diff --git a/tests/ui_gtk/sim3d/renderer/test_texture_renderer_update_from_artifact.py b/tests/ui_gtk/sim3d/renderer/test_texture_renderer_update_from_artifact.py deleted file mode 100644 index 955c46e58..000000000 --- a/tests/ui_gtk/sim3d/renderer/test_texture_renderer_update_from_artifact.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tests for TextureArtifactRenderer texture-layer ingestion.""" - -from unittest.mock import patch - -import numpy as np -import pytest -from raygeo.compressed_array import CompressedArray - -from rayforge.simulator.scene3d import CompiledSceneArtifact, TextureLayer -from rayforge.ui_gtk.sim3d.renderer.texture_renderer import ( - TextureArtifactRenderer, -) - - -@pytest.fixture -def renderer(): - r = TextureArtifactRenderer() - with ( - patch.object(r, "_create_vbo", return_value=1), - patch.object(r, "_create_vao", return_value=1), - patch.object(r, "_create_texture", return_value=1), - patch("OpenGL.GL.glBindBuffer"), - patch("OpenGL.GL.glBufferData"), - patch("OpenGL.GL.glBindVertexArray"), - patch("OpenGL.GL.glVertexAttribPointer"), - patch("OpenGL.GL.glEnableVertexAttribArray"), - patch("OpenGL.GL.glTexParameteri"), - patch("OpenGL.GL.glPixelStorei"), - patch("OpenGL.GL.glTexImage2D"), - patch("OpenGL.GL.glBindTexture"), - patch("OpenGL.GL.glDeleteTextures"), - patch("OpenGL.GL.glGenTextures", return_value=1), - patch( - "OpenGL.GL.glGetIntegerv", - side_effect=lambda name, out: setattr(out, "value", 8192), - ), - ): - r.init_gl() - yield r - - -def _make_texture_layer(laser_uid=""): - return TextureLayer( - power_texture=CompressedArray.from_uint8_2d( - np.zeros((2, 2), dtype=np.uint8) - ), - width_px=2, - height_px=2, - model_matrix=np.eye(4, dtype=np.float32), - cylinder_vertices=None, - rotary_diameter=0.0, - rotary_enabled=False, - activation_cmd_idx=-1, - laser_uid=laser_uid, - ) - - -def _make_artifact(texture_layers, laser_uid_order=None): - return CompiledSceneArtifact( - generation_id=1, - vertex_layers=[], - texture_layers=texture_layers, - overlay_layers=[], - laser_uid_order=laser_uid_order, - ) - - -@pytest.mark.ui -def test_add_instance_from_texture_layer_laser_index(renderer): - tl = _make_texture_layer(laser_uid="L1") - renderer.add_instance_from_texture_layer(tl, ["L0", "L1", "L2"]) - - assert len(renderer.instances) == 1 - assert renderer.instances[0]["laser_index"] == 1 - - -@pytest.mark.ui -def test_add_instance_from_texture_layer_missing_laser_uid(renderer): - renderer.add_instance_from_texture_layer( - _make_texture_layer(laser_uid=""), ["L0", "L1"] - ) - assert renderer.instances[0]["laser_index"] == 0 - - renderer.add_instance_from_texture_layer( - _make_texture_layer(laser_uid="L9"), ["L0", "L1"] - ) - assert renderer.instances[1]["laser_index"] == 0 - - -@pytest.mark.ui -def test_add_instance_from_texture_layer_rotary_flag(renderer): - tl = _make_texture_layer() - tl.rotary_enabled = True - tl.rotary_diameter = 30.0 - tl.cylinder_vertices = np.zeros((5, 5), dtype=np.float32) - renderer.add_instance_from_texture_layer(tl) - - inst = renderer.instances[0] - assert inst["rotary_enabled"] is True - assert inst["rotary_diameter"] == 30.0 - assert inst["cylinder_vertices"] is tl.cylinder_vertices - - -@pytest.mark.ui -def test_update_from_artifact_clears_and_uploads(renderer): - tl_a = _make_texture_layer(laser_uid="A") - tl_b = _make_texture_layer(laser_uid="B") - renderer.add_instance_from_texture_layer(tl_a, ["A"]) - assert len(renderer.instances) == 1 - - renderer.update_from_artifact(_make_artifact([tl_a, tl_b], ["A", "B"])) - - assert len(renderer.instances) == 2 - assert renderer.instances[0]["laser_index"] == 0 - assert renderer.instances[1]["laser_index"] == 1 diff --git a/tests/ui_gtk/sim3d/test_matrix_convention.py b/tests/ui_gtk/sim3d/test_matrix_convention.py index bca47f595..ab4c79c33 100644 --- a/tests/ui_gtk/sim3d/test_matrix_convention.py +++ b/tests/ui_gtk/sim3d/test_matrix_convention.py @@ -21,9 +21,6 @@ ViewportContext, ) from rayforge.ui_gtk.sim3d.renderer.plane_renderer import PlaneRenderer -from rayforge.ui_gtk.sim3d.renderer.texture_renderer import ( - TextureArtifactRenderer, -) from rayforge.ui_gtk.sim3d.shader.base import Shader @@ -133,24 +130,3 @@ def test_plane_renderer_passes_row_major_mvp(): name, mat = shader.set_mat4.call_args.args assert name == "uMVP" np.testing.assert_allclose(mat, mvp_ui @ model_matrix) - - -@pytest.mark.ui -def test_texture_renderer_cylinder_mvp_kept_row_major(): - """The cylinder MVP is stored row-major with no double transpose.""" - renderer = TextureArtifactRenderer() - cyl_mvp = np.array( - [ - [1.0, 2.0, 3.0, 4.0], - [5.0, 6.0, 7.0, 8.0], - [9.0, 10.0, 11.0, 12.0], - [13.0, 14.0, 15.0, 16.0], - ], - dtype=np.float32, - ) - ctx = _make_ctx(cyl_mesh_mvp=cyl_mvp) - - renderer.prepare(ctx) - - assert renderer._cyl_mvp is cyl_mvp - assert renderer._flat_mvp is ctx.camera.mvp_ui diff --git a/tests/ui_gtk/sim3d/test_picking.py b/tests/ui_gtk/sim3d/test_picking.py index 39c6ee77a..c1645d6db 100644 --- a/tests/ui_gtk/sim3d/test_picking.py +++ b/tests/ui_gtk/sim3d/test_picking.py @@ -6,7 +6,6 @@ from rayforge.simulator.scene3d import ( PickContext, StockLayer, - TextureLayer, WorkpieceImage, ) from rayforge.simulator.scene3d.picking import ( @@ -246,29 +245,6 @@ def pick_mesh(self, ctx: PickContext) -> PickMesh | None: assert point[2] == pytest.approx(5.0, abs=1e-5) -def test_texture_layer_pick_mesh_maps_quad(): - from raygeo.compressed_array import CompressedArray - - model_matrix = np.eye(4, dtype=np.float32) - model_matrix[0, 0] = 100.0 - model_matrix[1, 1] = 100.0 - model_matrix[2, 3] = 12.0 - layer = TextureLayer( - power_texture=CompressedArray.from_uint8_2d( - np.zeros((2, 2), dtype=np.uint8) - ), - width_px=100, - height_px=100, - model_matrix=model_matrix, - ) - - scene = build_pick_scene([layer], PickContext()) - assert scene is not None - point = pick_point(scene, _camera(), 320, 240) - assert point is not None - assert point[2] == pytest.approx(12.0, abs=1e-4) - - def test_workpiece_image_pick_mesh_maps_quad(): model_matrix = np.eye(4, dtype=np.float32) model_matrix[0, 0] = 100.0 diff --git a/tests/ui_gtk/sim3d/test_render_context_update.py b/tests/ui_gtk/sim3d/test_render_context_update.py index db8bb72b1..0f48c7823 100644 --- a/tests/ui_gtk/sim3d/test_render_context_update.py +++ b/tests/ui_gtk/sim3d/test_render_context_update.py @@ -45,7 +45,6 @@ def _update( show_grid=True, show_nogo_zones=True, show_models=True, - show_ops_underlay=True, show_stock=True, show_workpiece_image=True, ): @@ -54,7 +53,6 @@ def _update( show_grid=show_grid, show_nogo_zones=show_nogo_zones, show_models=show_models, - show_ops_underlay=show_ops_underlay, show_stock=show_stock, show_workpiece_image=show_workpiece_image, ) @@ -241,7 +239,6 @@ def test_update_propagates_show_toggles(): show_grid=False, show_nogo_zones=False, show_models=False, - show_ops_underlay=False, show_stock=False, show_workpiece_image=False, ) @@ -249,7 +246,6 @@ def test_update_propagates_show_toggles(): assert ctx.visibility.show_grid is False assert ctx.visibility.show_nogo_zones is False assert ctx.visibility.show_models is False - assert ctx.visibility.show_ops_underlay is False assert ctx.visibility.show_stock is False assert ctx.visibility.show_workpiece_image is False diff --git a/tests/ui_gtk/sim3d/test_scene_presenter.py b/tests/ui_gtk/sim3d/test_scene_presenter.py index 62e3fccf2..a0c7cabff 100644 --- a/tests/ui_gtk/sim3d/test_scene_presenter.py +++ b/tests/ui_gtk/sim3d/test_scene_presenter.py @@ -11,13 +11,26 @@ import numpy as np import pytest from blinker import Signal +from raygeo.compressed_array import CompressedArray +from raygeo.geo import Geometry, Matrix from raygeo.ops.axis import Axis +from raygeo.ops.material import RasterEffect +from raygeo.ops.material.fold import fold_effects +from raygeo.ops.material.spec import ( + FoldEntry, + MaterialFoldSpec, + PrismaticStock, +) +from rayforge.core.doc import Doc from rayforge.core.layer import Layer +from rayforge.core.material import Material +from rayforge.core.stock import StockItem +from rayforge.core.stock_asset import StockAsset from rayforge.core.workpiece import WorkPiece -from rayforge.machine.models.laser import LaserHead from rayforge.machine.models.machine import Machine from rayforge.machine.models.rotary_module import RotaryMode, RotaryModule +from rayforge.pipeline.artifact.material_state import MaterialStateArtifact from rayforge.simulator.scene3d import ( CompiledSceneArtifact, compile_stock_scene, @@ -127,56 +140,6 @@ def test_update_scene_from_doc_schedules_compilation(ui_context_initializer): assert "world_to_cyl_local" in config -@pytest.mark.ui -def test_update_scene_from_doc_populates_laser_dot_widths( - ui_context_initializer, -): - """Laser head spot sizes reach the render config so the 3D raster - preview can draw scanlines at the physical dot width.""" - head1 = LaserHead() - head1.spot_size_mm = (0.1, 0.2) - head2 = LaserHead() - head2.spot_size_mm = (0.3, 0.4) - - machine = MagicMock() - machine.heads = [head1, head2] - machine.assembly = MagicMock() - machine.assembly.has_rotary = False - machine.get_default_rotary_module.return_value = None - - doc_editor = MagicMock() - doc_editor.doc.layers = [] - presenter, _, _ = _make_presenter( - context=MagicMock(machine=machine), - doc_editor=doc_editor, - get_viewport=lambda: ViewportConfig.default(), - ) - presenter._schedule_scene_preparation = MagicMock() - - presenter.update_scene_from_doc() - - config = presenter._schedule_scene_preparation.call_args.args[0] - assert config["laser_dot_widths_mm"] == { - head1.uid: 0.1, - head2.uid: 0.3, - } - - -@pytest.mark.ui -def test_update_scene_from_doc_dot_widths_empty_without_machine( - ui_context_initializer, -): - doc_editor = MagicMock() - doc_editor.doc.layers = [] - presenter, _, _ = _make_presenter(doc_editor=doc_editor) - presenter._schedule_scene_preparation = MagicMock() - - presenter.update_scene_from_doc() - - config = presenter._schedule_scene_preparation.call_args.args[0] - assert config.get("laser_dot_widths_mm") is None - - @pytest.mark.ui def test_on_scene_prepared_ignores_cancelled_task(ui_context_initializer): presenter, _, calls = _make_presenter() @@ -404,6 +367,9 @@ def test_connect_and_disconnect_subscribe_pipeline(ui_context_initializer): pipeline.job_generation_finished.connect.assert_called_once_with( presenter._on_job_generation_finished ) + pipeline.material_state_ready.connect.assert_called_once_with( + presenter._on_material_state_ready + ) presenter.disconnect() pipeline.processing_state_changed.disconnect.assert_called_once_with( @@ -412,6 +378,9 @@ def test_connect_and_disconnect_subscribe_pipeline(ui_context_initializer): pipeline.job_generation_finished.disconnect.assert_called_once_with( presenter._on_job_generation_finished ) + pipeline.material_state_ready.disconnect.assert_called_once_with( + presenter._on_material_state_ready + ) @pytest.mark.ui @@ -699,3 +668,285 @@ def test_on_workpiece_images_ready_ignores_stale_generation( scene.workpiece_image_renderer.set_images.assert_not_called() assert calls["rendered"] == [] + + +# ── LUT overlay toggle ────────────────────────────────────────── + + +# ── Folded material states ────────────────────────────────────── + + +def _burned_state(): + """A real MaterialState with a raster-burn surface map.""" + effect = RasterEffect( + np.full((10, 10), 255.0, dtype=np.float32), + origin_mm=(0.0, 0.0), + px_per_mm=(10.0, 10.0), + ) + spec = MaterialFoldSpec( + stock=PrismaticStock( + polygons=[ + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)] + ], + thickness=3.0, + ), + entries=[FoldEntry("w1", Matrix.identity(), [effect])], + ) + return fold_effects(spec) + + +def _empty_state(): + spec = MaterialFoldSpec( + stock=PrismaticStock( + polygons=[ + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)] + ], + thickness=3.0, + ), + entries=[], + ) + return fold_effects(spec) + + +@pytest.mark.ui +def test_on_material_state_ready_stores_burn(ui_context_initializer): + presenter, defaults, _ = _make_presenter() + presenter.update_scene_from_doc = MagicMock() + + state = _burned_state() + assert state.surface_map is not None + assert state.grid is not None + defaults[ + "context" + ].artifact_store.get.return_value = MaterialStateArtifact( + material_state=state, stock_uid="s1", generation_id=1 + ) + stock_item = MagicMock() + stock_item.uid = "s1" + + presenter._on_material_state_ready( + None, item=stock_item, handle=MagicMock(), generation_id=1 + ) + + burn = presenter._material_states.get("s1") + assert burn is not None + np.testing.assert_array_equal( + burn["surface_map"].to_numpy(), state.surface_map.to_numpy() + ) + assert burn["origin_mm"] == tuple(state.grid.origin_mm) + assert burn["px_per_mm"] == tuple(state.grid.px_per_mm) + assert burn["size_px"] == tuple(state.grid.size_px) + presenter.update_scene_from_doc.assert_called_once() + + +@pytest.mark.ui +def test_on_material_state_ready_drops_empty_state(ui_context_initializer): + presenter, defaults, _ = _make_presenter() + presenter.update_scene_from_doc = MagicMock() + + defaults[ + "context" + ].artifact_store.get.return_value = MaterialStateArtifact( + material_state=_empty_state(), + stock_uid="s1", + generation_id=1, + ) + stock_item = MagicMock() + stock_item.uid = "s1" + + presenter._on_material_state_ready( + None, item=stock_item, handle=MagicMock(), generation_id=1 + ) + + assert "s1" not in presenter._material_states + + +@pytest.mark.ui +def test_on_material_state_ready_ignores_other_artifacts( + ui_context_initializer, +): + presenter, defaults, _ = _make_presenter() + presenter.update_scene_from_doc = MagicMock() + + defaults["context"].artifact_store.get.return_value = MagicMock() + + stock_item = MagicMock() + stock_item.uid = "s1" + + presenter._on_material_state_ready( + None, item=stock_item, handle=MagicMock(), generation_id=1 + ) + + assert presenter._material_states == {} + presenter.update_scene_from_doc.assert_not_called() + + +# ── Stock-top content lift ─────────────────────────────────────── + + +def _machine_with(z_axis: bool): + machine = MagicMock() + machine.has_z_axis = z_axis + machine.heads = [] + machine.assembly = MagicMock() + machine.assembly.has_rotary = False + machine.get_default_rotary_module.return_value = None + return machine + + +def _doc_with_stock(thickness): + doc = Doc() + asset = StockAsset(name="sheet") + asset.set_thickness(thickness) + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(100, 0) + geo.line_to(100, 80) + geo.line_to(0, 80) + geo.close_path() + asset.geometry = geo + doc.add_asset(asset) + doc.add_child(StockItem(stock_asset_uid=asset.uid, name="sheet")) + return doc + + +def _content_z(render_config_dict) -> float: + blob = render_config_dict["world_to_visual"] + w2v = np.frombuffer(blob, dtype=np.float32).reshape(4, 4) + return float(w2v[2, 3]) + + +@pytest.mark.ui +def test_content_not_lifted_for_z_machine(ui_context_initializer): + """Has-Z machines render content at its authored Z (plus WCS Z): + no stock-top lift.""" + doc_editor = MagicMock() + doc_editor.doc = _doc_with_stock(thickness=4.0) + presenter, _, _ = _make_presenter( + context=MagicMock(machine=_machine_with(z_axis=True)), + doc_editor=doc_editor, + get_viewport=lambda: ViewportConfig.default(), + ) + presenter._schedule_scene_preparation = MagicMock() + + presenter.update_scene_from_doc() + + config = presenter._schedule_scene_preparation.call_args.args[0] + assert _content_z(config) == pytest.approx(0.0) + + +@pytest.mark.ui +def test_content_lifted_to_stock_top_for_no_z_machine(ui_context_initializer): + doc_editor = MagicMock() + doc_editor.doc = _doc_with_stock(thickness=4.0) + presenter, _, _ = _make_presenter( + context=MagicMock(machine=_machine_with(z_axis=False)), + doc_editor=doc_editor, + get_viewport=lambda: ViewportConfig.default(), + ) + presenter._schedule_scene_preparation = MagicMock() + + presenter.update_scene_from_doc() + + config = presenter._schedule_scene_preparation.call_args.args[0] + assert _content_z(config) == pytest.approx(4.0) + + +@pytest.mark.ui +def test_content_not_lifted_without_stock(ui_context_initializer): + doc_editor = MagicMock() + doc_editor.doc = Doc() + presenter, _, _ = _make_presenter( + context=MagicMock(machine=_machine_with(z_axis=True)), + doc_editor=doc_editor, + get_viewport=lambda: ViewportConfig.default(), + ) + presenter._schedule_scene_preparation = MagicMock() + + presenter.update_scene_from_doc() + + config = presenter._schedule_scene_preparation.call_args.args[0] + assert _content_z(config) == pytest.approx(0.0) + + +# ── Rotary burn specs ──────────────────────────────────────────── + + +def _rotary_doc_editor(layer): + doc_editor = MagicMock() + doc = MagicMock() + doc.stock_items = [] + doc.layers = [layer] + doc_editor.doc = doc + return doc_editor + + +@pytest.mark.ui +def test_build_rotary_stock_specs_attaches_burn(ui_context_initializer): + """A rotary layer whose uid has a stored material state gets a + burn entry in its spec.""" + layer = Layer(name="rot") + layer.set_rotary_enabled(True) + layer.set_rotary_diameter(50.0) + material = Material(uid="m1", name="cherry") + doc_editor = _rotary_doc_editor(layer) + doc_editor.doc.get_asset_by_uid.return_value = None + + machine = MagicMock() + rm = RotaryModule() + rm.max_workpiece_length = 300.0 + machine.get_default_rotary_module.return_value = rm + + with patch.object(Layer, "stock_material", material): + presenter, _, _ = _make_presenter(doc_editor=doc_editor) + viewport = MagicMock() + viewport.width_mm = 400.0 + presenter._material_states[layer.uid] = { + "handle_key": "h1", + "surface_map": CompressedArray.from_float32_2d( + np.zeros((4, 4), dtype=np.float32) + ), + "origin_mm": (0.0, 0.0), + "px_per_mm": (10.0, 10.0), + "size_px": (4, 4), + } + specs = presenter._build_rotary_stock_specs(viewport, machine) + + assert len(specs) == 1 + assert specs[0]["kind"] == "rotary" + assert "burn" in specs[0] + assert specs[0]["burn"]["size_px"] == (4, 4) + + +@pytest.mark.ui +def test_refresh_material_states_includes_rotary_layers( + ui_context_initializer, +): + """Handles keyed by a rotary layer's uid are picked up on + refresh, not just flat stock item handles.""" + layer = Layer(name="rot") + layer.set_rotary_enabled(True) + layer.set_rotary_diameter(50.0) + + state = _burned_state() + handle = MagicMock() + pipeline = MagicMock() + pipeline._material_state_handles = {layer.uid: handle} + + doc_editor = _rotary_doc_editor(layer) + doc_editor.pipeline = pipeline + + material = Material(uid="m1", name="cherry") + with patch.object(Layer, "stock_material", material): + presenter, defaults, _ = _make_presenter(doc_editor=doc_editor) + defaults[ + "context" + ].artifact_store.get.return_value = MaterialStateArtifact( + material_state=state, + stock_uid=layer.uid, + generation_id=1, + ) + + changed = presenter._refresh_material_states() + assert changed is True + assert layer.uid in presenter._material_states diff --git a/tests/ui_gtk/sim3d/test_theme_resolver.py b/tests/ui_gtk/sim3d/test_theme_resolver.py index 82a8cda7d..96646f747 100644 --- a/tests/ui_gtk/sim3d/test_theme_resolver.py +++ b/tests/ui_gtk/sim3d/test_theme_resolver.py @@ -42,7 +42,7 @@ def _make_resolver(service, gl_initialized=True): widget.get_style_context.return_value = MagicMock() scene = MagicMock() scene.axis_renderer = MagicMock() - scene.texture_renderer = MagicMock() + scene.stock_renderer = MagicMock() rendered = [] resolver = ThemeResolver( diff --git a/website/docs/application-settings/materials.md b/website/docs/application-settings/materials.md index e12da8a86..dee471bfe 100644 --- a/website/docs/application-settings/materials.md +++ b/website/docs/application-settings/materials.md @@ -70,17 +70,13 @@ values repeat the texture more often across the same surface. #### Color -The base color of the material. When a texture is set and the material is -tintable, the color tints the texture instead. The color is only used for -visual appearance on the work surface - it does not affect the laser path -in any way. - -#### Tintable - -When enabled, the material's texture can be tinted with the color above. -This lets a single textured material (e.g. "Acrylic") cover multiple color -variants: the color is applied per stock item in the [Stock -Properties](../features/stock-handling.md) dialog. +An optional tint color. When set, the material's texture is tinted with +this color; when unset, the texture is shown as-is. This lets a single +textured material (e.g. "Acrylic") cover multiple color variants: the +color is applied per stock item in the [Stock +Properties](../features/stock-handling.md) dialog. The color is only +used for visual appearance on the work surface - it does not affect the +laser path in any way. #### Roughness diff --git a/website/docs/features/stock-handling.md b/website/docs/features/stock-handling.md index 3e8ca924c..6955bcb36 100644 --- a/website/docs/features/stock-handling.md +++ b/website/docs/features/stock-handling.md @@ -51,14 +51,13 @@ Materials define the visual properties of your stock: - **Description**: Additional information about the material See [Materials](../application-settings/materials.md) for the full list of -appearance properties (texture, roughness, metallic, and tinting). +appearance properties (texture, roughness, metallic, and tint color). Note: Material properties are defined in material libraries and cannot be edited through the stock properties dialog. The stock properties only allow you to assign a material to a stock item. ### Per-Instance Stock Color -For materials that are defined as **tintable** (e.g. colored acrylic), -each stock item can override the material's tint color independently: +Each stock item can override the material's tint color independently: 1. Open the **Stock Properties** dialog for the stock item 2. Click the color button in the **Color** row @@ -66,9 +65,8 @@ each stock item can override the material's tint color independently: The color is applied only to that stock item — other items using the same material keep their own colors. Use the clear button next to the color -row to revert to the material's default color. For materials that are not -tintable, the color row is disabled and the appearance comes from the -material definition. +row to revert to the material's default color. When neither the item nor +the material defines a color, the texture is shown untinted. ## Converting Workpieces to Stock diff --git a/website/docs/machine/laser.md b/website/docs/machine/laser.md index 81174f57a..02cd8eb62 100644 --- a/website/docs/machine/laser.md +++ b/website/docs/machine/laser.md @@ -92,25 +92,16 @@ To measure your spot size: 3. Use the average of multiple measurements ::: -#### Cut Color +#### Color -The color used to display cutting operations for this laser in the canvas and 3D -preview. This helps you visually distinguish which laser will perform each -cutting operation when working with multiple laser heads. +The color used to display this laser's operations (cuts and engraving) in the +canvas and 3D preview. This helps you visually distinguish which laser will +perform each operation when working with multiple laser heads. - Click the color swatch to open a color picker - Choose a color that contrasts well with your material preview - Default colors are assigned automatically -#### Raster Color - -The color used to display raster/engraving operations for this laser in the -canvas and 3D preview. - -- Click the color swatch to open a color picker -- Useful for differentiating raster operations from cuts -- Each laser can have its own distinct raster color - :::tip Multi-Laser Workflows When using multiple laser heads, assigning different colors to each laser makes it easy to see which operations will be performed by which laser. For example, diff --git a/website/docs/ui/3d-preview.md b/website/docs/ui/3d-preview.md index 5847b5eb7..a80b67b70 100644 --- a/website/docs/ui/3d-preview.md +++ b/website/docs/ui/3d-preview.md @@ -96,7 +96,7 @@ Customize what you see: :::tip Per-Laser Colors When using machines with multiple laser heads, each laser can have its own -cut and raster colors configured in [Laser Settings](../machine/laser.md). +color configured in [Laser Settings](../machine/laser.md). This makes it easy to identify which laser will perform each operation. ::: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/de/docusaurus-plugin-content-docs/current/application-settings/materials.md index 8fb90b36c..17027a7d4 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -64,18 +64,13 @@ Kleinere Werte wiederholen die Textur häufiger auf derselben Fläche. #### Farbe -Die Grundfarbe des Materials. Wenn eine Textur festgelegt ist und das -Material einfärbbar ist, färbt die Farbe die Textur. Die Farbe wird nur -für das visuelle Erscheinungsbild auf der Arbeitsfläche verwendet - sie -beeinflusst den Laserpfad in keiner Weise. - -#### Einfärbbar - -Wenn aktiviert, kann die Textur des Materials mit der obigen Farbe -eingefärbt werden. Dadurch kann ein einzelnes texturiertes Material +Eine optionale Tönungsfarbe. Wenn sie festgelegt ist, wird die Textur +des Materials mit dieser Farbe eingefärbt; andernfalls wird die Textur +unverändert angezeigt. Dadurch kann ein einzelnes texturiertes Material (z.B. "Acryl") mehrere Farbvarianten abdecken: Die Farbe wird pro Materialstück im Dialog [Materialeigenschaften](../features/stock-handling.md) -angewendet. +angewendet. Die Farbe wird nur für das visuelle Erscheinungsbild auf der +Arbeitsfläche verwendet - sie beeinflusst den Laserpfad in keiner Weise. #### Rauheit diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/stock-handling.md index 3dbbc2dcc..5c09c0819 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -49,15 +49,14 @@ Materialien definieren die visuellen Eigenschaften deines Rohmaterials: Siehe [Materialien](../application-settings/materials.md) für die vollständige Liste der Erscheinungsbild-Eigenschaften (Textur, Rauheit, Metallisch und -Einfärbbarkeit). +Tönfarbe). Hinweis: Materialeigenschaften sind in Materialbibliotheken definiert und können nicht durch den Rohmaterial-Eigenschaften-Dialog bearbeitet werden. Die Rohmaterial-Eigenschaften ermöglichen es dir nur, einem Rohmaterial-Element ein Material zuzuweisen. ### Farbe pro Rohmaterial-Element -Für Materialien, die als **einfärbbar** definiert sind (z.B. farbiges -Acryl), kann jedes Rohmaterial-Element die Tönfarbe des Materials -unabhängig überschreiben: +Jedes Rohmaterial-Element kann die Tönfarbe des Materials unabhängig +überschreiben: 1. Öffne den **Rohmaterial-Eigenschaften**-Dialog für das Rohmaterial-Element 2. Klicke auf die Farbtaste in der Zeile **Farbe** @@ -66,9 +65,8 @@ unabhängig überschreiben: Die Farbe wird nur auf dieses Rohmaterial-Element angewendet — andere Elemente mit demselben Material behalten ihre eigenen Farben. Verwende die Löschen-Taste neben der Farbzeile, um zur Standardfarbe des Materials -zurückzukehren. Für Materialien, die nicht einfärbbar sind, ist die -Farbzeile deaktiviert und das Erscheinungsbild stammt aus der -Materialdefinition. +zurückzukehren. Wenn weder das Element noch das Material eine Farbe +definiert, wird die Textur ungefärbt angezeigt. ## Werkstücke in Rohmaterial umwandeln diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/laser.md index fca596d07..5d6c25b80 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/laser.md @@ -91,26 +91,17 @@ Um deine Punktgröße zu messen: 3. Verwende den Durchschnitt mehrerer Messungen ::: -#### Schnittfarbe +#### Farbe -Die Farbe, die zum Anzeigen von Schnittoperationen für diesen Laser im Canvas und -in der 3D-Vorschau verwendet wird. Dies hilft dir, visuell zu unterscheiden, -welcher Laser welche Schnittoperation durchführen wird, wenn du mit mehreren -Laserköpfen arbeitest. +Die Farbe, die zum Anzeigen der Operationen dieses Lasers (Schnitte und +Gravuren) im Canvas und in der 3D-Vorschau verwendet wird. Dies hilft dir, +visuell zu unterscheiden, welcher Laser welche Operation durchführen wird, +wenn du mit mehreren Laserköpfen arbeitest. - Klicke auf die Farbauswahl, um einen Farbwähler zu öffnen - Wähle eine Farbe, die gut mit deiner Materialvorschau kontrastiert - Standardfarben werden automatisch zugewiesen -#### Rasterfarbe - -Die Farbe, die zum Anzeigen von Raster-/Gravuroperationen für diesen Laser im -Canvas und in der 3D-Vorschau verwendet wird. - -- Klicke auf die Farbauswahl, um einen Farbwähler zu öffnen -- Nützlich zur Unterscheidung von Rasteroperationen von Schnitten -- Jeder Laser kann seine eigene Rasterfarbe haben - :::tip Multi-Laser-Workflows Bei der Verwendung mehrerer Laserköpfe erleichtert das Zuweisen unterschiedlicher Farben zu jedem Laser es, zu erkennen, welche Operationen von welchem Laser diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/3d-preview.md index e8e2fc8d3..4b9b3eed8 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -97,9 +97,9 @@ Passe an, was du siehst: - **Nach Operation färben**: Verschiedene Farben für jede Operation :::tip Farben pro Laser -Bei Maschinen mit mehreren Laserköpfen kann jeder Laser eigene Schnitt- und -Rasterfarben haben, die in den [Lasereinstellungen](../machine/laser.md) -konfiguriert werden. Dies erleichtert die Identifizierung, welcher Laser +Bei Maschinen mit mehreren Laserköpfen kann jeder Laser eine eigene Farbe haben, +die in den [Lasereinstellungen](../machine/laser.md) +konfiguriert wird. Dies erleichtert die Identifizierung, welcher Laser welche Operation ausführt. ::: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/es/docusaurus-plugin-content-docs/current/application-settings/materials.md index 05682d048..5ac39d3d6 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -64,19 +64,15 @@ superficie. #### Color -El color base del material. Cuando se establece una textura y el material -es teñible, el color tiñe la textura. El color solo se usa para la +Un color de tinte opcional. Cuando se establece, la textura del material +se tiñe con este color; cuando no, la textura se muestra tal cual. Esto +permite que un único material texturizado (ej., "Acrílico") cubra +múltiples variantes de color: el color se aplica por elemento de material +de base en el diálogo [Propiedades del material de +base](../features/stock-handling.md). El color solo se usa para la apariencia visual en la superficie de trabajo - no afecta la trayectoria del láser de ninguna manera. -#### Teñible - -Cuando está habilitado, la textura del material se puede teñir con el -color anterior. Esto permite que un único material texturizado (ej., -"Acrílico") cubra múltiples variantes de color: el color se aplica por -elemento de material de base en el diálogo [Propiedades del material de -base](../features/stock-handling.md). - #### Rugosidad Un valor de 0-1 que describe cuán rugosa o pulida aparece la superficie diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/stock-handling.md index 560334930..deacb657c 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -54,8 +54,7 @@ Nota: Las propiedades del material se definen en bibliotecas de materiales y no ### Color por Elemento de Material Base -Para materiales definidos como **teñibles** (ej., acrílico de color), -cada elemento de material base puede anular el color de tinte del +Cada elemento de material base puede anular el color de tinte del material de forma independiente: 1. Abre el diálogo de **Propiedades de Material Base** para el elemento @@ -65,8 +64,8 @@ material de forma independiente: El color se aplica solo a ese elemento de material base — otros elementos que usan el mismo material conservan sus propios colores. Usa el botón de borrar junto a la fila de color para volver al color predeterminado del -material. Para materiales que no son teñibles, la fila de color está -desactivada y la apariencia proviene de la definición del material. +material. Cuando ni el elemento ni el material definen un color, la +textura se muestra sin teñir. ## Convertir Piezas en Material Base diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/laser.md index d27dbbb38..72cc9b576 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/laser.md @@ -94,25 +94,16 @@ Para medir el tamaño de tu punto: 3. Usa el promedio de múltiples mediciones ::: -#### Color de Corte +#### Color -El color usado para mostrar operaciones de corte para este láser en el lienzo y -la vista previa 3D. Esto te ayuda a distinguir visualmente qué láser realizará -cada operación de corte cuando trabajas con múltiples cabezales láser. +El color usado para mostrar las operaciones de este láser (cortes y grabado) en +el lienzo y la vista previa 3D. Esto te ayuda a distinguir visualmente qué +láser realizará cada operación cuando trabajas con múltiples cabezales láser. - Haz clic en la muestra de color para abrir un selector de color - Elige un color que contraste bien con la vista previa de tu material - Los colores predeterminados se asignan automáticamente -#### Color de Raster - -El color usado para mostrar operaciones de raster/grabado para este láser en -el lienzo y la vista previa 3D. - -- Haz clic en la muestra de color para abrir un selector de color -- Útil para diferenciar operaciones de raster de cortes -- Cada láser puede tener su propio color de raster distintivo - :::tip Flujos de Trabajo Multi-Láser Al usar múltiples cabezales láser, asignar diferentes colores a cada láser facilita ver qué operaciones serán realizadas por qué láser. Por ejemplo, usa diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/3d-preview.md index 33ba75faf..9cb3f8424 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -93,8 +93,8 @@ Personaliza lo que ves: - **Colorear por Operación**: Diferentes colores para cada operación :::tip Colores por Láser -Al usar máquinas con múltiples cabezales láser, cada láser puede tener sus propios -colores de corte y raster configurados en [Ajustes de Láser](../machine/laser.md). +Al usar máquinas con múltiples cabezales láser, cada láser puede tener su propio +color configurado en [Ajustes de Láser](../machine/laser.md). Esto facilita identificar qué láser realizará cada operación. ::: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/application-settings/materials.md index cbeaa34ec..3dbabe5d6 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -65,18 +65,14 @@ surface. #### Couleur -La couleur de base du matériau. Lorsqu'une texture est définie et que le -matériau est teintable, la couleur teinte la texture. La couleur est -uniquement utilisée pour l'apparence visuelle sur la surface de travail - -elle n'affecte pas le parcours laser de quelque manière que ce soit. - -#### Teintable - -Lorsqu'elle est activée, la texture du matériau peut être teintée avec la -couleur ci-dessus. Cela permet à un seul matériau texturé (ex : -"Acrylique") de couvrir plusieurs variantes de couleur : la couleur est -appliquée par brut dans la boîte de dialogue [Propriétés du -brut](../features/stock-handling.md). +Une couleur de teinte facultative. Lorsqu'elle est définie, la texture du +matériau est teintée avec cette couleur ; sinon, la texture est affichée +telle quelle. Cela permet à un seul matériau texturé (ex : "Acrylique") +de couvrir plusieurs variantes de couleur : la couleur est appliquée par +brut dans la boîte de dialogue [Propriétés du +brut](../features/stock-handling.md). La couleur est uniquement utilisée +pour l'apparence visuelle sur la surface de travail - elle n'affecte pas +le parcours laser de quelque manière que ce soit. #### Rugosité diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/stock-handling.md index 2d7fed56c..b3682e356 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -55,8 +55,7 @@ Note : Les propriétés de matériau sont définies dans les bibliothèques de m ### Couleur par Élément de Matériau -Pour les matériaux définis comme **teintables** (ex : acrylique coloré), -chaque élément de matériau peut remplacer indépendamment la couleur de +Chaque élément de matériau peut remplacer indépendamment la couleur de teinte du matériau : 1. Ouvrez la boîte de dialogue **Propriétés du Matériau** pour l'élément @@ -66,9 +65,8 @@ teinte du matériau : La couleur n'est appliquée qu'à cet élément de matériau — les autres éléments utilisant le même matériau conservent leurs propres couleurs. Utilisez le bouton d'effacement à côté de la rangée de couleur pour revenir -à la couleur par défaut du matériau. Pour les matériaux non teintables, la -rangée de couleur est désactivée et l'apparence provient de la définition -du matériau. +à la couleur par défaut du matériau. Lorsque ni l'élément ni le matériau +ne définissent de couleur, la texture est affichée sans teinte. ## Convertir des Pièces en Matériau diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/laser.md index fa29e8c68..72cc59cf9 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/laser.md @@ -94,26 +94,17 @@ Pour mesurer la taille de ton spot : 3. Utilise la moyenne de plusieurs mesures ::: -#### Couleur de Coupe +#### Couleur -La couleur utilisée pour afficher les opérations de coupe pour ce laser dans -le canevas et la prévisualisation 3D. Cela t'aide à distinguer visuellement -quel laser effectuera chaque opération de coupe lorsque tu travailles avec +La couleur utilisée pour afficher les opérations de ce laser (coupes et +gravure) dans le canevas et la prévisualisation 3D. Cela t'aide à distinguer +visuellement quel laser effectuera chaque opération lorsque tu travailles avec plusieurs têtes laser. - Clique sur l'échantillon de couleur pour ouvrir un sélecteur de couleur - Choisis une couleur qui contraste bien avec l'aperçu de ton matériau - Les couleurs par défaut sont attribuées automatiquement -#### Couleur de Raster - -La couleur utilisée pour afficher les opérations de raster/gravure pour ce -laser dans le canevas et la prévisualisation 3D. - -- Clique sur l'échantillon de couleur pour ouvrir un sélecteur de couleur -- Utile pour différencier les opérations de raster des coupes -- Chaque laser peut avoir sa propre couleur de raster distincte - :::tip Workflows Multi-Laser Lors de l'utilisation de plusieurs têtes laser, l'attribution de couleurs différentes à chaque laser facilite la visualisation des opérations effectuées diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/3d-preview.md index baabb0ec1..1712a58f5 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -93,7 +93,7 @@ Personnalise ce que tu vois : :::tip Couleurs par Laser Lors de l'utilisation de machines avec plusieurs têtes laser, chaque laser peut -avoir ses propres couleurs de coupe et de raster configurées dans +avoir sa propre couleur configurée dans [Paramètres Laser](../machine/laser.md). Cela facilite l'identification du laser qui effectuera chaque opération. ::: diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/application-settings/materials.md index 51cf91372..2462b965e 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -66,17 +66,13 @@ Valores menores repetem a textura com mais frequência na mesma superfície. #### Cor -A cor base do material. Quando uma textura é definida e o material é -tingível, a cor tinge a textura. A cor é usada apenas para aparência -visual na superfície de trabalho - não afeta o caminho do laser de -nenhuma forma. - -#### Tingível - -Quando habilitado, a textura do material pode ser tingida com a cor -acima. Isso permite que um único material texturizado (ex., "Acrílico") -cubra múltiplas variantes de cor: a cor é aplicada por item de material -no diálogo [Propriedades do Material](../features/stock-handling.md). +Uma cor de tonalidade opcional. Quando definida, a textura do material é +tingida com essa cor; quando não, a textura é exibida como está. Isso +permite que um único material texturizado (ex., "Acrílico") cubra +múltiplas variantes de cor: a cor é aplicada por item de material no +diálogo [Propriedades do Material](../features/stock-handling.md). A cor +é usada apenas para aparência visual na superfície de trabalho - não +afeta o caminho do laser de nenhuma forma. #### Rugosidade diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/stock-handling.md index 91db0bac6..19f004b20 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -54,8 +54,7 @@ Nota: As propriedades do material são definidas em bibliotecas de materiais e n ### Cor por Item de Material -Para materiais definidos como **tingíveis** (ex., acrílico colorido), -cada item de material pode substituir a cor de tingimento do material +Cada item de material pode substituir a cor de tingimento do material independentemente: 1. Abra o diálogo de **Propriedades do Material** para o item @@ -64,9 +63,8 @@ independentemente: A cor é aplicada apenas a esse item de material — outros itens que usam o mesmo material mantêm suas próprias cores. Use o botão de limpar ao lado -da linha de cor para reverter à cor padrão do material. Para materiais que -não são tingíveis, a linha de cor fica desativada e a aparência vem da -definição do material. +da linha de cor para reverter à cor padrão do material. Quando nem o item +nem o material definem uma cor, a textura é exibida sem tingimento. ## Convertendo Peças em Material diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/laser.md index 78880a1a8..b5393346e 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/laser.md @@ -93,25 +93,16 @@ Para medir o tamanho do seu ponto: 3. Use a média de múltiplas medições ::: -#### Cor de Corte +#### Cor -A cor usada para exibir operações de corte para este laser na tela e na -visualização 3D. Isso ajuda você a distinguir visualmente qual laser realizará -cada operação de corte ao trabalhar com múltiplas cabeças de laser. +A cor usada para exibir as operações deste laser (cortes e gravação) na tela e +na visualização 3D. Isso ajuda você a distinguir visualmente qual laser +realizará cada operação ao trabalhar com múltiplas cabeças de laser. - Clique na amostra de cor para abrir um seletor de cores - Escolha uma cor que contraste bem com a visualização do seu material - Cores padrão são atribuídas automaticamente -#### Cor de Raster - -A cor usada para exibir operações de raster/gravação para este laser na tela e -na visualização 3D. - -- Clique na amostra de cor para abrir um seletor de cores -- Útil para diferenciar operações de raster de cortes -- Cada laser pode ter sua própria cor de raster distinta - :::tip Fluxos de Trabalho Multi-Laser Ao usar múltiplas cabeças de laser, atribuir cores diferentes a cada laser facilita ver quais operações serão realizadas por qual laser. Por exemplo, use diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/3d-preview.md index db3ab892c..3eb082892 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -96,8 +96,8 @@ Personalize o que você vê: - **Colorir por Operação**: Cores diferentes para cada operação :::tip Cores por Laser -Ao usar máquinas com múltiplas cabeças de laser, cada laser pode ter suas -próprias cores de corte e raster configuradas nas +Ao usar máquinas com múltiplas cabeças de laser, cada laser pode ter sua +própria cor configurada nas [Configurações do Laser](../machine/laser.md). Isso facilita identificar qual laser realizará cada operação. ::: diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/application-settings/materials.md index 16f3aa3b4..e78286763 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -66,17 +66,13 @@ WebP за допомогою скрипта `scripts/optimize_material_textures. #### Колір -Основний колір матеріалу. Якщо встановлено текстуру і матеріал можна -тонувати, колір тонує текстуру. Колір використовується лише для -візуального вигляду на робочій поверхні - він не впливає на шлях лазера -жодним чином. - -#### Можна тонувати - -Якщо увімкнено, текстуру матеріалу можна тонувати вказаним вище -кольором. Це дозволяє одному текстурованому матеріалу (наприклад, +Необов'язковий колір тонування. Якщо його встановлено, текстуру +матеріалу буде тоновано цим кольором; якщо ні, текстура відображається +як є. Це дозволяє одному текстурованому матеріалу (наприклад, "Акрил") покривати кілька варіантів кольору: колір застосовується до кожної заготовки в діалозі [Властивості заготовки](../features/stock-handling.md). +Колір використовується лише для візуального вигляду на робочій поверхні - +він не впливає на шлях лазера жодним чином. #### Шорсткість diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/stock-handling.md index 218b4b7e6..1b87382eb 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -54,9 +54,7 @@ ### Колір для кожної заготовки -Для матеріалів, визначених як **можна тонувати** (наприклад, кольоровий -акрил), кожна заготовка може незалежно змінювати колір тонування -матеріалу: +Кожна заготовка може незалежно змінювати колір тонування матеріалу: 1. Відкрийте діалог **Властивості заготовки** для елемента 2. Натисніть кнопку кольору в рядку **Колір** @@ -65,8 +63,8 @@ Колір застосовується лише до цієї заготовки — інші елементи з тим самим матеріалом зберігають власні кольори. Використовуйте кнопку очищення поруч із рядком кольору, щоб повернутися до кольору матеріалу за -замовчуванням. Для матеріалів, які не можна тонувати, рядок кольору -вимкнено, а вигляд береться з визначення матеріалу. +замовчуванням. Коли ні елемент, ні матеріал не визначають колір, +текстура відображається без тонування. ## Перетворення заготовок на заготовки diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/laser.md index 337ba4e0f..d2cd01b89 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/laser.md @@ -93,25 +93,16 @@ Rayforge підтримує машини з кількома лазерними 3. Використовуйте середнє з кількох вимірювань ::: -#### Колір різання +#### Колір -Колір, що використовується для відображення операцій різання для цього лазера -на полотні та в 3D перегляді. Це допомагає візуально розрізняти, який лазер -виконуватиме кожну операцію різання при роботі з кількома лазерними головками. +Колір, що використовується для відображення операцій цього лазера (різання та +гравірування) на полотні та в 3D перегляді. Це допомагає візуально розрізняти, +який лазер виконуватиме кожну операцію при роботі з кількома лазерними головками. - Натисніть на зразок кольору, щоб відкрити палітру кольорів - Виберіть колір, який добре контрастує з попереднім переглядом матеріалу - Кольори за замовчуванням призначаються автоматично -#### Колір растра - -Колір, що використовується для відображення операцій растра/гравірування для -цього лазера на полотні та в 3D перегляді. - -- Натисніть на зразок кольору, щоб відкрити палітру кольорів -- Корисно для розрізнення операцій растера від різань -- Кожен лазер може мати свій власний колір растра - :::tip Багатолазерні робочі процеси При використанні кількох лазерних головок призначення різних кольорів кожному лазеру полегшує перегляд того, які операції будуть виконуватися яким лазером. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/3d-preview.md index c15525d5c..df1d618ae 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -93,7 +93,7 @@ :::tip Кольори для кожного лазера При використанні машин з кількома лазерними головками кожен лазер може мати -власні кольори різання та растра, налаштовані в +власний колір, налаштований у [Налаштуваннях лазера](../machine/laser.md). Це полегшує ідентифікацію того, який лазер виконуватиме кожну операцію. ::: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/application-settings/materials.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/application-settings/materials.md index 3142c14e4..9d825e4ac 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/application-settings/materials.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/application-settings/materials.md @@ -58,14 +58,10 @@ Rayforge 中的材料库允许您组织和管理激光切割和雕刻项目的 #### 颜色 -材料的基本颜色。当设置了纹理且材料可着色时,颜色会给纹理着色。颜色仅用于 -工作表面上的视觉外观 - 它不会以任何方式影响激光路径。 - -#### 可着色 - -启用后,材料纹理可以使用上述颜色着色。这样单个带纹理的材料(例如, -"亚克力")就可以覆盖多种颜色变体:颜色在[毛坯属性](../features/stock-handling.md) -对话框中按毛坯项应用。 +可选的着色颜色。设置后,材料纹理将使用此颜色着色;未设置时,纹理按原样 +显示。这样单个带纹理的材料(例如,"亚克力")就可以覆盖多种颜色变体: +颜色在[毛坯属性](../features/stock-handling.md)对话框中按毛坯项应用。 +颜色仅用于工作表面上的视觉外观 - 它不会以任何方式影响激光路径。 #### 粗糙度 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/stock-handling.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/stock-handling.md index 050403d1b..5c0910504 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/stock-handling.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/stock-handling.md @@ -53,16 +53,15 @@ Rayforge 中的材料代表您将切割或雕刻的物理材料。材料是一 ### 每个材料项目的颜色 -对于定义为**可着色**的材料(例如,彩色亚克力),每个材料项目可以独立覆盖 -材料的着色颜色: +每个材料项目可以独立覆盖材料的着色颜色: 1. 打开该项目的**材料属性**对话框 2. 点击**颜色**行中的颜色按钮 3. 从颜色选择器中选择一种颜色 该颜色仅应用于这个材料项目 — 使用相同材料的其他项目保留自己的颜色。 -使用颜色行旁边的清除按钮恢复为材料的默认颜色。对于不可着色的材料, -颜色行被禁用,外观来自材料定义。 +使用颜色行旁边的清除按钮恢复为材料的默认颜色。当项目和材料都未定义 +颜色时,纹理按原样显示(不着色)。 ## 将工件转换为材料 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/laser.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/laser.md index 0f341f22a..9806843a3 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/laser.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/laser.md @@ -80,22 +80,14 @@ Rayforge 支持具有多个激光头的机器。每个激光头都有自己的 3. 使用多次测量的平均值 ::: -#### 切割颜色 +#### 颜色 -用于在画布和 3D 预览中显示此激光切割操作的颜色。这有助于在使用多个激光头时直观地区分哪个激光将执行哪个切割操作。 +用于在画布和 3D 预览中显示此激光操作(切割和雕刻)的颜色。这有助于在使用多个激光头时直观地区分哪个激光将执行哪个操作。 - 点击颜色样本打开颜色选择器 - 选择与材料预览对比度好的颜色 - 默认颜色自动分配 -#### 栅格颜色 - -用于在画布和 3D 预览中显示此激光栅格/雕刻操作的颜色。 - -- 点击颜色样本打开颜色选择器 -- 用于区分栅格操作和切割操作 -- 每个激光可以有自己的栅格颜色 - :::tip 多激光工作流程 使用多个激光头时,为每个激光分配不同的颜色可以轻松查看哪些操作将由哪个激光执行。例如,主切割激光使用红色,次级雕刻激光使用蓝色。 ::: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/3d-preview.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/3d-preview.md index e7022ec76..9e74057ff 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/3d-preview.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/3d-preview.md @@ -78,7 +78,7 @@ - **按操作着色**:每个操作不同颜色 :::tip 每个激光的颜色 -使用具有多个激光头的机器时,每个激光可以在[激光设置](../machine/laser.md)中配置自己的切割和栅格颜色。这使得识别哪个激光将执行哪个操作变得容易。 +使用具有多个激光头的机器时,每个激光可以在[激光设置](../machine/laser.md)中配置自己的颜色。这使得识别哪个激光将执行哪个操作变得容易。 ::: ### 激光头模型 diff --git a/website/static/screenshots/main-3d-bee.png b/website/static/screenshots/main-3d-bee.png index 040e87adc..97ae13978 100644 Binary files a/website/static/screenshots/main-3d-bee.png and b/website/static/screenshots/main-3d-bee.png differ