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
20 changes: 20 additions & 0 deletions .github/workflows/freecad.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: FreeCAD setup
on:
push:
pull_request:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Validate package
run: |
python3 -m py_compile freecad/install.py freecad/export.py freecad/launcher.py
python3 freecad/tests/test_profile.py
python3 freecad/tests/test_install.py
python3 freecad/tests/test_launcher.py
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
Portable fixes and configuration for Nick's Omarchy desktops.

- [Fusion 360 window compatibility](fusion360/): preserve Fusion palettes, transparency and dialog input under Wine/XWayland on Hyprland.
- [FreeCAD setup](freecad/): portable Preference Pack, Omarchy font scaling, and a repeatable install/export/restore workflow.
85 changes: 85 additions & 0 deletions freecad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Nick's FreeCAD setup

A portable **NickOmarchy Preference Pack**, the Omarchy text-scaling launcher, and a home for future modeling macros. Baseline captured from saved FreeCAD 1.1.4 settings on November 8, 2026.

## New laptop

Install native FreeCAD on Omarchy/Arch first, then register this setup:

```bash
omarchy pkg add freecad
git clone https://github.com/nickfoy/sharedomarchy.git
cd sharedomarchy
python3 freecad/install.py install
```

Restart FreeCAD from its app-launcher entry when convenient. In **Edit → Preferences → General**, find **NickOmarchy** and apply it explicitly to restore the baseline. Installing this package registers it but does **not** overwrite your live preferences or close your documents. FreeCAD may request a restart after applying a pack.

The installer queries `FreeCADCmd --get-config UserAppData` to find the version-specific data directory. On the tested system it is `~/.local/share/FreeCAD/v1-1/`. Runtime prerequisites are native `/usr/bin/FreeCAD`, `FreeCADCmd`, Python 3.10+, `gsettings`, and the Arch desktop entry `/usr/share/applications/org.freecad.FreeCAD.desktop`.

## Baseline contents

- Part Design startup workbench; Standard millimeter/kilogram/second units.
- CAD navigation style.
- FreeCAD Dark theme, built-in stylesheet names and view/background/selection colors.
- Saved editor/console appearance, tree font/padding and navigation-cube appearance where present.
- Toolbar visibility. Screen coordinates, window dimensions and serialized Qt layouts are excluded.

The exporter uses a defined set of preference groups/keys. It excludes recent files/macros, document paths, plugin paths, language, account data, unknown text preferences and absolute stylesheet paths. A future preference outside this set must be deliberately added to `export.py` and its tests. It does not copy all of `user.cfg`.

**Font scaling is independent of the pack.** The launcher reads `org.gnome.desktop.interface text-scaling-factor` at each start and supplies `QT_FONT_DPI=round(96×scale)`. On the tested desktop the factor was `0.8182`, producing `79` DPI instead of Qt's default `96`. An explicit existing `QT_FONT_DPI` is respected. If settings are unavailable or invalid it falls back to Qt's default. The default GTK font family still comes from the desktop. Font changes require restarting FreeCAD; this is not a live settings watcher. Launching `/usr/bin/FreeCAD` directly bypasses the wrapper.

## Save future adjustments

1. Make your FreeCAD changes, save your documents, then close FreeCAD so its current preferences are written.
2. Export selected saved preferences into the version-controlled pack:

```bash
cd ~/Work/sharedomarchy # or wherever you cloned it
python3 freecad/export.py --source ~/.config/FreeCAD/v1-1/user.cfg
git diff -- freecad/pack/NickOmarchy/NickOmarchy.cfg
```

Adjust the `v1-1` path for a future FreeCAD version. Inspect the diff before publishing, especially if you extend the export rules.
3. Run the scoped tests below, then commit/push the pack changes.
4. Run `python3 freecad/install.py install` to update the registered pack. Applying the pack is optional on the laptop where you just made the settings; those preferences are already active there.

