Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/core-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Install Scan2Sketch on Windows

macOS: [INSTALL_MACOS.md](INSTALL_MACOS.md)

## Requirements

- Windows 10 or 11
Expand Down
88 changes: 88 additions & 0 deletions INSTALL_MACOS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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

- 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.

## 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
```

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

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.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions requirements-opencv-spike.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion scan2sketch/core/calibration_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import tempfile

from .distortion_calibration import DistortionMap
from ..platform_paths import user_data_root
from .models import (
ArcSegmentMM,
BSplineSegmentMM,
Expand All @@ -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"


Expand Down
18 changes: 6 additions & 12 deletions scan2sketch/core/external_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion scan2sketch/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import tempfile
import zipfile

from .platform_paths import cache_root


_LOGGER = logging.getLogger("scan2sketch")
_CONFIGURED = False
Expand All @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions scan2sketch/platform_paths.py
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion scan2sketch/ui/calibration_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

from ..i18n import tr
from ..platform_paths import venv_python


_SCRIPT_BY_MODE = {
Expand Down Expand Up @@ -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"))
6 changes: 3 additions & 3 deletions scan2sketch/ui/grid_calibration_dialog.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
)
Expand Down
4 changes: 2 additions & 2 deletions scan2sketch/ui/paper_calibration_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions scan2sketch/ui/ruler_calibration_dialog.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading