remove prusaslicer (out of scope)
This commit is contained in:
parent
8d5fb68aaa
commit
9137d297e5
19 changed files with 0 additions and 45555 deletions
|
|
@ -1,181 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import os, shutil, tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from ..blender.blender_mesh_capture import SlicingGroup, SlicingCollection
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def write_metadata_xml(group: SlicingGroup, filepath: str | Path) -> None:
|
||||
# Custom sorting order for object types
|
||||
object_type_order = {
|
||||
'ModelPart': 0,
|
||||
'NegativeVolume': 1,
|
||||
'ParameterModifier': 2,
|
||||
'SupportBlocker': 3,
|
||||
'SupportEnforcer': 4
|
||||
}
|
||||
|
||||
xml_content = ET.Element("config")
|
||||
|
||||
valid_collections = {k: c for k, c in group.collections.items() if c.meshes}
|
||||
|
||||
for j, (k, collection) in enumerate(valid_collections.items()):
|
||||
|
||||
sorted_data = sorted(
|
||||
zip(collection.mesh_start_ids, collection.mesh_end_ids, collection.objects),
|
||||
key=lambda x: (
|
||||
0 if x[2].name == collection.name else 1,
|
||||
object_type_order.get(x[2].object_type, 5),
|
||||
x[2].name,
|
||||
),
|
||||
)
|
||||
|
||||
object_elem = ET.SubElement(xml_content, "object", id=str(j+1), instances_count="1")
|
||||
ET.SubElement(object_elem, "metadata", type="object", key="name", value=collection.name)
|
||||
|
||||
for i, (start, end, metadata) in enumerate(sorted_data):
|
||||
|
||||
volume_elem = ET.SubElement(object_elem, "volume", firstid=str(start), lastid=str(end))
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="name", value=metadata.name)
|
||||
|
||||
if metadata.object_type == "ParameterModifier":
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="modifier", value="1")
|
||||
|
||||
if metadata.object_type in ["ModelPart", "ParameterModifier"]:
|
||||
for mod in metadata.modifiers:
|
||||
if not mod: continue
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key=mod['param_id'], value=mod['param_value'])
|
||||
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="volume_type", value=metadata.object_type)
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="extruder", value=str(metadata.extruder))
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="source_object_id", value="0")
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="source_volume_id", value=str(i))
|
||||
ET.SubElement(volume_elem, "metadata", type="volume", key="matrix", value="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1")
|
||||
ET.SubElement(volume_elem, "mesh", edges_fixed="0", degenerate_facets="0", facets_removed="0", facets_reversed="0", backwards_edges="0")
|
||||
|
||||
ET.indent(xml_content)
|
||||
xml_tree = ET.ElementTree(xml_content)
|
||||
xml_tree.write(filepath, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
|
||||
def write_wipe_tower_xml(group: SlicingGroup, filename: str | Path) -> None:
|
||||
with open(filename, 'w', encoding="UTF-8") as file:
|
||||
file.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
||||
file.write(f'<wipe_tower_information bed_idx="0" position_x="{group.wipe_tower_xy[0]}" position_y="{group.wipe_tower_xy[1]}" rotation_deg="{group.wipe_tower_rotation_deg}"/>\n')
|
||||
|
||||
|
||||
def write_model_xml(group: SlicingGroup, filename: str | Path) -> None:
|
||||
now = date.today().isoformat()
|
||||
|
||||
# Open file for writing
|
||||
with open(filename, 'w', encoding="UTF-8") as file:
|
||||
# Write the XML declaration and opening model tag
|
||||
file.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
||||
file.write('<model xmlns="" unit="millimeter" xml:lang="en-US" xmlns:slic3rpe="">\n')
|
||||
|
||||
# Write metadata entries using list comprehension
|
||||
metadata_entries: list[tuple[str, str]] = [
|
||||
("slic3rpe:Version3mf", "1"),
|
||||
("Title", "box"),
|
||||
("Designer", ""),
|
||||
("Description", "box"),
|
||||
("Copyright", ""),
|
||||
("LicenseTerms", ""),
|
||||
("Rating", ""),
|
||||
("CreationDate", now),
|
||||
("ModificationDate", now),
|
||||
("Application", "PrusaSlicer-2.9.0")
|
||||
]
|
||||
file.writelines([f' <metadata name="{name}">{value}</metadata>\n' for name, value in metadata_entries])
|
||||
|
||||
# Write resources element and object using list comprehension
|
||||
file.write(' <resources>\n')
|
||||
|
||||
verts_template = np.vectorize(lambda x, y, z: '<vertex x="%.6f" y="%.6f" z="%.6f" />\n' % (x, y, z))
|
||||
idx_template = np.vectorize(lambda a, b, c: '<triangle v1="%d" v2="%d" v3="%d" />\n' % (a, b, c))
|
||||
|
||||
valid_collections: dict[str, SlicingCollection] = {k: c for k, c in group.collections.items() if c.meshes}
|
||||
|
||||
for i, (k, collection) in enumerate[tuple[str, SlicingCollection]](valid_collections.items()):
|
||||
if not collection.meshes: continue
|
||||
|
||||
uv, t_idx = collection.unique_verts
|
||||
|
||||
if uv.size and t_idx.size:
|
||||
file.write(f' <object id="{str(i+1)}" type="model">\n')
|
||||
|
||||
file.write(' <mesh>\n')
|
||||
|
||||
file.write(' <vertices>\n')
|
||||
file.writelines(verts_template(uv[:,0], uv[:,1], uv[:,2]))
|
||||
file.write(' </vertices>\n')
|
||||
|
||||
file.write(' <triangles>\n')
|
||||
file.writelines(idx_template(t_idx[:,0], t_idx[:,1], t_idx[:,2]))
|
||||
file.write(' </triangles>\n')
|
||||
|
||||
file.write(' </mesh>\n')
|
||||
file.write(' </object>\n')
|
||||
|
||||
file.write(' </resources>\n')
|
||||
|
||||
# Write build element
|
||||
file.write(' <build>\n')
|
||||
ox, oy, oz = getattr(group, "object_offset", (0.0, 0.0, 0.0))
|
||||
transform_str = f"1 0 0 0 1 0 0 0 1 {ox} {oy} {oz}"
|
||||
for i, k in enumerate(valid_collections):
|
||||
if not valid_collections[k].unique_verts[0].size: continue
|
||||
file.writelines([f' <item objectid="{str(i+1)}" transform="{transform_str}" printable="1" />\n'])
|
||||
file.write(' </build>\n')
|
||||
|
||||
# Close the model tag
|
||||
file.write('</model>\n')
|
||||
|
||||
|
||||
def to_3mf(folder_path: str | Path, output_base_path: str | Path) -> None:
|
||||
zip_file_path = shutil.make_archive(os.path.splitext(output_base_path)[0], 'zip', folder_path)
|
||||
new_file_path = os.path.splitext(zip_file_path)[0] + '.3mf'
|
||||
os.replace(zip_file_path, new_file_path)
|
||||
|
||||
|
||||
def write_z_gcodes(z_gcodes, filename: str | Path) -> None:
|
||||
root = ET.Element("custom_gcodes_per_print_z", bed_idx="0")
|
||||
|
||||
for c in z_gcodes:
|
||||
ET.SubElement(root, "code", {
|
||||
"print_z": str(c.z),
|
||||
"type": str(c.type),
|
||||
"extruder": str(c.extruder),
|
||||
"color": c.color,
|
||||
"extra": c.extra,
|
||||
"gcode": c.gcode,
|
||||
})
|
||||
|
||||
ET.SubElement(root, "mode", {"value": "SingleExtruder"})
|
||||
|
||||
ET.ElementTree(root).write(filename, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
def prepare_3mf(filepath: Path, geoms: SlicingGroup, conf: Any, z_gcodes: Any, pg: Any = None, cx: Any = None) -> None:
|
||||
source_folder = os.path.join(script_dir, 'prusaslicer_3mf')
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
shutil.copytree(source_folder, temp_dir, dirs_exist_ok=True)
|
||||
|
||||
os.makedirs(os.path.join(temp_dir, '3D'), exist_ok=True)
|
||||
os.makedirs(os.path.join(temp_dir, 'Metadata'), exist_ok=True)
|
||||
|
||||
write_model_xml(geoms, os.path.join(temp_dir, '3D', '3dmodel.model'))
|
||||
|
||||
write_metadata_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Slic3r_PE_model.config'))
|
||||
write_wipe_tower_xml(geoms, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_wipe_tower_information.xml'))
|
||||
write_z_gcodes(z_gcodes, os.path.join(temp_dir, 'Metadata', 'Prusa_Slicer_custom_gcode_per_print_z.xml'))
|
||||
conf.write_ini_3mf(os.path.join(temp_dir, 'Metadata', 'Slic3r_PE.config'))
|
||||
|
||||
to_3mf(temp_dir, filepath)
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<Types xmlns="">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
|
||||
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml" />
|
||||
<Default Extension="png" ContentType="image/png" />
|
||||
</Types>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
|
||||
<Relationship Target="/Metadata/thumbnail.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/>
|
||||
</Relationships>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
# PrusaSlicer Profiles Module
|
||||
|
||||
This module handles the parsing, management, and evaluation of PrusaSlicer configuration profiles (`.ini` files). It supports profile inheritance, compatibility evaluation using PrusaSlicer's expression language, and generating merged configurations for slicing.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **`__init__.py`**: Exports the primary API (`LocalCache`, `Profile`, `ConfigWriter`).
|
||||
- **`cache.py`**: Contains the `LocalCache` class for managing profile loading and metadata.
|
||||
- **`models.py`**: Defines core data models like `Profile` (as a dataclass) and `ConfigWriter`.
|
||||
- **`parser.py`**: Implements a recursive descent parser for PrusaSlicer's expression language.
|
||||
- **`fields.py`**: Robustly loads and searches PrusaSlicer setting metadata (JSON/CSV).
|
||||
- **`utils.py`**: Contains canonical implementations of INI parsing and configuration merging (`generate_conf`).
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. `LocalCache` (`cache.py`)
|
||||
- Manages a collection of profiles loaded from disk.
|
||||
- Scans directories for `.ini` files and tracks changes using file metadata.
|
||||
- Categorizes profiles into `printer`, `print`, and `filament`.
|
||||
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
|
||||
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles.
|
||||
|
||||
### 2. `Profile` (`models.py`)
|
||||
- Represents a single PrusaSlicer profile (Printer, Filament, or Print).
|
||||
- Stores configuration as a dictionary (`conf_dict`).
|
||||
- Handles inheritance logic via `generate_inherited_confs`.
|
||||
- Supports compatibility checks via `evaluate_compatibility`.
|
||||
|
||||
### 3. `ConfigWriter` (`models.py`)
|
||||
- Helper to write merged configurations to a file (e.g., for passing to a slicer or embedding in a 3MF).
|
||||
- Includes checksum generation for configuration integrity.
|
||||
|
||||
### 4. `Parser` (`parser.py`)
|
||||
- Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
|
||||
- Supports variables, indexing, logical operators, and regex matching.
|
||||
|
||||
### 5. `fields.py`
|
||||
- Loads metadata about PrusaSlicer configuration fields from `prusaslicer_fields.json` and `prusaslicer_modifier_fields.csv`.
|
||||
- Uses robust path resolution relative to the module's directory.
|
||||
|
||||
## Typical Workflow
|
||||
1. Initialize `LocalCache`.
|
||||
2. Call `cache.load([paths_to_profiles])`.
|
||||
3. Select a printer, print, and filament profile(s).
|
||||
4. (Optional) Evaluate compatibility using `cache.evaluate_compatibility`.
|
||||
5. Generate a merged config using `cache.generate_conf_writer`.
|
||||
6. Use `ConfigWriter` to output the final configuration for slicing.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from .cache import LocalCache
|
||||
from .models import Profile, ConfigWriter
|
||||
|
||||
__all__ = ['LocalCache', 'Profile', 'ConfigWriter']
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import Profile, ConfigWriter
|
||||
from .utils import ini_to_dict
|
||||
|
||||
class LocalCache:
|
||||
def __init__(self):
|
||||
self.profiles: dict[str, Profile] = {}
|
||||
self.files_metadata: dict[str, float] = {}
|
||||
|
||||
@property
|
||||
def display_profiles(self) -> dict[str, Profile]:
|
||||
"""Profiles that are not hidden (don't contain '*' in their ID)."""
|
||||
return {k: p for k, p in self.profiles.items() if '*' not in p.id}
|
||||
|
||||
@property
|
||||
def printer_profiles(self) -> dict[str, Profile]:
|
||||
return {k: p for k, p in self.display_profiles.items() if p.category == 'printer'}
|
||||
|
||||
@property
|
||||
def print_profiles(self) -> dict[str, Profile]:
|
||||
return {k: p for k, p in self.display_profiles.items() if p.category == 'print'}
|
||||
|
||||
@property
|
||||
def filament_profiles(self) -> dict[str, Profile]:
|
||||
return {k: p for k, p in self.display_profiles.items() if p.category == 'filament'}
|
||||
|
||||
@property
|
||||
def vendors(self) -> list[str]:
|
||||
return sorted({p.vendor for p in self.filament_profiles.values() if p.vendor})
|
||||
|
||||
def load(self, dirs: list[Path | str]) -> tuple[set[str], set[str], set[str]]:
|
||||
"""
|
||||
Loads profiles from the specified directories.
|
||||
Returns sets of (changed, added, deleted) file paths.
|
||||
"""
|
||||
old_metadata = self.files_metadata.copy()
|
||||
new_metadata = self._fetch_files_metadata(dirs)
|
||||
|
||||
changed = {k for k in old_metadata.keys() & new_metadata.keys() if old_metadata[k] != new_metadata[k]}
|
||||
added = new_metadata.keys() - old_metadata.keys()
|
||||
deleted = old_metadata.keys() - new_metadata.keys()
|
||||
|
||||
if not (changed | added | deleted):
|
||||
return set(), set(), set()
|
||||
|
||||
# Handle deletions
|
||||
for deleted_file in deleted:
|
||||
keys_to_remove = [k for k, v in self.profiles.items() if str(v.path) == deleted_file]
|
||||
for k in keys_to_remove:
|
||||
self.profiles.pop(k, None)
|
||||
|
||||
# Handle additions and changes
|
||||
for file_path in (changed | added):
|
||||
self._process_ini_to_cache(file_path)
|
||||
|
||||
self.files_metadata = new_metadata
|
||||
|
||||
# Recalculate inheritance for all profiles
|
||||
for profile in self.display_profiles.values():
|
||||
profile.generate_inherited_conf(self.profiles)
|
||||
|
||||
return changed, added, deleted
|
||||
|
||||
def evaluate_compatibility(self, enabled_printers: set[str], enabled_vendors: set[str]):
|
||||
"""Evaluates compatibility expressions for all active profiles."""
|
||||
for k in enabled_printers:
|
||||
printer = self.printer_profiles["printer:" + k]
|
||||
|
||||
active_vendors = enabled_vendors | {''}
|
||||
active_filaments = {k: p for k, p in self.filament_profiles.items() if p.vendor in active_vendors}
|
||||
|
||||
printer.evaluate_compatibility(active_filaments | self.print_profiles)
|
||||
|
||||
def generate_conf_writer(
|
||||
self,
|
||||
printer_id: str,
|
||||
filament_ids: list[str],
|
||||
print_id: str,
|
||||
overrides: dict[str, dict[str, Any]]
|
||||
) -> ConfigWriter:
|
||||
"""Merges selected profiles and overrides into a ConfigWriter."""
|
||||
from .fields import search_db
|
||||
|
||||
conf: dict[str, Any] = {}
|
||||
|
||||
# Add printer and print profile
|
||||
conf.update(self.profiles[printer_id].all_conf_dict)
|
||||
conf.update(self.profiles[print_id].all_conf_dict)
|
||||
|
||||
# Merge filament profiles (handling multi-extruder setups)
|
||||
filament_confs = [self.profiles[fid].all_conf_dict for fid in filament_ids]
|
||||
all_keys = set().union(*filament_confs)
|
||||
all_keys.discard('cooling_slowdown_logic')
|
||||
|
||||
for key in all_keys:
|
||||
from .fields import search_db
|
||||
key_props = search_db.get(key)
|
||||
if not key_props:
|
||||
continue
|
||||
|
||||
default_val = key_props.get('default', '')
|
||||
key_type = key_props['type']
|
||||
|
||||
# Select separator based on field type
|
||||
list_types = {
|
||||
'coPercents', 'coFloats', 'coFloatsOrPercents',
|
||||
'coInts', 'coIntsNullable', 'coBools', 'coPoints'
|
||||
}
|
||||
separator = ',' if key_type in list_types else ';'
|
||||
|
||||
values = [d.get(key, default_val) for d in filament_confs]
|
||||
conf[key] = separator.join(map(str, values))
|
||||
|
||||
# Apply overrides
|
||||
for k, o in overrides.items():
|
||||
conf[k] = o['value']
|
||||
|
||||
# Add profile names for reference
|
||||
conf.update({
|
||||
'printer_settings_id': printer_id.split(":")[1],
|
||||
'filament_settings_id': ";".join(fid.split(":")[1] for fid in filament_ids),
|
||||
'print_settings_id': print_id.split(":")[1],
|
||||
})
|
||||
|
||||
return ConfigWriter(conf)
|
||||
|
||||
def _fetch_files_metadata(self, dirs: list[Path | str]) -> dict[str, float]:
|
||||
metadata = {}
|
||||
for d in dirs:
|
||||
path = Path(os.path.expanduser(d)).resolve()
|
||||
if not path.is_dir():
|
||||
continue
|
||||
|
||||
for root, _, files in os.walk(path, followlinks=True):
|
||||
for f in files:
|
||||
if f.endswith('.ini'):
|
||||
file_path = Path(root) / f
|
||||
try:
|
||||
metadata[str(file_path)] = file_path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
return metadata
|
||||
|
||||
def _process_ini_to_cache(self, path: str):
|
||||
has_header, sections = ini_to_dict(path)
|
||||
for key, conf in sections.items():
|
||||
if ":" in key:
|
||||
category = key.split(':')[0]
|
||||
self.profiles[key] = Profile(
|
||||
key=key,
|
||||
category=category,
|
||||
path=Path(path),
|
||||
has_header=has_header,
|
||||
conf_dict=conf
|
||||
)
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from ..json_handling import dict_from_json
|
||||
from ..csv_handling import parse_csv_to_dict
|
||||
|
||||
# Robust path resolution relative to this file
|
||||
BASE_DIR = os.path.dirname(__file__)
|
||||
search_db_path: str = os.path.join(BASE_DIR, 'prusaslicer_fields.json')
|
||||
search_db: dict[str, dict[str, Any]] = dict_from_json(search_db_path)
|
||||
|
||||
search_db_mod_path: str = os.path.join(BASE_DIR, 'prusaslicer_modifier_fields.csv')
|
||||
search_db_mod: dict[str, list[str]] = parse_csv_to_dict(search_db_mod_path)
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def search_in_db(term: str) -> dict[str, dict[str, Any]]:
|
||||
"""Searches the PrusaSlicer fields database for a given term."""
|
||||
words = term.lower().split()
|
||||
return {
|
||||
k: v for k, v in search_db.items()
|
||||
if all(word in k + " " + v.get('label', '') for word in words)
|
||||
}
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def search_in_mod_db(term: str) -> dict[str, dict[str, Any]]:
|
||||
"""Searches the PrusaSlicer modifier fields database for a given term."""
|
||||
words = term.lower().split()
|
||||
filtered_db = {k: v for k, v in search_db.items() if k in search_db_mod}
|
||||
return {
|
||||
k: v for k, v in filtered_db.items()
|
||||
if all(word in k + " " + v.get('label', '') for word in words)
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .parser import ExprNode
|
||||
|
||||
class Profile:
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
category: str,
|
||||
path: Path,
|
||||
has_header: bool,
|
||||
conf_dict: dict[str, Any]
|
||||
):
|
||||
self.key = key
|
||||
self.category = category
|
||||
self.path = path
|
||||
self.has_header = has_header
|
||||
self.conf_dict = conf_dict
|
||||
|
||||
self.id = self.key.split(':')[1] if len(self.key.split(':')) > 1 else self.key
|
||||
self.vendor = ""
|
||||
self.all_conf_dict: dict[str, Any] = {}
|
||||
self.compatibility_expression: ExprNode | None = None
|
||||
|
||||
self.compatible_profiles: list[Profile] = []
|
||||
|
||||
@property
|
||||
def compatible_print_profiles(self) -> list[Profile]:
|
||||
return [k for k in self.compatible_profiles if k.category == 'print']
|
||||
|
||||
@property
|
||||
def compatible_filament_profiles(self) -> list[Profile]:
|
||||
return [k for k in self.compatible_profiles if k.category == 'filament']
|
||||
|
||||
def generate_inherited_conf(self, all_profiles: dict[str, Profile]):
|
||||
"""Calculates the full configuration dictionary by resolving inheritance."""
|
||||
from .utils import generate_conf
|
||||
from .parser import Parser
|
||||
|
||||
self.all_conf_dict = generate_conf(all_profiles, self.key)
|
||||
self.vendor = self.all_conf_dict.get('filament_vendor', '')
|
||||
|
||||
if self.category == 'printer':
|
||||
nozzle_diameters = self.all_conf_dict.get('nozzle_diameter', '0.4')
|
||||
self.all_conf_dict['num_extruders'] = str(len(nozzle_diameters.split(',')))
|
||||
|
||||
if exp := self.all_conf_dict.get('compatible_printers_condition'):
|
||||
try:
|
||||
self.compatibility_expression = Parser(exp).parse()
|
||||
except Exception as e:
|
||||
print(f'Expression parsing failed for {self.id}: {exp} - {e}')
|
||||
|
||||
def evaluate_compatibility(self, profiles: dict[str, Profile]):
|
||||
"""Checks which of the provided profiles are compatible with this one."""
|
||||
self.compatible_profiles = []
|
||||
for key, p in profiles.items():
|
||||
compat = p.compatibility_expression
|
||||
if not compat or compat.eval(self.all_conf_dict):
|
||||
self.compatible_profiles += [p]
|
||||
|
||||
class ConfigWriter:
|
||||
def __init__(self, conf: dict[str, str | list[str]]) -> None:
|
||||
self.config_dict: dict[str, str | list[str]] = conf
|
||||
self.temp_dir = tempfile.gettempdir()
|
||||
|
||||
def write_ini_3mf(self, config_local_path: Path):
|
||||
with open(config_local_path, 'w') as file:
|
||||
for key, val in dict(sorted(self.config_dict.items())).items():
|
||||
if key in ("wipe_tower_position", "offset_object"):
|
||||
continue
|
||||
file.write(f"; {key} = {val}\n")
|
||||
|
||||
def get(self, key: str, default: str = "") -> str | list[str]:
|
||||
return self.config_dict.get(key, default)
|
||||
|
||||
@property
|
||||
def checksum(self) -> int:
|
||||
import json, zlib
|
||||
data = json.dumps(self.config_dict, sort_keys=True).encode("utf-8")
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
TOKENS = [
|
||||
('OP', r'==|!=|<=|>=|=~|!~|&&|\|\||[()\[\]!><]'),
|
||||
('REGEX', r'/((?:\\/|[^/])*)/'),
|
||||
('STRING', r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\''),
|
||||
('NUMBER', r'\d+(?:\.\d+)?'),
|
||||
('IDENT', r'[a-zA-Z_]\w*'),
|
||||
]
|
||||
TOKEN_RE = re.compile('|'.join(f'(?P<{name}>{pattern})' for name, pattern in TOKENS))
|
||||
|
||||
_OPS = {
|
||||
'==': (str, operator.eq),
|
||||
'!=': (str, operator.ne),
|
||||
'<': (float, operator.lt),
|
||||
'>': (float, operator.gt),
|
||||
'<=': (float, operator.le),
|
||||
'>=': (float, operator.ge),
|
||||
}
|
||||
|
||||
def _tokenize(text: str) -> list[tuple[str, Any]]:
|
||||
tokens: list[tuple[str, Any]] = []
|
||||
for m in TOKEN_RE.finditer(text):
|
||||
kind: str = m.lastgroup if m.lastgroup else ""
|
||||
val = m.group(kind)
|
||||
if kind == 'OP':
|
||||
if val == '&&': tokens.append(('OP', 'and'))
|
||||
elif val == '||': tokens.append(('OP', 'or'))
|
||||
elif val in ('(', ')', '[', ']'): tokens.append((val, val))
|
||||
else: tokens.append(('OP', val))
|
||||
elif kind == 'REGEX':
|
||||
tokens.append(('REGEX', re.compile(val[1:-1])))
|
||||
elif kind == 'STRING':
|
||||
tokens.append(('LIT', val[1:-1]))
|
||||
elif kind == 'NUMBER':
|
||||
tokens.append(('LIT', val))
|
||||
elif kind == 'IDENT':
|
||||
if val == 'and': tokens.append(('OP', 'and'))
|
||||
elif val == 'or': tokens.append(('OP', 'or'))
|
||||
else: tokens.append(('IDENT', val))
|
||||
return tokens
|
||||
|
||||
class ExprNode:
|
||||
"""Represents an expression node in the AST."""
|
||||
def __init__(self, node: tuple[Any, ...]) -> None:
|
||||
self.node = node
|
||||
|
||||
def eval(self, context: dict[str, Any]) -> Any:
|
||||
return self._eval_node(self.node, context)
|
||||
|
||||
@staticmethod
|
||||
def _eval_node(node: tuple[Any, ...], context: dict[str, Any]) -> Any:
|
||||
ntype = node[0]
|
||||
if ntype == 'lit':
|
||||
return node[1]
|
||||
if ntype == 'var':
|
||||
return context.get(node[1], '0')
|
||||
if ntype == 'index':
|
||||
val = str(context.get(node[1], '0'))
|
||||
parts = val.split(',')
|
||||
idx = node[2]
|
||||
return parts[idx].strip() if idx < len(parts) else '0'
|
||||
if ntype == 'unary':
|
||||
cval = ExprNode._eval_node(node[2], context)
|
||||
try:
|
||||
return not float(cval)
|
||||
except (ValueError, TypeError):
|
||||
return not bool(cval)
|
||||
if ntype == 'bin':
|
||||
op, left, right = node[1], node[2], node[3]
|
||||
if op == 'and':
|
||||
lv = ExprNode._eval_node(left, context)
|
||||
if not lv or lv == '0': return False
|
||||
return ExprNode._eval_node(right, context)
|
||||
if op == 'or':
|
||||
lv = ExprNode._eval_node(left, context)
|
||||
if lv and lv != '0': return True
|
||||
return ExprNode._eval_node(right, context)
|
||||
lv = ExprNode._eval_node(left, context)
|
||||
rv = ExprNode._eval_node(right, context)
|
||||
if op in _OPS:
|
||||
caster, func = _OPS[op]
|
||||
try:
|
||||
return func(caster(lv), caster(rv))
|
||||
except (ValueError, TypeError):
|
||||
return func(str(lv), str(rv))
|
||||
if op == '=~':
|
||||
return rv.search(str(lv)) is not None
|
||||
if op == '!~':
|
||||
return rv.search(str(lv)) is None
|
||||
return '0'
|
||||
|
||||
class Parser:
|
||||
"""Recursive descent parser for PrusaSlicer expressions."""
|
||||
def __init__(self, text: str) -> None:
|
||||
self.tokens = _tokenize(text)
|
||||
self.pos = 0
|
||||
|
||||
def parse(self) -> ExprNode:
|
||||
node = self._parse_or()
|
||||
if self.pos < len(self.tokens):
|
||||
raise SyntaxError(f'Trailing tokens: {self.tokens[self.pos:]}')
|
||||
return ExprNode(node)
|
||||
|
||||
def _peek(self) -> tuple[str, Any] | None:
|
||||
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
|
||||
def _advance(self) -> tuple[str, Any]:
|
||||
tok = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
def _parse_or(self) -> tuple[Any, ...]:
|
||||
left = self._parse_and()
|
||||
while (tok := self._peek()) and tok == ('OP', 'or'):
|
||||
self._advance()
|
||||
left = ('bin', 'or', left, self._parse_and())
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> tuple[Any, ...]:
|
||||
left = self._parse_not()
|
||||
while (tok := self._peek()) and tok == ('OP', 'and'):
|
||||
self._advance()
|
||||
left = ('bin', 'and', left, self._parse_not())
|
||||
return left
|
||||
|
||||
def _parse_not(self) -> tuple[Any, ...]:
|
||||
if (tok := self._peek()) and tok == ('OP', '!'):
|
||||
self._advance()
|
||||
return ('unary', '!', self._parse_not())
|
||||
return self._parse_comp()
|
||||
|
||||
def _parse_comp(self) -> tuple[Any, ...]:
|
||||
left = self._parse_atom()
|
||||
if (tok := self._peek()) and tok[0] == 'OP' and tok[1] in ('==', '!=', '<', '>', '<=', '>=', '=~', '!~'):
|
||||
op = self._advance()[1]
|
||||
left = ('bin', op, left, self._parse_atom())
|
||||
return left
|
||||
|
||||
def _parse_atom(self) -> tuple[Any, ...]:
|
||||
tok = self._advance()
|
||||
ttype, val = tok
|
||||
if ttype in ('LIT', 'REGEX'):
|
||||
return ('lit', val)
|
||||
if ttype == 'IDENT':
|
||||
if self._peek() == ('[', '['):
|
||||
self._advance()
|
||||
idx_tok = self._advance()
|
||||
if self._advance() != (']', ']'): raise SyntaxError('Expected ]')
|
||||
return ('index', val, int(idx_tok[1]))
|
||||
return ('var', val)
|
||||
if ttype == '(':
|
||||
node = self._parse_or()
|
||||
if self._advance() != (')', ')'): raise SyntaxError('Expected )')
|
||||
return node
|
||||
raise SyntaxError(f'Unexpected token: {tok}')
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +0,0 @@
|
|||
bottom_fill_pattern
|
||||
bottom_solid_layers
|
||||
bottom_solid_min_thickness
|
||||
bridge_angle
|
||||
bridge_flow_ratio
|
||||
bridge_speed
|
||||
enable_dynamic_overhang_speeds
|
||||
external_perimeter_extrusion_width
|
||||
external_perimeter_speed
|
||||
external_perimeters_first
|
||||
extra_perimeters
|
||||
extra_perimeters_on_overhangs
|
||||
fill_angle
|
||||
fill_density
|
||||
fill_pattern
|
||||
fuzzy_skin
|
||||
fuzzy_skin_point_dist
|
||||
fuzzy_skin_thickness
|
||||
gap_fill_enabled
|
||||
gap_fill_speed
|
||||
infill_anchor
|
||||
infill_anchor_max
|
||||
infill_every_layers
|
||||
infill_extrusion_width
|
||||
infill_overlap
|
||||
infill_speed
|
||||
ironing
|
||||
ironing_flowrate
|
||||
ironing_spacing
|
||||
ironing_speed
|
||||
ironing_type
|
||||
only_one_perimeter_first_layer
|
||||
overhang_speed_0
|
||||
overhang_speed_1
|
||||
overhang_speed_2
|
||||
overhang_speed_3
|
||||
overhangs
|
||||
perimeter_extrusion_width
|
||||
perimeter_speed
|
||||
perimeters
|
||||
small_perimeter_speed
|
||||
solid_infill_below_area
|
||||
solid_infill_every_layers
|
||||
solid_infill_extrusion_width
|
||||
solid_infill_speed
|
||||
thin_walls
|
||||
top_fill_pattern
|
||||
top_infill_extrusion_width
|
||||
top_one_perimeter_type
|
||||
top_solid_infill_speed
|
||||
top_solid_layers
|
||||
top_solid_min_thickness
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
from configparser import ConfigParser, MissingSectionHeaderError
|
||||
from typing import Any
|
||||
|
||||
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
|
||||
"""Recursively merges inherited profiles into a single configuration dictionary."""
|
||||
if not (profile := profiles.get(id)):
|
||||
return {}
|
||||
if not profile.conf_dict:
|
||||
return {}
|
||||
|
||||
conf_current = profiles[id].conf_dict.copy()
|
||||
if conf_current.get('inherits', False):
|
||||
curr_category = id.split(":")[0]
|
||||
# Split on semicolon for multiple inheritance
|
||||
inherited_ids = [
|
||||
curr_category + ":" + inherit_id.strip()
|
||||
for inherit_id in conf_current['inherits'].split(';')
|
||||
]
|
||||
|
||||
merged_conf = {}
|
||||
for inherit_id in inherited_ids:
|
||||
if inherit_id in profiles:
|
||||
inherited_conf = generate_conf(profiles, inherit_id)
|
||||
merged_conf.update(inherited_conf)
|
||||
|
||||
merged_conf.update(conf_current)
|
||||
conf_current = merged_conf
|
||||
|
||||
conf_current.pop('inherits', None)
|
||||
conf_current.pop('renamed_from', None)
|
||||
return conf_current
|
||||
|
||||
def ini_to_dict(path: str) -> tuple[bool, dict[str, dict[str, str]]]:
|
||||
"""Parses a PrusaSlicer INI file into a dictionary of sections."""
|
||||
with open(path, 'r') as file:
|
||||
content = file.read()
|
||||
|
||||
config = ConfigParser(interpolation=None)
|
||||
try:
|
||||
config.read_string(content)
|
||||
has_header = True
|
||||
except MissingSectionHeaderError:
|
||||
# Determine category based on specific IDs in the content
|
||||
if re.search(r'^filament_settings_id', content, re.MULTILINE):
|
||||
cat = 'filament'
|
||||
elif re.search(r'^print_settings_id', content, re.MULTILINE):
|
||||
cat = 'print'
|
||||
elif re.search(r'^printer_settings_id', content, re.MULTILINE):
|
||||
cat = 'printer'
|
||||
else:
|
||||
raise ValueError(f"Unable to determine category for the INI file: {path}")
|
||||
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
default_section = f"[{cat}:{name}]\n" + content
|
||||
config.read_string(default_section)
|
||||
has_header = False
|
||||
|
||||
return has_header, {
|
||||
section: dict(sorted(config.items(section)))
|
||||
for section in sorted(config.sections())
|
||||
}
|
||||
|
||||
def ini_content_to_dict(path: str) -> dict[str, str]:
|
||||
"""Parses a PrusaSlicer INI file without sections into a flat dictionary."""
|
||||
with open(path, 'r') as file:
|
||||
content = file.read()
|
||||
|
||||
config = ConfigParser(interpolation=None)
|
||||
default_section = f"[default:default]\n" + content
|
||||
config.read_string(default_section)
|
||||
|
||||
return dict(sorted(config.items(config.sections()[0])))
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
/home/nicolas/Workspace/Design Projects/3d Print Library/Configs
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import bpy
|
||||
|
||||
from ..core.nodeslicer.sockets import SettingItem
|
||||
from ..registry import register_class
|
||||
|
||||
extruder_options: list[tuple[str, str, str]] = [
|
||||
("0", "Default Extruder", "Default Extruder"),
|
||||
("1", "Extruder 1", "Extruder 1"),
|
||||
("2", "Extruder 2", "Extruder 2"),
|
||||
("3", "Extruder 3", "Extruder 3"),
|
||||
("4", "Extruder 4", "Extruder 4"),
|
||||
("5", "Extruder 5", "Extruder 5"),
|
||||
]
|
||||
|
||||
object_type_options: list[tuple[str, str, str]] = [
|
||||
("ModelPart", "Part", "Model Part"),
|
||||
("NegativeVolume", "Negative Volume", "Negative Volume"),
|
||||
("ParameterModifier", "Modifier", "Modifier"),
|
||||
("SupportBlocker", "Support Blocker", "Support Blocker"),
|
||||
("SupportEnforcer", "Support Enforcer", "Support Enforcer"),
|
||||
("WipeTower", "Wipe Tower", "Wipe Tower"),
|
||||
("Ignore", "Ignore", "Ignore"),
|
||||
]
|
||||
|
||||
from ..core.nodeslicer.bpy_ns_types import NfEnumProperty, NfStringProperty
|
||||
|
||||
@register_class
|
||||
class SlicerObjectPropertyGroup(bpy.types.PropertyGroup):
|
||||
object_type: NfEnumProperty(name="Part type", default="ModelPart", items=object_type_options) # pyright: ignore[reportInvalidTypeForm]
|
||||
extruder: NfEnumProperty(name="Extruder", default="0", items=extruder_options) # pyright: ignore[reportInvalidTypeForm]
|
||||
search_term: NfStringProperty(name="Search") # pyright: ignore[reportInvalidTypeForm]
|
||||
modifiers: bpy.props.CollectionProperty(type=SettingItem) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import bpy
|
||||
from bpy.types import bpy_struct
|
||||
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
def _pv(inst: bpy_struct) -> 'PrusaSlicerTypes':
|
||||
return cast(PrusaSlicerTypes, inst)
|
||||
|
||||
def get_prop_bool(inst: bpy_struct) -> bool:
|
||||
return _pv(inst).param_value == '1'
|
||||
|
||||
def set_prop_bool(inst: bpy_struct, value: bool) -> None:
|
||||
_pv(inst).param_value = '1' if value else '0'
|
||||
|
||||
def get_prop_float(inst: bpy_struct) -> float:
|
||||
return float(_pv(inst).param_value) if _pv(inst).param_value else 0
|
||||
|
||||
def set_prop_float(inst: bpy_struct, value: float) -> None:
|
||||
_pv(inst).param_value = str(round(value, 5))
|
||||
|
||||
def get_prop_int(inst: bpy_struct) -> int:
|
||||
return int(_pv(inst).param_value) if _pv(inst).param_value else 0
|
||||
|
||||
def set_prop_int(inst: bpy_struct, value: int) -> None:
|
||||
_pv(inst).param_value = str(value)
|
||||
|
||||
def get_prop_perc(inst: bpy_struct) -> float:
|
||||
if _pv(inst).param_value:
|
||||
return float(_pv(inst).param_value.rstrip('%')) if _pv(inst).param_value else 0
|
||||
return 0
|
||||
|
||||
def set_prop_perc(inst: bpy_struct, value: float) -> None:
|
||||
_pv(inst).param_value = str(value)+'%'
|
||||
|
||||
def get_prop_angle(inst: bpy_struct) -> float:
|
||||
return (float(_pv(inst).param_value) * math.pi) / 180
|
||||
|
||||
def set_prop_angle(inst: bpy_struct, value: float) -> None:
|
||||
_pv(inst).param_value = str(round((value * 180) / math.pi, 5))
|
||||
|
||||
class PrusaSlicerTypes():
|
||||
param_value: bpy.props.StringProperty(name='') # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
param_bool: bpy.props.BoolProperty(name='',
|
||||
get=get_prop_bool,
|
||||
set=set_prop_bool,
|
||||
default=False,
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
param_float: bpy.props.FloatProperty(name='',
|
||||
get=get_prop_float,
|
||||
set=set_prop_float,
|
||||
soft_min=0,
|
||||
step = 5,
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
param_int: bpy.props.IntProperty(name='',
|
||||
get=get_prop_int,
|
||||
set=set_prop_int,
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
param_perc: bpy.props.FloatProperty(name='',
|
||||
get=get_prop_perc,
|
||||
set=set_prop_perc,
|
||||
subtype='PERCENTAGE',
|
||||
soft_min=0,
|
||||
soft_max=100,
|
||||
step = 5,
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
||||
param_angle: bpy.props.FloatProperty(name='',
|
||||
get=get_prop_angle,
|
||||
set=set_prop_angle,
|
||||
subtype='ANGLE',
|
||||
soft_min=0,
|
||||
soft_max=360,
|
||||
step = 1,
|
||||
) # pyright: ignore[reportInvalidTypeForm]
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to sys.path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.append(str(PROJECT_ROOT))
|
||||
|
||||
from infra.prusaslicer_profiles.cache import LocalCache
|
||||
from infra.prusaslicer_profiles.fields import search_in_db
|
||||
|
||||
class TestPrusaSlicerProfiles(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.cache = LocalCache()
|
||||
cls.test_dir = PROJECT_ROOT / "profiles" / "prusaslicer"
|
||||
cls.ini_file = cls.test_dir / "2.4.5.ini"
|
||||
|
||||
if not cls.ini_file.exists():
|
||||
raise FileNotFoundError(f"Test baseline {cls.ini_file} not found.")
|
||||
|
||||
cls.cache.load([cls.test_dir])
|
||||
|
||||
def test_profile_counts(self):
|
||||
"""Verify that a large number of profiles are loaded from the baseline INI."""
|
||||
self.assertGreater(len(self.cache.profiles), 6000)
|
||||
self.assertGreater(len(self.cache.printer_profiles), 200)
|
||||
self.assertGreater(len(self.cache.print_profiles), 500)
|
||||
self.assertGreater(len(self.cache.filament_profiles), 5000)
|
||||
|
||||
def test_inheritance_and_vendor(self):
|
||||
"""Verify that inheritance is resolved and vendor information is extracted."""
|
||||
# Find a known filament profile
|
||||
filament_keys = [k for k in self.cache.display_profiles.keys() if k.startswith("filament:")]
|
||||
self.assertGreater(len(filament_keys), 0)
|
||||
|
||||
profile = self.cache.profiles[filament_keys[0]]
|
||||
self.assertTrue(len(profile.all_conf_dict) > 0)
|
||||
self.assertTrue(profile.vendor or profile.all_conf_dict.get('filament_vendor') is None)
|
||||
|
||||
def test_fields_database(self):
|
||||
"""Verify that the fields database is accessible and searchable."""
|
||||
res = search_in_db("bed shape")
|
||||
self.assertIn("bed_shape", res)
|
||||
self.assertEqual(res["bed_shape"].get("label"), "Bed shape")
|
||||
|
||||
def test_compatibility(self):
|
||||
"""Verify that compatibility evaluation runs without errors and finds matches."""
|
||||
printer_keys = {k.removeprefix("printer:") for k in self.cache.printer_profiles.keys()}
|
||||
vendors = set(self.cache.vendors)
|
||||
|
||||
self.cache.evaluate_compatibility(printer_keys, vendors)
|
||||
|
||||
has_compatible = any(p.compatible_profiles for p in self.cache.printer_profiles.values())
|
||||
self.assertTrue(has_compatible, "No compatible profiles found for any printer.")
|
||||
|
||||
def test_all_conditions_in_baseline(self):
|
||||
"""Exhaustively parse and evaluate all unique conditions in 2.4.5.ini across all printer profiles."""
|
||||
from infra.prusaslicer_profiles.parser import Parser
|
||||
|
||||
conditions = [p.all_conf_dict.get('compatible_printers_condition') for p in self.cache.profiles.values()]
|
||||
unique_conditions = sorted(list(set(c for c in conditions if c)))
|
||||
self.assertGreater(len(unique_conditions), 300)
|
||||
|
||||
printers = [p for p in self.cache.profiles.values() if p.category == 'printer']
|
||||
self.assertGreater(len(printers), 200)
|
||||
|
||||
total_evals = 0
|
||||
positive_evals = 0
|
||||
for cond_str in unique_conditions:
|
||||
expr = Parser(cond_str).parse()
|
||||
self.assertIsNotNone(expr)
|
||||
for p in printers:
|
||||
res = bool(expr.eval(p.all_conf_dict))
|
||||
if res:
|
||||
positive_evals += 1
|
||||
total_evals += 1
|
||||
|
||||
self.assertEqual(total_evals, len(unique_conditions) * len(printers))
|
||||
self.assertEqual(positive_evals, 3722)
|
||||
|
||||
def test_parser_operators(self):
|
||||
"""Test individual parser operator semantics."""
|
||||
from infra.prusaslicer_profiles.parser import Parser
|
||||
|
||||
ctx = {
|
||||
"printer_model": "MK3S",
|
||||
"nozzle_diameter": "0.4,0.6",
|
||||
"single_extruder_multi_material": "0",
|
||||
"layer_height": "0.2"
|
||||
}
|
||||
|
||||
self.assertTrue(Parser('printer_model == "MK3S"').parse().eval(ctx))
|
||||
self.assertFalse(Parser('printer_model == "MK2"').parse().eval(ctx))
|
||||
self.assertTrue(Parser('printer_model =~ /MK(2|3).*/').parse().eval(ctx))
|
||||
self.assertFalse(Parser('printer_model =~ /MINI/').parse().eval(ctx))
|
||||
self.assertTrue(Parser('printer_model !~ /MINI/').parse().eval(ctx))
|
||||
self.assertTrue(Parser('nozzle_diameter[0] == 0.4').parse().eval(ctx))
|
||||
self.assertTrue(Parser('nozzle_diameter[1] == 0.6').parse().eval(ctx))
|
||||
self.assertTrue(Parser('nozzle_diameter[0] < 0.5 and nozzle_diameter[1] > 0.5').parse().eval(ctx))
|
||||
self.assertTrue(Parser('! single_extruder_multi_material').parse().eval(ctx))
|
||||
self.assertTrue(Parser('! (printer_model == "MK2" and nozzle_diameter[0] == 0.25)').parse().eval(ctx))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import unittest
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.append(str(PROJECT_ROOT.parent))
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
class DummyBase: pass
|
||||
mock_types = MagicMock()
|
||||
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
|
||||
mock_types.Node = type("Node", (DummyBase,), {})
|
||||
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
|
||||
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
|
||||
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
|
||||
mock_types.Operator = type("Operator", (DummyBase,), {})
|
||||
mock_types.Panel = type("Panel", (DummyBase,), {})
|
||||
mock_types.Object = MagicMock
|
||||
mock_types.Mesh = MagicMock
|
||||
|
||||
mock_bpy = MagicMock()
|
||||
mock_bpy.types = mock_types
|
||||
mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/")
|
||||
|
||||
sys.modules['bpy'] = mock_bpy
|
||||
sys.modules['bpy.types'] = mock_types
|
||||
sys.modules['bpy.props'] = MagicMock()
|
||||
sys.modules['bpy.utils'] = MagicMock()
|
||||
sys.modules['bpy.utils.previews'] = MagicMock()
|
||||
sys.modules['bpy.app'] = MagicMock()
|
||||
sys.modules['bpy.app.handlers'] = MagicMock()
|
||||
|
||||
from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_WipeTower, SLICENODE_OffsetObject
|
||||
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_AssignModifiers
|
||||
from NodeSlicer.infra.prusaslicer_profiles.models import ConfigWriter
|
||||
from NodeSlicer.infra._3mf_writer._3mf import write_model_xml, write_metadata_xml
|
||||
from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class TestWipeTowerNode(unittest.TestCase):
|
||||
def test_wipe_tower_node_output(self):
|
||||
node = SLICENODE_WipeTower()
|
||||
node.resolve = lambda key: 180.5 if key == "X Position" else 135.2
|
||||
|
||||
res = node.evaluate_output("Settings")
|
||||
self.assertIn("wipe_tower_position", res)
|
||||
self.assertEqual(res["wipe_tower_position"], (180.5, 135.2))
|
||||
|
||||
def test_offset_object_node_output(self):
|
||||
node = SLICENODE_OffsetObject()
|
||||
node.resolve = lambda key: {"X Offset": 12.5, "Y Offset": -5.0, "Z Offset": 2.0}.get(key, 0.0)
|
||||
|
||||
res = node.evaluate_output("Settings")
|
||||
self.assertIn("offset_object", res)
|
||||
self.assertEqual(res["offset_object"], (12.5, -5.0, 2.0))
|
||||
|
||||
def test_config_writer_excludes_special_keys(self):
|
||||
conf = {
|
||||
"layer_height": "0.2",
|
||||
"wipe_tower_position": "(180.5, 135.2)",
|
||||
"offset_object": "(12.5, -5.0, 2.0)",
|
||||
"nozzle_diameter": "0.4"
|
||||
}
|
||||
writer = ConfigWriter(conf)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
ini_path = Path(temp_dir) / "Slic3r_PE.config"
|
||||
writer.write_ini_3mf(ini_path)
|
||||
|
||||
with open(ini_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
self.assertIn("; layer_height = 0.2", content)
|
||||
self.assertIn("; nozzle_diameter = 0.4", content)
|
||||
self.assertNotIn("wipe_tower_position", content)
|
||||
self.assertNotIn("offset_object", content)
|
||||
|
||||
|
||||
class TestAssignModifiersNode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.so = SlicingObject()
|
||||
self.so.name = "TestPart"
|
||||
self.so.object_type = "ModelPart"
|
||||
self.so.extruder = "1"
|
||||
self.so.modifiers = [{"param_id": "initial_param", "param_value": "123"}]
|
||||
self.so.mesh = np.array([[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]], dtype=np.float64)
|
||||
|
||||
self.sc = SlicingCollection([], "TestColl")
|
||||
self.sc.objects = [self.so]
|
||||
self.sc.mesh_start_ids = [0]
|
||||
self.sc.mesh_end_ids = [1]
|
||||
|
||||
self.sg = SlicingGroup([])
|
||||
self.sg.collections = {"TestColl": self.sc}
|
||||
|
||||
def test_assign_modifiers_to_slicing_object(self):
|
||||
node = SLICENODE_AssignModifiers()
|
||||
node.part_type = "ParameterModifier"
|
||||
node.extruder = "3"
|
||||
node.resolve = lambda f: {
|
||||
"Geometry": self.so,
|
||||
"Modifiers": {"infill_density": "20%", "layer_height": "0.15"}
|
||||
}.get(f)
|
||||
|
||||
result_so = node.evaluate_output("Geometry")
|
||||
self.assertIsNotNone(result_so)
|
||||
self.assertEqual(result_so.object_type, "ParameterModifier")
|
||||
self.assertEqual(result_so.extruder, 3)
|
||||
|
||||
mod_dict = {m['param_id']: m['param_value'] for m in result_so.modifiers}
|
||||
self.assertEqual(mod_dict["initial_param"], "123")
|
||||
self.assertEqual(mod_dict["infill_density"], "20%")
|
||||
self.assertEqual(mod_dict["layer_height"], "0.15")
|
||||
|
||||
def test_assign_modifiers_to_slicing_group(self):
|
||||
node = SLICENODE_AssignModifiers()
|
||||
node.part_type = "NegativeVolume"
|
||||
node.extruder = "2"
|
||||
node.resolve = lambda f: {
|
||||
"Geometry": self.sg,
|
||||
"Modifiers": {"perimeters": "4"}
|
||||
}.get(f)
|
||||
|
||||
result_sg = node.evaluate_output("Geometry")
|
||||
self.assertIsNotNone(result_sg)
|
||||
res_so = result_sg.collections["TestColl"].objects[0]
|
||||
self.assertEqual(res_so.object_type, "NegativeVolume")
|
||||
self.assertEqual(res_so.extruder, 2)
|
||||
|
||||
mod_dict = {m['param_id']: m['param_value'] for m in res_so.modifiers}
|
||||
self.assertEqual(mod_dict["perimeters"], "4")
|
||||
|
||||
def test_3mf_metadata_xml_output(self):
|
||||
node = SLICENODE_AssignModifiers()
|
||||
node.part_type = "ParameterModifier"
|
||||
node.extruder = "2"
|
||||
node.resolve = lambda f: {
|
||||
"Geometry": self.sg,
|
||||
"Modifiers": {"fill_density": "30%"}
|
||||
}.get(f)
|
||||
|
||||
modified_sg = node.evaluate_output("Geometry")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
xml_path = Path(temp_dir) / "Slic3r_PE_model.config"
|
||||
write_metadata_xml(modified_sg, xml_path)
|
||||
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
volumes = root.findall(".//volume")
|
||||
self.assertEqual(len(volumes), 1)
|
||||
vol = volumes[0]
|
||||
|
||||
meta_entries = {m.get("key"): m.get("value") for m in vol.findall("metadata")}
|
||||
self.assertEqual(meta_entries.get("volume_type"), "ParameterModifier")
|
||||
self.assertEqual(meta_entries.get("modifier"), "1")
|
||||
self.assertEqual(meta_entries.get("extruder"), "2")
|
||||
self.assertEqual(meta_entries.get("fill_density"), "30%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
32
ui/panels.py
32
ui/panels.py
|
|
@ -1,32 +0,0 @@
|
|||
from bpy.types import Panel
|
||||
|
||||
from ..infra.blender.mixins import DrawCollectionMixin
|
||||
|
||||
from ..registry import register_class
|
||||
|
||||
@register_class
|
||||
class SlicerObjectPanel(Panel, DrawCollectionMixin):
|
||||
bl_label = "NodeSlicer Settings"
|
||||
bl_idname = f"OBJECT_PT_nodeslicer"
|
||||
bl_space_type = 'PROPERTIES'
|
||||
bl_region_type = 'WINDOW'
|
||||
bl_context = "object"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
if not layout: return
|
||||
|
||||
obj = context.object
|
||||
if not obj: return
|
||||
|
||||
pg = getattr(obj, 'nodeslicer', None)
|
||||
if not pg:
|
||||
layout.label(text="Property not registered")
|
||||
return
|
||||
|
||||
layout.prop(pg, "object_type", text="Object type")
|
||||
layout.prop(pg, "extruder", text="Extruder")
|
||||
|
||||
layout.label(text="Modifiers:")
|
||||
if pg.object_type in ['ParameterModifier', 'ModelPart']:
|
||||
self.draw_collection(pg.modifiers, layout)
|
||||
Loading…
Add table
Reference in a new issue