You can also use FreeCAD's **Save new…** Preference Pack UI to capture selected preferences. Review any resulting CFG before adding it to this public repository. Keep full `user.cfg` backups local/private because they can contain recent file paths and other personal state.

## Files and removal

The installer backs up and manages these files only:

- `~/.local/bin/freecad-omarchy`
- `~/.local/share/applications/org.freecad.FreeCAD.desktop`
- `<FreeCAD UserAppData>/Preference Packs/NickOmarchy/package.xml`
- `<FreeCAD UserAppData>/Preference Packs/NickOmarchy/LICENSE`
- `<FreeCAD UserAppData>/Preference Packs/NickOmarchy/NickOmarchy/NickOmarchy.cfg`

State and retained backups are under `~/.local/share/sharedomarchy-freecad/`. Reinstalling preserves the original backup; local modifications to managed files cause a refusal rather than being discarded. Missing backups prevent removal before any managed files are changed. Symlinked managed files are rejected; integrate them through your dotfile manager instead.

```bash
python3 freecad/install.py uninstall
```

Uninstall restores pre-existing files or removes files the package created. It leaves your actual FreeCAD preferences and documents unchanged. If you applied NickOmarchy and want to undo those preferences, restore the timestamped backup FreeCAD made when applying the pack (under `SavedPreferencePacks/Backups` in UserAppData). FreeCAD normally retains these for one week; keep a separate local backup for longer retention. Uninstalling the package does not undo applied preferences.

For offline staging/tests, `--home /target/home --data-dir /target/freecad-data --desktop-template /path/to/upstream.desktop` avoids querying a running installation. After a FreeCAD upgrade changes the data directory, uninstall the old registration before installing to the new version.

## Macros

`macros/` is reserved for reusable modeling automation. No startup/pre/post macro is installed: restoring preferences should not run modeling code.

## Validation

From the repo root:

```bash
python3 freecad/tests/test_profile.py
python3 freecad/tests/test_install.py
python3 freecad/tests/test_launcher.py
```

The pack was also loaded by FreeCADCmd **1.1.4** using an isolated temporary user configuration, with the live settings hash verified unchanged. The app and font launcher were commissioned on Hyprland **0.56.2**, native Wayland, scale **1.25**. That commissioning included editable parameter recompute, save/reopen, STEP round-trip, watertight STL export and GUI resize/redraw. Test models/screenshots and full workstation settings are not included here.

