From 326b494771d6bd8faa959bed9bbaa882f671e3ec Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Mon, 3 Aug 2026 12:04:08 -0500 Subject: [PATCH 1/3] Add macOS support for per-user installation Co-Authored-By: Claude Fable 5 --- .github/workflows/core-tests.yml | 5 +- INSTALL.md | 2 + INSTALL_MACOS.md | 82 +++++ README.md | 11 +- scan2sketch/core/calibration_profile.py | 3 +- scan2sketch/core/external_backend.py | 18 +- scan2sketch/diagnostics.py | 4 +- scan2sketch/platform_paths.py | 41 +++ scan2sketch/ui/calibration_runtime.py | 3 +- scan2sketch/ui/grid_calibration_dialog.py | 6 +- scan2sketch/ui/paper_calibration_wizard.py | 4 +- scan2sketch/ui/ruler_calibration_dialog.py | 4 +- scripts/install_user_addon.py | 279 ++++++++++++++++++ scripts/uninstall_user_addon.py | 43 +++ tests/unit/test_calibration_runtime.py | 3 +- tests/unit/test_external_backend_selection.py | 30 +- tests/unit/test_platform_paths.py | 26 ++ 17 files changed, 523 insertions(+), 41 deletions(-) create mode 100644 INSTALL_MACOS.md create mode 100644 scan2sketch/platform_paths.py create mode 100644 scripts/install_user_addon.py create mode 100644 scripts/uninstall_user_addon.py create mode 100644 tests/unit/test_platform_paths.py diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index 7f0467a..37e13b2 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -9,7 +9,10 @@ permissions: jobs: unit-and-opencv: - runs-on: windows-latest + strategy: + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/INSTALL.md b/INSTALL.md index a8adc5b..2194655 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,5 +1,7 @@ # Install Scan2Sketch on Windows +macOS: [INSTALL_MACOS.md](INSTALL_MACOS.md) + ## Requirements - Windows 10 or 11 diff --git a/INSTALL_MACOS.md b/INSTALL_MACOS.md new file mode 100644 index 0000000..8dadc71 --- /dev/null +++ b/INSTALL_MACOS.md @@ -0,0 +1,82 @@ +# Install Scan2Sketch on macOS (beta) + +macOS support is newer than the Windows release. The workbench and its +isolated helper runtimes are the same on both platforms; please report macOS +issues on GitHub with a diagnostic report. + +## Requirements + +- macOS 13 or newer +- FreeCAD 1.1.1 installed in `/Applications` (the official `.app` bundle) +- internet access during setup +- several gigabytes of free space for the optional vision models + +Scan2Sketch does not modify the FreeCAD application bundle. The add-on and all +helper dependencies are installed for the current user only. + +## Recommended source installation + +1. On GitHub choose **Code → Download ZIP**, then fully extract the archive to + a normal folder. +2. Quit FreeCAD completely. +3. Open Terminal in the extracted folder. +4. Run the full portable CPU installation: + + ```bash + python3 scripts/install_user_addon.py --copy --force --full-vision + ``` + + (CUDA is not available on macOS; the CPU vision packages are used.) + +5. Wait for setup to finish. Model files are downloaded from their official + upstream sources and verified by SHA-256. +6. Start FreeCAD and select the **Scan2Sketch** workbench. +7. Complete scanner calibration on first use. + +The installer is readable source code. It creates isolated environments inside +the installed add-on using FreeCAD's bundled Python and does not install +global packages. + +If FreeCAD's Python is not found automatically, pass it explicitly: + +```bash +python3 scripts/install_user_addon.py --copy --force --full-vision \ + --freecad-python /Applications/FreeCAD.app/Contents/Resources/bin/python +``` + +If your FreeCAD user directory is not `~/Library/Application Support/FreeCAD/v1-1`, +pass `--user-appdata-dir` with the directory shown by +`App.getUserAppDataDir()` in FreeCAD's Python console. + +## Lightweight OpenCV mode + +To omit SAM/HQ-SAM and reduce installation size: + +```bash +python3 scripts/install_user_addon.py --copy --force +``` + +## Recommended scan settings + +- use Image Capture (built into macOS) or your scanner vendor's utility +- lossless PNG output, not JPEG +- 600 dpi for normal parts; 1200 dpi for small critical details +- disable automatic sharpening and enhancement where possible +- create a Scan2Sketch calibration profile for every DPI mode used + +## Updating + +Quit FreeCAD, extract the new source release and run the same command again +with `--copy --force`. Calibration profiles stored in the user profile remain. + +## Uninstalling + +```bash +python3 scripts/uninstall_user_addon.py +``` + +## Diagnostics + +Use **Scan2Sketch → Export diagnostic report** in FreeCAD. The report excludes +scans and CAD documents. Attach it and exact reproduction steps to a GitHub +issue when reporting a problem. diff --git a/README.md b/README.md index 6858dcc..b078525 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ part height from a single scan. ## Installation -The supported environment is currently **Windows with FreeCAD 1.1.1**. Install -from source using [INSTALL.md](INSTALL.md). The installation stays inside the +The primary supported environment is **Windows with FreeCAD 1.1.1**; macOS +support is in beta. Install from source using [INSTALL.md](INSTALL.md) +(Windows) or [INSTALL_MACOS.md](INSTALL_MACOS.md). The installation stays inside the current user's FreeCAD data directory and uses isolated Python environments; it does not modify FreeCAD program files or install global Python packages. @@ -50,6 +51,12 @@ caliper. & '.\.venv-opencv\Scripts\python.exe' -m unittest discover -s tests -p 'test_*.py' ``` +On macOS and Linux: + +```bash +./.venv-opencv/bin/python -m unittest discover -s tests -p 'test_*.py' +``` + Development setup is documented in [docs/development_setup.md](docs/development_setup.md). Contributions and reproducible issue reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/scan2sketch/core/calibration_profile.py b/scan2sketch/core/calibration_profile.py index ed3db14..ffdd192 100644 --- a/scan2sketch/core/calibration_profile.py +++ b/scan2sketch/core/calibration_profile.py @@ -9,6 +9,7 @@ import tempfile from .distortion_calibration import DistortionMap +from ..platform_paths import user_data_root from .models import ( ArcSegmentMM, BSplineSegmentMM, @@ -26,7 +27,7 @@ def default_profile_path(): import FreeCAD as App base = Path(App.getUserAppDataDir()) except ImportError: - base = Path(os.environ.get("APPDATA", Path.home())) / "FreeCAD" + base = user_data_root() / "FreeCAD" return base / "Scan2Sketch" / "scanner_calibration.json" diff --git a/scan2sketch/core/external_backend.py b/scan2sketch/core/external_backend.py index d3be628..66428ee 100644 --- a/scan2sketch/core/external_backend.py +++ b/scan2sketch/core/external_backend.py @@ -16,6 +16,7 @@ from .backends import AnalysisResult from .image_metadata import read_image_size from .models import scan_geometry_from_dict +from ..platform_paths import cache_root, venv_python def _hidden_process_options(): @@ -29,7 +30,7 @@ class ExternalOpenCVBackend: def __init__(self, project_root): self.project_root = Path(project_root) - sam_python = self.project_root / ".venv-sam" / "Scripts" / "python.exe" + sam_python = venv_python(self.project_root / ".venv-sam") sam_script = self.project_root / "scripts" / "analyze_hybrid_external.py" sam_model = self.project_root / "build" / "models" / "sam_vit_b_01ec64.pth" hq_model = self.project_root / "build" / "models" / "sam_hq_vit_tiny.pth" @@ -42,7 +43,7 @@ def __init__(self, project_root): self.python = ( sam_python if self.hybrid_enabled - else self.project_root / ".venv-opencv" / "Scripts" / "python.exe" + else venv_python(self.project_root / ".venv-opencv") ) self.script = ( sam_script @@ -61,9 +62,7 @@ def __init__(self, project_root): @staticmethod def _diagnostic_path(): - root = Path( - os.environ.get("LOCALAPPDATA", tempfile.gettempdir()) - ) / "Scan2Sketch" + root = cache_root() / "Scan2Sketch" root.mkdir(parents=True, exist_ok=True) return root / "last_analysis_diagnostic.json" @@ -86,9 +85,7 @@ def _preview_path(image_path): f"{source.stat().st_size}" ) digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16] - root = Path( - os.environ.get("LOCALAPPDATA", tempfile.gettempdir()) - ) / "Scan2Sketch" / "previews" + root = cache_root() / "Scan2Sketch" / "previews" root.mkdir(parents=True, exist_ok=True) return root / f"{digest}.jpg" @@ -224,10 +221,7 @@ def analyze_double_scan( "Der 180°-Doppelscan benötigt die vollständige Hybrid-Laufzeit." ) script = self.project_root / "scripts" / "analyze_double_scan_external.py" - cache = ( - Path(os.environ.get("LOCALAPPDATA", tempfile.gettempdir())) - / "Scan2Sketch" - ) + cache = cache_root() / "Scan2Sketch" cache.mkdir(parents=True, exist_ok=True) overlay = cache / "double_scan_overlay.png" with tempfile.TemporaryDirectory(prefix="scan2sketch-double-") as directory: diff --git a/scan2sketch/diagnostics.py b/scan2sketch/diagnostics.py index dff2089..cbee99f 100644 --- a/scan2sketch/diagnostics.py +++ b/scan2sketch/diagnostics.py @@ -14,6 +14,8 @@ import tempfile import zipfile +from .platform_paths import cache_root + _LOGGER = logging.getLogger("scan2sketch") _CONFIGURED = False @@ -24,7 +26,7 @@ def diagnostics_root(): override = os.environ.get("SCAN2SKETCH_DIAGNOSTICS_DIR") candidates = [ Path(override) if override else None, - Path(os.environ.get("LOCALAPPDATA", tempfile.gettempdir())) / "Scan2Sketch", + cache_root() / "Scan2Sketch", Path(tempfile.gettempdir()) / "Scan2Sketch", ] for root in candidates: diff --git a/scan2sketch/platform_paths.py b/scan2sketch/platform_paths.py new file mode 100644 index 0000000..8b03576 --- /dev/null +++ b/scan2sketch/platform_paths.py @@ -0,0 +1,41 @@ +"""Platform-dependent filesystem locations shared by core and UI code.""" + +from __future__ import annotations + +import os +from pathlib import Path +import sys +import tempfile + + +def venv_python(environment_root): + """Return the interpreter inside one isolated helper environment.""" + root = Path(environment_root) + if os.name == "nt": + return root / "Scripts" / "python.exe" + return root / "bin" / "python" + + +def cache_root(fallback=None): + """Return the per-user base directory for caches and diagnostics.""" + if os.name == "nt": + local = os.environ.get("LOCALAPPDATA") + if local: + return Path(local) + if fallback is not None: + return Path(fallback) + return Path(tempfile.gettempdir()) + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" + configured = os.environ.get("XDG_CACHE_HOME") + return Path(configured) if configured else Path.home() / ".cache" + + +def user_data_root(): + """Return the base directory holding the user's FreeCAD data folder.""" + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home())) + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" + configured = os.environ.get("XDG_DATA_HOME") + return Path(configured) if configured else Path.home() / ".local" / "share" diff --git a/scan2sketch/ui/calibration_runtime.py b/scan2sketch/ui/calibration_runtime.py index 887ef2c..8537652 100644 --- a/scan2sketch/ui/calibration_runtime.py +++ b/scan2sketch/ui/calibration_runtime.py @@ -6,6 +6,7 @@ from pathlib import Path from ..i18n import tr +from ..platform_paths import venv_python _SCRIPT_BY_MODE = { @@ -42,7 +43,7 @@ def calibration_command(project_root, mode, arguments=()): return [str(executable), mode, *map(str, arguments)] script = root / "scripts" / _SCRIPT_BY_MODE[mode] for environment in (".venv-sam", ".venv-opencv"): - python = root / environment / "Scripts" / "python.exe" + python = venv_python(root / environment) if python.is_file() and script.is_file(): return [str(python), str(script), *map(str, arguments)] raise RuntimeError(tr("grid.runtime_missing")) diff --git a/scan2sketch/ui/grid_calibration_dialog.py b/scan2sketch/ui/grid_calibration_dialog.py index 2d0d450..bc55e42 100644 --- a/scan2sketch/ui/grid_calibration_dialog.py +++ b/scan2sketch/ui/grid_calibration_dialog.py @@ -1,11 +1,11 @@ """Guided scanner-grid calibration dialog.""" from __future__ import annotations import json -import os from pathlib import Path import subprocess from ..i18n import tr +from ..platform_paths import cache_root, user_data_root from .background_analysis import run_with_progress from .icons import set_button_icon from .calibration_runtime import calibration_command @@ -24,7 +24,7 @@ def _profile_path(): import FreeCAD as App base = Path(App.getUserAppDataDir()) except ImportError: - base = Path(os.environ.get("APPDATA", Path.home())) / "FreeCAD" + base = user_data_root() / "FreeCAD" target = base / "Scan2Sketch" target.mkdir(parents=True, exist_ok=True) return target / "scanner_calibration.json" @@ -89,7 +89,7 @@ def _analyze(self): def operation(): preview_path = ( - Path(os.environ.get("LOCALAPPDATA", self.project_root / "build")) + cache_root(self.project_root / "build") / "Scan2Sketch" / "grid_preview.png" ) diff --git a/scan2sketch/ui/paper_calibration_wizard.py b/scan2sketch/ui/paper_calibration_wizard.py index 1b059e0..fb12d32 100644 --- a/scan2sketch/ui/paper_calibration_wizard.py +++ b/scan2sketch/ui/paper_calibration_wizard.py @@ -4,7 +4,6 @@ from datetime import datetime, timezone import json import math -import os from pathlib import Path import subprocess from types import SimpleNamespace @@ -13,6 +12,7 @@ from ..core.models import ScanGeometry from ..core.calibration_profile import save_calibration_profile from ..i18n import tr +from ..platform_paths import cache_root from .background_analysis import run_with_progress from .canvas import CorrectionCanvas from .grid_calibration_dialog import _profile_path @@ -141,7 +141,7 @@ def _load_current_scan(self): original_size = read_image_size(source) if not original_size: raise ValueError(tr("paper.image_failed")) - preview = Path(os.environ.get("LOCALAPPDATA", self.project_root / "build")) / "Scan2Sketch" / f"paper_{dpi}_preview.jpg" + preview = cache_root(self.project_root / "build") / "Scan2Sketch" / f"paper_{dpi}_preview.jpg" preview.parent.mkdir(parents=True, exist_ok=True) def operation(): subprocess.run(calibration_command(self.project_root, "preview", ["--image", str(source), "--output", str(preview), "--maximum", "4096"]), check=True, capture_output=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) diff --git a/scan2sketch/ui/ruler_calibration_dialog.py b/scan2sketch/ui/ruler_calibration_dialog.py index eb62e2b..05f29a4 100644 --- a/scan2sketch/ui/ruler_calibration_dialog.py +++ b/scan2sketch/ui/ruler_calibration_dialog.py @@ -1,12 +1,12 @@ """Automatic metric-ruler scanner calibration dialog.""" from __future__ import annotations import json -import os from pathlib import Path import subprocess from ..core.image_metadata import read_image_scale from ..i18n import tr +from ..platform_paths import cache_root from .background_analysis import run_with_progress from .icons import set_button_icon from .grid_calibration_dialog import _profile_path @@ -69,7 +69,7 @@ def _analyze(self): if not image_path.is_file(): self.summary.setText(tr("start.file_missing")); return def operation(): - preview = Path(os.environ.get("LOCALAPPDATA", self.project_root / "build")) / "Scan2Sketch" / "ruler_preview.png" + preview = cache_root(self.project_root / "build") / "Scan2Sketch" / "ruler_preview.png" preview.parent.mkdir(parents=True, exist_ok=True) completed = subprocess.run(calibration_command(self.project_root, "ruler", [ str(image_path), str(self.dpi.value()), "--preview", str(preview), diff --git a/scripts/install_user_addon.py b/scripts/install_user_addon.py new file mode 100644 index 0000000..4b43129 --- /dev/null +++ b/scripts/install_user_addon.py @@ -0,0 +1,279 @@ +"""Per-user Scan2Sketch installer for macOS and Linux. + +Mirrors scripts/install_user_addon.ps1: installs the add-on into the user's +FreeCAD Mod directory and creates isolated helper runtimes from FreeCAD's +bundled Python. It never modifies the FreeCAD application itself and never +installs global packages. On Windows use install_user_addon.ps1 instead. +""" + +from __future__ import annotations + +import argparse +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import urllib.request + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +COPIED_FILES = [ + "Init.py", "InitGui.py", "package.xml", "LICENSE", "THIRD_PARTY_NOTICES.md", + "README.md", "INSTALL.md", "INSTALL_DE.md", "UNINSTALL.md", "UNINSTALL_DE.md", + "requirements-opencv-spike.txt", "requirements-sam-spike.txt", +] +COPIED_DIRECTORIES = ["scan2sketch", "scripts"] + +MODEL_DOWNLOADS = [ + ( + "https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth", + "sam_vit_b_01ec64.pth", + "ec2df62732614e57411cdcf32a23ffdf28910380d03139ee0f4fcbe91eb8c912", + ), + ( + "https://huggingface.co/lkeab/hq-sam/resolve/main/sam_hq_vit_tiny.pth", + "sam_hq_vit_tiny.pth", + "0f32c075ccdd870ae54db2f7630e7a0878ede5a2b06d05d6fe02c65a82fb7196", + ), +] + + +def default_user_appdata_dir(): + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "FreeCAD" / "v1-1" + configured = os.environ.get("XDG_DATA_HOME") + base = Path(configured) if configured else Path.home() / ".local" / "share" + return base / "FreeCAD" / "v1-1" + + +def find_freecad_python(explicit): + if explicit: + path = Path(explicit) + if not path.is_file(): + raise SystemExit(f"FreeCAD Python was not found: {path}") + return path.resolve() + + candidates = [] + if sys.platform == "darwin": + application_roots = [Path("/Applications"), Path.home() / "Applications"] + for applications in application_roots: + for bundle in sorted(applications.glob("FreeCAD*.app"), reverse=True): + candidates.append(bundle / "Contents" / "Resources" / "bin" / "python") + else: + for name in ("freecad-python3", "python3"): + located = shutil.which(name) + if located: + candidates.append(Path(located)) + + for candidate in candidates: + if candidate.is_file(): + return candidate.resolve() + raise SystemExit( + "FreeCAD Python was not found. Install FreeCAD 1.1 or pass " + "--freecad-python explicitly." + ) + + +def is_scan2sketch_folder(path): + metadata = path / "package.xml" + if not metadata.is_file(): + return False + return "Scan2Sketch" in metadata.read_text(encoding="utf-8") + + +def run_checked(command, error): + completed = subprocess.run(command) + if completed.returncode != 0: + raise SystemExit(error) + + +def create_helper_venv(base_python, runtime): + helper_python = runtime / "bin" / "python" + if not helper_python.is_file(): + print(f"Creating the isolated runtime: {runtime.name}") + run_checked( + [str(base_python), "-m", "venv", "--without-pip", str(runtime)], + "Could not create the isolated helper runtime.", + ) + return helper_python + + +def pip_install(base_python, helper_python, arguments, error): + run_checked( + [ + str(base_python), "-m", "pip", "--python", str(helper_python), + "install", "--disable-pip-version-check", *arguments, + ], + error, + ) + + +def probe_runtime(helper_python, install_root): + probe = install_root / "scripts" / "probe_helper_environment.py" + completed = subprocess.run( + [str(helper_python), str(probe)], capture_output=True, text=True + ) + if completed.returncode != 0: + raise SystemExit( + "The isolated image-analysis runtime failed its validation:\n" + + completed.stderr + ) + + +def install_helper_runtime(options, base_python, install_root): + if options.skip_runtime: + return + runtime = install_root / ".venv-opencv" + helper_python = create_helper_venv(base_python, runtime) + print("Installing image-analysis packages locally (this can take a few minutes)...") + pip_install( + base_python, helper_python, + ["-r", str(install_root / "requirements-opencv-spike.txt")], + "Could not install the image-analysis packages.", + ) + probe_runtime(helper_python, install_root) + + +def download_verified_model(uri, destination, sha256): + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_file(): + digest = hashlib.sha256(destination.read_bytes()).hexdigest() + if digest == sha256: + return + print(f"Downloading verified model: {destination.name}") + urllib.request.urlretrieve(uri, destination) + digest = hashlib.sha256(destination.read_bytes()).hexdigest() + if digest != sha256: + destination.unlink() + raise SystemExit(f"Model checksum mismatch for {destination.name}.") + + +def install_full_vision_runtime(options, base_python, install_root): + if options.skip_runtime or not options.full_vision: + return + if options.vision_accelerator == "cuda" and sys.platform == "darwin": + raise SystemExit( + "CUDA is not available on macOS. Use --vision-accelerator cpu." + ) + runtime = install_root / ".venv-sam" + helper_python = create_helper_venv(base_python, runtime) + if options.vision_accelerator == "cuda": + print("Installing optional NVIDIA CUDA vision packages locally (download is large)...") + torch_packages = ["torch==2.5.1+cu121", "torchvision==0.20.1+cu121"] + torch_index = "https://download.pytorch.org/whl/cu121" + else: + print("Installing portable CPU vision packages locally (download is large)...") + if sys.platform == "darwin": + torch_packages = ["torch==2.5.1", "torchvision==0.20.1"] + else: + torch_packages = ["torch==2.5.1+cpu", "torchvision==0.20.1+cpu"] + torch_index = "https://download.pytorch.org/whl/cpu" + pip_install( + base_python, helper_python, + [*torch_packages, "--index-url", torch_index], + f"Could not install the {options.vision_accelerator} vision packages. " + "The existing FreeCAD installation was not modified.", + ) + pip_install( + base_python, helper_python, + ["-r", str(install_root / "requirements-sam-spike.txt")], + "Could not install SAM/HQ-SAM.", + ) + models = install_root / "build" / "models" + for uri, name, sha256 in MODEL_DOWNLOADS: + download_verified_model(uri, models / name, sha256) + probe_runtime(helper_python, install_root) + + +def remove_existing_target(target, force): + if not target.exists() and not target.is_symlink(): + return + if not force: + raise SystemExit( + f"Scan2Sketch already exists at {target}. " + "Run the installer again with --force to update it." + ) + if target.is_symlink(): + target.unlink() + elif is_scan2sketch_folder(target): + shutil.rmtree(target) + else: + raise SystemExit( + "Safety stop: the existing folder is not a verified " + f"Scan2Sketch installation: {target}" + ) + + +def install_copy(options, target): + remove_existing_target(target, options.force) + target.mkdir(parents=True) + for name in COPIED_FILES: + shutil.copy2(PROJECT_ROOT / name, target / name) + for name in COPIED_DIRECTORIES: + shutil.copytree(PROJECT_ROOT / name, target / name) + return target + + +def install_link(options, target): + if target.is_symlink() and target.resolve() == PROJECT_ROOT: + return PROJECT_ROOT + remove_existing_target(target, options.force) + target.symlink_to(PROJECT_ROOT, target_is_directory=True) + return PROJECT_ROOT + + +def parse_arguments(argv): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--user-appdata-dir", default=str(default_user_appdata_dir()), + help="FreeCAD user application-data directory (contains Mod)", + ) + parser.add_argument("--copy", action="store_true", + help="copy a release instead of linking this checkout") + parser.add_argument("--force", action="store_true", + help="replace an existing Scan2Sketch installation") + parser.add_argument("--skip-runtime", action="store_true", + help="skip creating the isolated helper runtimes") + parser.add_argument("--full-vision", action="store_true", + help="also install SAM/HQ-SAM and download the models") + parser.add_argument("--vision-accelerator", choices=("cpu", "cuda"), + default="cpu") + parser.add_argument("--freecad-python", default="", + help="explicit path to FreeCAD's bundled Python") + return parser.parse_args(argv) + + +def main(argv=None): + if os.name == "nt": + raise SystemExit("On Windows use scripts/install_user_addon.ps1.") + options = parse_arguments(argv) + if options.copy and options.skip_runtime: + raise SystemExit( + "Safety stop: --copy and --skip-runtime together would create an " + "installation without image analysis. Use development-link mode " + "for fast local updates." + ) + base_python = None + if not options.skip_runtime: + base_python = find_freecad_python(options.freecad_python) + mod_root = Path(options.user_appdata_dir).expanduser() / "Mod" + mod_root.mkdir(parents=True, exist_ok=True) + target = mod_root / "Scan2Sketch" + + if options.copy: + install_root = install_copy(options, target) + else: + install_root = install_link(options, target) + install_helper_runtime(options, base_python, install_root) + install_full_vision_runtime(options, base_python, install_root) + if options.copy: + print(f"Scan2Sketch installed for the current user: {target}") + else: + print(f"Scan2Sketch development link installed: {target} -> {install_root}") + print("Restart FreeCAD, then select the Scan2Sketch workbench.") + + +if __name__ == "__main__": + main() diff --git a/scripts/uninstall_user_addon.py b/scripts/uninstall_user_addon.py new file mode 100644 index 0000000..9a90d0d --- /dev/null +++ b/scripts/uninstall_user_addon.py @@ -0,0 +1,43 @@ +"""Per-user Scan2Sketch uninstaller for macOS and Linux. + +Mirrors scripts/uninstall_user_addon.ps1. On Windows use that script instead. +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from install_user_addon import default_user_appdata_dir, is_scan2sketch_folder + + +def main(argv=None): + if os.name == "nt": + raise SystemExit("On Windows use scripts/uninstall_user_addon.ps1.") + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--user-appdata-dir", default=str(default_user_appdata_dir()), + help="FreeCAD user application-data directory (contains Mod)", + ) + options = parser.parse_args(argv) + target = Path(options.user_appdata_dir).expanduser() / "Mod" / "Scan2Sketch" + if not target.exists() and not target.is_symlink(): + print("Scan2Sketch is not installed for this user.") + return + if target.is_symlink(): + target.unlink() + elif is_scan2sketch_folder(target): + shutil.rmtree(target) + else: + raise SystemExit( + f"Safety stop: {target} is not a verified Scan2Sketch installation." + ) + print("Scan2Sketch was removed. FreeCAD program files were not changed.") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/test_calibration_runtime.py b/tests/unit/test_calibration_runtime.py index 66067d4..ad5a1f7 100644 --- a/tests/unit/test_calibration_runtime.py +++ b/tests/unit/test_calibration_runtime.py @@ -4,6 +4,7 @@ import unittest from unittest.mock import patch +from scan2sketch.platform_paths import venv_python from scan2sketch.ui.calibration_runtime import calibration_command @@ -11,7 +12,7 @@ class CalibrationRuntimeTests(unittest.TestCase): def test_development_runtime_uses_isolated_python_and_script(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - python = root / ".venv-opencv" / "Scripts" / "python.exe" + python = venv_python(root / ".venv-opencv") script = root / "scripts" / "analyze_ruler_calibration.py" python.parent.mkdir(parents=True); script.parent.mkdir(parents=True) python.touch(); script.touch() diff --git a/tests/unit/test_external_backend_selection.py b/tests/unit/test_external_backend_selection.py index 21ebda2..cb75ebb 100644 --- a/tests/unit/test_external_backend_selection.py +++ b/tests/unit/test_external_backend_selection.py @@ -3,42 +3,42 @@ import tempfile from scan2sketch.core.external_backend import ExternalOpenCVBackend +from scan2sketch.platform_paths import venv_python class ExternalBackendSelectionTests(unittest.TestCase): - def _touch(self, root, relative): - path = root / relative + def _touch(self, path): path.parent.mkdir(parents=True, exist_ok=True) path.touch() def test_hybrid_runtime_is_preferred_when_complete(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - for relative in ( - ".venv-sam/Scripts/python.exe", - "scripts/analyze_hybrid_external.py", - "build/models/sam_vit_b_01ec64.pth", - "build/models/sam_hq_vit_tiny.pth", - "scripts/analyze_external.py", + for path in ( + venv_python(root / ".venv-sam"), + root / "scripts/analyze_hybrid_external.py", + root / "build/models/sam_vit_b_01ec64.pth", + root / "build/models/sam_hq_vit_tiny.pth", + root / "scripts/analyze_external.py", ): - self._touch(root, relative) + self._touch(path) backend = ExternalOpenCVBackend(root) self.assertTrue(backend.hybrid_enabled) self.assertTrue(backend.triple_hybrid_enabled) - self.assertEqual(backend.python, root / ".venv-sam/Scripts/python.exe") + self.assertEqual(backend.python, venv_python(root / ".venv-sam")) self.assertEqual(backend.script, root / "scripts/analyze_hybrid_external.py") def test_incomplete_hybrid_runtime_uses_opencv_fallback(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - for relative in ( - ".venv-opencv/Scripts/python.exe", - "scripts/analyze_external.py", + for path in ( + venv_python(root / ".venv-opencv"), + root / "scripts/analyze_external.py", ): - self._touch(root, relative) + self._touch(path) backend = ExternalOpenCVBackend(root) self.assertFalse(backend.hybrid_enabled) - self.assertEqual(backend.python, root / ".venv-opencv/Scripts/python.exe") + self.assertEqual(backend.python, venv_python(root / ".venv-opencv")) def test_mojibaked_unicode_filename_is_recovered(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/unit/test_platform_paths.py b/tests/unit/test_platform_paths.py new file mode 100644 index 0000000..c6db7e0 --- /dev/null +++ b/tests/unit/test_platform_paths.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path +import unittest + +from scan2sketch.platform_paths import cache_root, user_data_root, venv_python + + +class PlatformPathTests(unittest.TestCase): + def test_venv_python_matches_the_running_platform_layout(self): + python = venv_python(Path("root") / ".venv-opencv") + if os.name == "nt": + self.assertEqual(python, Path("root/.venv-opencv/Scripts/python.exe")) + else: + self.assertEqual(python, Path("root/.venv-opencv/bin/python")) + + def test_cache_root_is_a_per_user_absolute_directory(self): + root = cache_root() + self.assertTrue(root.is_absolute()) + + def test_user_data_root_is_a_per_user_absolute_directory(self): + root = user_data_root() + self.assertTrue(root.is_absolute()) + + +if __name__ == "__main__": + unittest.main() From a45c5b802884096366d8a4ea4ec0ec5a741f67a9 Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Mon, 3 Aug 2026 15:21:43 -0500 Subject: [PATCH 2/3] Declare Shapely test dependency Co-Authored-By: Claude Fable 5 --- requirements-opencv-spike.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements-opencv-spike.txt b/requirements-opencv-spike.txt index 38b62c3..2456098 100644 --- a/requirements-opencv-spike.txt +++ b/requirements-opencv-spike.txt @@ -3,3 +3,7 @@ opencv-python-headless==5.0.0.93 numpy==2.4.6 scipy==1.16.3 Pillow>=10,<13 +# Inside FreeCAD the robust XY offset (scan2sketch/core/offset.py) uses the +# Shapely that FreeCAD 1.1 ships; declared here so the FreeCAD-independent +# test environment matches. Pinned to the version bundled with FreeCAD 1.1.1. +shapely==2.1.2 From bcdec05f7378ced02a0eafe5e5ef09487f096a37 Mon Sep 17 00:00:00 2001 From: Evan Jones Date: Mon, 3 Aug 2026 16:03:47 -0500 Subject: [PATCH 3/3] Auto-detect the FreeCAD user directory and guard Intel macOS installs Co-Authored-By: Claude Fable 5 --- INSTALL_MACOS.md | 14 +++++--- scripts/install_user_addon.py | 63 ++++++++++++++++++++++++++++++--- scripts/uninstall_user_addon.py | 21 ++++++++--- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/INSTALL_MACOS.md b/INSTALL_MACOS.md index 8dadc71..11052ae 100644 --- a/INSTALL_MACOS.md +++ b/INSTALL_MACOS.md @@ -6,11 +6,15 @@ issues on GitHub with a diagnostic report. ## Requirements -- macOS 13 or newer +- Apple silicon: macOS 13 or newer; Intel: macOS 14 or newer - FreeCAD 1.1.1 installed in `/Applications` (the official `.app` bundle) - internet access during setup - several gigabytes of free space for the optional vision models +Intel Macs support the lightweight OpenCV mode only: PyTorch no longer +publishes Intel macOS builds, so the optional `--full-vision` runtime +requires an Apple silicon Mac. + Scan2Sketch does not modify the FreeCAD application bundle. The add-on and all helper dependencies are installed for the current user only. @@ -44,9 +48,11 @@ python3 scripts/install_user_addon.py --copy --force --full-vision \ --freecad-python /Applications/FreeCAD.app/Contents/Resources/bin/python ``` -If your FreeCAD user directory is not `~/Library/Application Support/FreeCAD/v1-1`, -pass `--user-appdata-dir` with the directory shown by -`App.getUserAppDataDir()` in FreeCAD's Python console. +The installer asks FreeCAD (via `freecadcmd`) for the user directory that +your FreeCAD version actually reads, so upgrades that change the versioned +directory name are handled automatically. To override it, pass +`--user-appdata-dir` with the directory shown by `App.getUserAppDataDir()` +in FreeCAD's Python console. ## Lightweight OpenCV mode diff --git a/scripts/install_user_addon.py b/scripts/install_user_addon.py index 4b43129..0056d22 100644 --- a/scripts/install_user_addon.py +++ b/scripts/install_user_addon.py @@ -12,9 +12,11 @@ import hashlib import os from pathlib import Path +import platform import shutil import subprocess import sys +import tempfile import urllib.request PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -48,6 +50,45 @@ def default_user_appdata_dir(): return base / "FreeCAD" / "v1-1" +def detect_user_appdata_dir(freecad_python): + """Ask the installed FreeCAD for its user data directory. + + FreeCAD versions its per-user directory (v1-1, v1-2, ...), so asking + freecadcmd keeps the installer correct across FreeCAD upgrades. Falls + back to the FreeCAD 1.1 default when freecadcmd is unavailable. + """ + candidates = [] + if freecad_python is not None: + candidates.append(freecad_python.parent / "freecadcmd") + located = shutil.which("freecadcmd") + if located: + candidates.append(Path(located)) + probe = "import FreeCAD as App\nprint('S2S_USERDATA=' + App.getUserAppDataDir())\n" + for freecadcmd in candidates: + if not freecadcmd.is_file(): + continue + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as handle: + handle.write(probe) + script = handle.name + try: + completed = subprocess.run( + [str(freecadcmd), script], + capture_output=True, text=True, timeout=180, + ) + except (OSError, subprocess.TimeoutExpired): + continue + finally: + os.unlink(script) + for line in completed.stdout.splitlines(): + if line.startswith("S2S_USERDATA="): + return Path(line.split("=", 1)[1]) + print( + "Warning: could not query freecadcmd for the FreeCAD user directory; " + f"assuming {default_user_appdata_dir()}" + ) + return default_user_appdata_dir() + + def find_freecad_python(explicit): if explicit: path = Path(explicit) @@ -157,6 +198,12 @@ def install_full_vision_runtime(options, base_python, install_root): raise SystemExit( "CUDA is not available on macOS. Use --vision-accelerator cpu." ) + if sys.platform == "darwin" and platform.machine() == "x86_64": + raise SystemExit( + "PyTorch provides no Intel macOS builds for the pinned version, so " + "--full-vision requires an Apple silicon Mac. Install without " + "--full-vision to use the lightweight OpenCV mode." + ) runtime = install_root / ".venv-sam" helper_python = create_helper_venv(base_python, runtime) if options.vision_accelerator == "cuda": @@ -227,8 +274,9 @@ def install_link(options, target): def parse_arguments(argv): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( - "--user-appdata-dir", default=str(default_user_appdata_dir()), - help="FreeCAD user application-data directory (contains Mod)", + "--user-appdata-dir", default="", + help="FreeCAD user application-data directory (contains Mod); " + "auto-detected via freecadcmd when omitted", ) parser.add_argument("--copy", action="store_true", help="copy a release instead of linking this checkout") @@ -256,9 +304,16 @@ def main(argv=None): "for fast local updates." ) base_python = None - if not options.skip_runtime: + try: base_python = find_freecad_python(options.freecad_python) - mod_root = Path(options.user_appdata_dir).expanduser() / "Mod" + except SystemExit: + if not options.skip_runtime: + raise + if options.user_appdata_dir: + user_appdata = Path(options.user_appdata_dir).expanduser() + else: + user_appdata = detect_user_appdata_dir(base_python) + mod_root = user_appdata / "Mod" mod_root.mkdir(parents=True, exist_ok=True) target = mod_root / "Scan2Sketch" diff --git a/scripts/uninstall_user_addon.py b/scripts/uninstall_user_addon.py index 9a90d0d..278d4ac 100644 --- a/scripts/uninstall_user_addon.py +++ b/scripts/uninstall_user_addon.py @@ -12,7 +12,11 @@ import sys sys.path.insert(0, str(Path(__file__).resolve().parent)) -from install_user_addon import default_user_appdata_dir, is_scan2sketch_folder +from install_user_addon import ( + detect_user_appdata_dir, + find_freecad_python, + is_scan2sketch_folder, +) def main(argv=None): @@ -20,11 +24,20 @@ def main(argv=None): raise SystemExit("On Windows use scripts/uninstall_user_addon.ps1.") parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( - "--user-appdata-dir", default=str(default_user_appdata_dir()), - help="FreeCAD user application-data directory (contains Mod)", + "--user-appdata-dir", default="", + help="FreeCAD user application-data directory (contains Mod); " + "auto-detected via freecadcmd when omitted", ) options = parser.parse_args(argv) - target = Path(options.user_appdata_dir).expanduser() / "Mod" / "Scan2Sketch" + if options.user_appdata_dir: + user_appdata = Path(options.user_appdata_dir).expanduser() + else: + try: + freecad_python = find_freecad_python("") + except SystemExit: + freecad_python = None + user_appdata = detect_user_appdata_dir(freecad_python) + target = user_appdata / "Mod" / "Scan2Sketch" if not target.exists() and not target.is_symlink(): print("Scan2Sketch is not installed for this user.") return