On a new laptop, verify launcher startup, text sizing, pack appearance in Preferences, apply/restart, toolbar/navigation settings, and model save/reopen. Unit tests do not simulate Qt, the Preference Pack selection UI or a compositor.
75 changes: 75 additions & 0 deletions freecad/export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Export selected saved FreeCAD preferences; never copy an entire user.cfg."""
import argparse
import copy
from pathlib import Path
import xml.etree.ElementTree as ET

# Unlisted groups and strings stay private. Numeric appearance values have no
# file paths, but geometry/column widths are intentionally not in these groups.
NUMERIC_GROUPS = {'View', 'Editor', 'Themes', 'OutputWindow', 'NaviCube'}
KEYS = {
'General': {'AutoloadModule', 'FontSize'},
'Units': {'UserSchema', 'Decimals'},
'View': {'NavigationStyle'},
'TreeView': {'FontSize', 'ItemBackgroundPadding', 'TreeActiveColor', 'TreeEditColor'},
'MainWindow': {'Theme', 'QtStyle', 'StyleSheet', 'OverlayActiveStyleSheet',
'InputHintEnabled', 'QuickMeasureEnabled', 'StatusBar'},
'Editor': {'Font'},
}


def portable_profile(source):
result = ET.Element('FCParameters')
root = ET.SubElement(result, 'FCParamGroup', Name='Root')
base = ET.SubElement(root, 'FCParamGroup', Name='BaseApp')
prefs = ET.SubElement(base, 'FCParamGroup', Name='Preferences')
src_base = source.find("./FCParamGroup[@Name='Root']/FCParamGroup[@Name='BaseApp']")
if src_base is None:
raise ValueError('Not a FreeCAD user.cfg: missing Root/BaseApp')
src_prefs = src_base.find("FCParamGroup[@Name='Preferences']")
if src_prefs is None:
raise ValueError('Missing Preferences group')
count = 0
for group in src_prefs:
name = group.get('Name')
selected = []
for entry in group:
key = entry.get('Name', '')
allowed = key in KEYS.get(name, set())
allowed |= name in NUMERIC_GROUPS and entry.tag in {'FCInt', 'FCUInt', 'FCFloat', 'FCBool'}
if not allowed or entry.tag == 'FCParamGroup':
continue
if entry.tag == 'FCText':
text = entry.text or ''
if any(token in text for token in ('/', '\\', ':')) and key != 'NavigationStyle':
continue
selected.append(copy.deepcopy(entry))
if selected:
dest = ET.SubElement(prefs, 'FCParamGroup', Name=name)
dest.extend(selected)
count += len(selected)
# Visibility toggles are portable; serialized Qt layout and window positions are not.
toolbars = src_base.find("FCParamGroup[@Name='MainWindow']/FCParamGroup[@Name='Toolbars']")
if toolbars is not None:
dest = ET.SubElement(ET.SubElement(base, 'FCParamGroup', Name='MainWindow'), 'FCParamGroup', Name='Toolbars')
dest.extend(copy.deepcopy(e) for e in toolbars if e.tag == 'FCBool')
if not count:
raise ValueError('No supported preferences found')
ET.indent(result, space=' ')
return result


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True, help='Saved user.cfg; close FreeCAD first to flush changes')
parser.add_argument('--output', type=Path, default=Path(__file__).resolve().parent / 'pack/NickOmarchy/NickOmarchy.cfg')
args = parser.parse_args()
result = portable_profile(ET.parse(args.source).getroot())
args.output.parent.mkdir(parents=True, exist_ok=True)
ET.ElementTree(result).write(args.output, encoding='UTF-8', xml_declaration=True)
print(f'Exported portable baseline to {args.output}')


if __name__ == '__main__':
main()
115 changes: 115 additions & 0 deletions freecad/install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Register Nick's FreeCAD Preference Pack and install the Omarchy launcher."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys


def sha(data):
return hashlib.sha256(data).hexdigest()


def desktop_quote(path):
text = str(path).replace('%', '%%')
for char in ('\\', '"', '`', '$'):
text = text.replace(char, '\\' + char)
return '"' + text + '"'


def manage(args):
home = args.home.resolve()
state_file = home / '.local/share/sharedomarchy-freecad/state.json'
state = json.loads(state_file.read_text()) if state_file.exists() else None
if args.action == 'uninstall':
if not state:
print('No managed FreeCAD setup to remove.')
return
for target, record in state.items():
path = Path(target)
if path.is_symlink() or not path.is_file() or sha(path.read_bytes()) != record['sha']:
raise RuntimeError(f'Preserving edited/missing file: {target}. Reconcile it before removal.')
# Validate all backup reads before making any changes.
originals = {target: Path(r['backup']).read_bytes() if r['backup'] else None
for target, r in state.items()}
for target, original in originals.items():
path = Path(target)
if original is None:
path.unlink()
else:
path.write_bytes(original)
shutil.copystat(state[target]['backup'], path)
state_file.unlink()
print('Removed managed files and restored originals; preferences and backups retained.')
return

if args.home.resolve() != Path.home().resolve() and args.data_dir is None:
raise RuntimeError('--home for another user requires --data-dir for offline staging.')
data_dir = args.data_dir
if data_dir is None:
value = subprocess.check_output(['FreeCADCmd', '--get-config', 'UserAppData'], text=True).strip()
data_dir = Path(value)
if not data_dir.is_absolute():
raise RuntimeError('Could not determine the FreeCAD user data directory.')
pack_dir = data_dir.resolve() / 'Preference Packs/NickOmarchy'
source = Path(__file__).resolve().parent
template = args.desktop_template.read_text()
wrapper = home / '.local/bin/freecad-omarchy'
desktop, count = re.subn(r'^Exec=(?:FreeCAD|freecad|/usr/bin/FreeCAD)([^\n]*)$',
lambda m: 'Exec=' + desktop_quote(wrapper) + m[1], template, flags=re.M)
if count != 1:
raise RuntimeError('Unexpected upstream desktop Exec; no files changed.')
files = {
wrapper: (source / 'launcher.py').read_bytes(),
home / '.local/share/applications/org.freecad.FreeCAD.desktop': desktop.encode(),
pack_dir / 'package.xml': (source / 'pack/package.xml').read_bytes(),
pack_dir / 'LICENSE': (source.parent / 'LICENSE').read_bytes(),
pack_dir / 'NickOmarchy/NickOmarchy.cfg': (source / 'pack/NickOmarchy/NickOmarchy.cfg').read_bytes(),
}
if state and set(state) != {str(p) for p in files}:
raise RuntimeError('FreeCAD data location changed; uninstall the old registration before reinstalling.')
for target in files:
if target.is_symlink() or (target.exists() and not target.is_file()):
raise RuntimeError(f'Refusing to replace a symlink or directory: {target}')
if state and (not target.exists() or sha(target.read_bytes()) != state[str(target)]['sha']):
raise RuntimeError(f'Preserving edited/missing file: {target}. Reconcile it before updating.')
if not state:
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
backup_dir = home / '.local/share/sharedomarchy-freecad/backups' / stamp
backup_dir.mkdir(parents=True)
state = {}
for index, target in enumerate(files):
backup = backup_dir / str(index) if target.exists() else None
if backup:
shutil.copy2(target, backup)
state[str(target)] = {'backup': str(backup) if backup else None}
for target, payload in files.items():
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
target.chmod(0o755 if target == wrapper else 0o644)
state[str(target)]['sha'] = sha(payload)
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text(json.dumps(state, indent=2) + '\n')
print('Installed launcher and registered NickOmarchy. Apply it from FreeCAD Preferences when desired.')


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('install', 'uninstall'))
parser.add_argument('--home', type=Path, default=Path.home())
parser.add_argument('--data-dir', type=Path, help='FreeCAD UserAppData directory; auto-detected by default')
parser.add_argument('--desktop-template', type=Path,
default=Path('/usr/share/applications/org.freecad.FreeCAD.desktop'))
manage(parser.parse_args())


if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
sys.exit(str(exc))
18 changes: 18 additions & 0 deletions freecad/launcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Start FreeCAD with Omarchy's current GNOME/GTK text scale applied to Qt."""
import math
import os
import subprocess
import sys

if 'QT_FONT_DPI' not in os.environ:
try:
scale = float(subprocess.check_output(
['gsettings', 'get', 'org.gnome.desktop.interface', 'text-scaling-factor'],
text=True, stderr=subprocess.DEVNULL, timeout=3).strip())
if math.isfinite(scale) and 0.25 <= scale <= 4:
os.environ['QT_FONT_DPI'] = str(round(96 * scale))
except (OSError, ValueError, subprocess.SubprocessError):
pass # Fall back to Qt's normal font settings if GNOME settings are absent.

os.execv('/usr/bin/FreeCAD', ['/usr/bin/FreeCAD', *sys.argv[1:]])
5 changes: 5 additions & 0 deletions freecad/macros/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Modeling macros

Put reusable, reviewed `.FCMacro` modeling automation here as it is developed.
Appearance and navigation preferences belong in the Preference Pack, not a startup macro.
No automatic modeling or pre/post-application macro is installed by this package.
Loading
Loading