diff --git a/PLAN.md b/PLAN.md
index 4ba5cdc..5c57208 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -1,18 +1,37 @@
# ZTools Development Plan
-## Current Status: v0.1.0 (70% complete)
+## Current Status: v0.3.0 (80% complete)
### What's Working
-- Workbench registration with 10 toolbars and menus
+- Workbench registration with 17 toolbars and menus
- All 15 datum creation functions with custom ZTools attachment system
- Datum Creator GUI (task panel with Planes/Axes/Points tabs)
- OK button creates datum, Cancel dismisses without creating
- Rotated Linear Pattern feature (complete)
-- Icon system (21+ Catppuccin-themed SVGs)
+- Icon system (32+ Catppuccin-themed SVGs)
- Metadata storage system (ZTools_Type, ZTools_Params, ZTools_SourceRefs)
- Spreadsheet linking for parametric control
+- FreeCAD 1.0+ Assembly workbench integration (all stock commands)
+- Assembly Linear Pattern tool (complete)
+- Assembly Polar Pattern tool (complete)
+- FreeCAD Spreadsheet workbench integration (all stock commands)
+- zSpreadsheet formatting toolbar (9 commands)
-### Recent Changes (2026-01-24)
+### Recent Changes (2026-01-25)
+- Added zSpreadsheet module with formatting toolbar
+- Native Spreadsheet commands exposed (CreateSheet, Import, Export, SetAlias, MergeCells, SplitCell)
+- Created 9 formatting commands: Bold, Italic, Underline, Align Left/Center/Right, Background Color, Text Color, Quick Alias
+- Added 9 spreadsheet icons (Catppuccin Mocha theme)
+- Spreadsheet text color now defaults to white for dark theme compatibility
+
+### Previous Changes (2026-01-25)
+- Added FreeCAD 1.0+ Assembly workbench integration
+- All native Assembly commands exposed in ztools workbench (21 commands)
+- Created Assembly Linear Pattern tool with task panel UI
+- Created Assembly Polar Pattern tool with task panel UI
+- Added assembly pattern icons (Catppuccin Mocha theme)
+
+### Previous Changes (2026-01-24)
- Replaced FreeCAD's vanilla attachment system with custom ZTools attachment
- All datums now use `MapMode='Deactivated'` with calculated placements
- Source references stored in `ZTools_SourceRefs` property for future update capability
@@ -42,6 +61,174 @@ All ZTools datums have these custom properties:
---
+## Phase 0: Complete (Assembly Integration)
+
+### FreeCAD 1.0+ Assembly Workbench Commands
+
+ZTools exposes all native FreeCAD Assembly workbench commands in 3 toolbars:
+
+**Assembly Structure:**
+- `Assembly_CreateAssembly` - Create new assembly container
+- `Assembly_InsertLink` - Insert component as link
+- `Assembly_InsertNewPart` - Create and insert new part
+
+**Assembly Joints (13 types):**
+- `Assembly_CreateJointFixed` - Lock parts together (0 DOF)
+- `Assembly_CreateJointRevolute` - Rotation around axis
+- `Assembly_CreateJointCylindrical` - Rotation + translation along axis
+- `Assembly_CreateJointSlider` - Translation along axis
+- `Assembly_CreateJointBall` - Spherical rotation
+- `Assembly_CreateJointDistance` - Maintain distance
+- `Assembly_CreateJointParallel` - Keep parallel
+- `Assembly_CreateJointPerpendicular` - Keep perpendicular
+- `Assembly_CreateJointAngle` - Maintain angle
+- `Assembly_CreateJointRackPinion` - Rack and pinion motion
+- `Assembly_CreateJointScrew` - Helical motion
+- `Assembly_CreateJointGears` - Gear ratio constraint
+- `Assembly_CreateJointBelt` - Belt/pulley constraint
+
+**Assembly Management:**
+- `Assembly_ToggleGrounded` - Lock part in place
+- `Assembly_SolveAssembly` - Run constraint solver
+- `Assembly_CreateView` - Create exploded view
+- `Assembly_CreateBom` - Generate bill of materials
+- `Assembly_ExportASMT` - Export assembly file
+
+### ZTools Assembly Pattern Tools
+
+**Assembly Linear Pattern** (`ZTools_AssemblyLinearPattern`)
+
+Creates copies of assembly components along a linear direction.
+
+Features:
+- Multi-component selection via table UI
+- Direction vector (X, Y, Z)
+- Occurrence count (2-100)
+- Spacing modes: Total Length or Fixed Spacing
+- Creates as Links (recommended) or copies
+- Option to hide original components
+- Auto-detects parent assembly
+
+UI Layout:
+```
++----------------------------------+
+| Components |
+| +------------------------------+ |
+| | Component_1 [X] | |
+| | Component_2 [X] | |
+| +------------------------------+ |
+| Select components in 3D view |
++----------------------------------+
+| Pattern Parameters |
+| Direction: X[1] Y[0] Z[0] |
+| Occurrences: [3] |
+| Mode: [Total Length v] |
+| Total Length: [100 mm] |
++----------------------------------+
+| Options |
+| [x] Create as Links |
+| [ ] Hide original components |
++----------------------------------+
+```
+
+**Assembly Polar Pattern** (`ZTools_AssemblyPolarPattern`)
+
+Creates copies of assembly components around a rotation axis.
+
+Features:
+- Multi-component selection via table UI
+- Axis presets (X, Y, Z) or custom axis vector
+- Center point specification
+- Occurrence count (2-100)
+- Angle modes: Full Circle (360) or Custom Angle
+- Creates as Links (recommended) or copies
+- Option to hide original components
+
+UI Layout:
+```
++----------------------------------+
+| Components |
+| +------------------------------+ |
+| | Component_1 [X] | |
+| +------------------------------+ |
++----------------------------------+
+| Rotation Axis |
+| Axis: [Z Axis v] |
+| Direction: X[0] Y[0] Z[1] |
+| Center: X[0] Y[0] Z[0] |
++----------------------------------+
+| Pattern Parameters |
+| Occurrences: [6] |
+| Mode: [Full Circle v] |
+| Total Angle: [360 deg] |
++----------------------------------+
+| Options |
+| [x] Create as Links |
+| [ ] Hide original components |
++----------------------------------+
+```
+
+---
+
+## Phase 0.5: Complete (zSpreadsheet)
+
+### FreeCAD Spreadsheet Workbench Commands
+
+ZTools exposes native Spreadsheet commands in the "Spreadsheet" toolbar:
+
+- `Spreadsheet_CreateSheet` - Create new spreadsheet
+- `Spreadsheet_Import` - Import CSV file
+- `Spreadsheet_Export` - Export to CSV
+- `Spreadsheet_SetAlias` - Set cell alias
+- `Spreadsheet_MergeCells` - Merge selected cells
+- `Spreadsheet_SplitCell` - Split merged cell
+
+### ZTools Spreadsheet Formatting Tools
+
+Quick formatting toolbar for cell styling without dialogs:
+
+**Style Commands:**
+- `ZTools_SpreadsheetStyleBold` - Toggle bold (B icon)
+- `ZTools_SpreadsheetStyleItalic` - Toggle italic (I icon)
+- `ZTools_SpreadsheetStyleUnderline` - Toggle underline (U icon)
+
+**Alignment Commands:**
+- `ZTools_SpreadsheetAlignLeft` - Align text left
+- `ZTools_SpreadsheetAlignCenter` - Align text center
+- `ZTools_SpreadsheetAlignRight` - Align text right
+
+**Color Commands:**
+- `ZTools_SpreadsheetBgColor` - Set cell background color (color picker)
+- `ZTools_SpreadsheetTextColor` - Set cell text color (color picker)
+
+**Utility Commands:**
+- `ZTools_SpreadsheetQuickAlias` - Create alias from adjacent label cell
+
+### Implementation Details
+
+**Cell Selection Helper:**
+The `get_selected_cells()` function:
+1. Gets active MDI subwindow
+2. Finds QTableView widget
+3. Gets selected indexes from selection model
+4. Converts to A1 notation (handles AA, AB, etc.)
+
+**Style Toggle Pattern:**
+```python
+current = sheet.getStyle(cell) or ""
+styles = set(s.strip() for s in current.split("|") if s.strip())
+if "bold" in styles:
+ styles.discard("bold")
+else:
+ styles.add("bold")
+sheet.setStyle(cell, "|".join(sorted(styles)))
+```
+
+**Color Picker Integration:**
+Uses Qt's `QColorDialog.getColor()` with Catppuccin defaults for dark theme.
+
+---
+
## Phase 1: Complete (Datum Tools)
All datum creation functions now work:
@@ -150,8 +337,10 @@ UI Layout:
| `ztools/ztools/commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
| `ztools/ztools/commands/pocket_commands.py` | Enhanced Pocket GUI | ~600 |
| `ztools/ztools/commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
-| `ztools/InitGui.py` | Workbench registration | ~200 |
-| `ztools/ztools/resources/icons.py` | SVG icon definitions | ~400 |
+| `ztools/ztools/commands/assembly_pattern_commands.py` | Assembly Linear/Polar Patterns | ~580 |
+| `ztools/ztools/commands/spreadsheet_commands.py` | Spreadsheet formatting tools | ~480 |
+| `ztools/InitGui.py` | Workbench registration | ~330 |
+| `ztools/ztools/resources/icons.py` | SVG icon definitions | ~540 |
---
@@ -188,6 +377,39 @@ UI Layout:
- [ ] Rename persists after recompute
- [ ] Delete removes datum cleanly
+### Assembly Integration Tests
+- [ ] Assembly workbench commands appear in toolbars
+- [ ] Assembly_CreateAssembly works from ztools
+- [ ] Assembly_InsertLink works from ztools
+- [ ] All joint commands accessible
+- [ ] Assembly_SolveAssembly works
+
+### Assembly Pattern Tests
+- [ ] Linear pattern with 3 occurrences along X axis
+- [ ] Linear pattern with Total Length mode
+- [ ] Linear pattern with Spacing mode
+- [ ] Linear pattern creates links (not copies)
+- [ ] Polar pattern with 6 occurrences (full circle)
+- [ ] Polar pattern with custom angle (90 degrees, 4 occurrences)
+- [ ] Polar pattern around Z axis
+- [ ] Polar pattern with custom center point
+- [ ] Multiple components can be patterned simultaneously
+- [ ] Pattern instances added to parent assembly
+
+### zSpreadsheet Tests
+- [ ] Spreadsheet toolbar appears with stock commands
+- [ ] ztools Spreadsheet toolbar appears with formatting commands
+- [ ] Create new spreadsheet via toolbar
+- [ ] Select cells and toggle Bold style
+- [ ] Select cells and toggle Italic style
+- [ ] Select cells and toggle Underline style
+- [ ] Align cells left/center/right
+- [ ] Set background color via color picker
+- [ ] Set text color via color picker (verify white default for dark theme)
+- [ ] Quick Alias creates alias from left-adjacent cell content
+- [ ] Undo/redo works for all formatting operations
+- [ ] Commands are disabled when no cells selected
+
---
## Notes
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..bf662ba
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,388 @@
+# ZTools Roadmap
+
+**Version:** 0.3.0
+**Last Updated:** 2026-01-25
+**Target Platform:** FreeCAD 1.0+
+**License:** LGPL-3.0-or-later
+
+---
+
+## Executive Summary
+
+ZTools is an extended PartDesign workbench replacement for FreeCAD, focused on velocity-driven CAD workflows. It integrates enhanced datum creation, assembly patterning, spreadsheet formatting, and a unified dark theme (Catppuccin Mocha).
+
+**Current State:** 80% complete for v1.0 release
+**Active Development Areas:** Datum management, Enhanced Pocket completion, documentation
+
+---
+
+## Table of Contents
+
+1. [Implemented Features](#implemented-features)
+2. [Known Gaps & Incomplete Features](#known-gaps--incomplete-features)
+3. [FreeCAD Ecosystem Alignment](#freecad-ecosystem-alignment)
+4. [Development Roadmap](#development-roadmap)
+5. [Technical Architecture](#technical-architecture)
+6. [File Reference](#file-reference)
+
+---
+
+## Implemented Features
+
+### 1. Workbench Integration (17 Toolbars)
+
+ZTools consolidates multiple FreeCAD workbenches into a single unified interface:
+
+| Category | Toolbars | Commands |
+|----------|----------|----------|
+| PartDesign | Structure, Datums, Additive, Subtractive, Transformations, Dress-Up, Boolean | 35+ native commands |
+| Sketcher | Sketcher | 4 native commands |
+| Assembly | Assembly, Assembly Joints, Assembly Management | 21 native commands |
+| Spreadsheet | Spreadsheet | 6 native commands |
+| ZTools Custom | ztools Datums, ztools Patterns, ztools Features, ztools Assembly, ztools Spreadsheet | 14 custom commands |
+
+### 2. Datum Creation System (16 Functions)
+
+**Custom Attachment System** - Replaces FreeCAD's unreliable vanilla attachment:
+- Calculates placement directly from source geometry
+- Stores source references in `ZTools_SourceRefs` (JSON)
+- Uses `MapMode='Deactivated'` to prevent interference
+- Stores creation parameters in `ZTools_Params` for recalculation
+
+#### Datum Planes (7 modes)
+| Mode | Function | Parameters |
+|------|----------|------------|
+| Offset from Face | `plane_offset_from_face()` | distance (mm) |
+| Offset from Plane | `plane_offset_from_plane()` | distance (mm) |
+| Midplane | `plane_midplane()` | 2 parallel faces |
+| 3 Points | `plane_from_3_points()` | 3 vertices |
+| Normal to Edge | `plane_normal_to_edge()` | parameter (0.0-1.0) |
+| Angled | `plane_angled()` | angle (degrees) |
+| Tangent to Cylinder | `plane_tangent_to_cylinder()` | angle (degrees) |
+
+#### Datum Axes (4 modes)
+| Mode | Function | Parameters |
+|------|----------|------------|
+| 2 Points | `axis_from_2_points()` | 2 vertices |
+| From Edge | `axis_from_edge()` | linear edge |
+| Cylinder Center | `axis_cylinder_center()` | cylindrical face |
+| Plane Intersection | `axis_intersection_planes()` | 2 planes |
+
+#### Datum Points (5 modes)
+| Mode | Function | Parameters |
+|------|----------|------------|
+| At Vertex | `point_at_vertex()` | vertex |
+| XYZ Coordinates | `point_at_coordinates()` | x, y, z |
+| On Edge | `point_on_edge()` | parameter (0.0-1.0) |
+| Face Center | `point_center_of_face()` | face |
+| Circle Center | `point_center_of_circle()` | circular edge |
+
+**Datum Creator GUI:**
+- Auto-detection of 15+ creation modes based on selection
+- Manual mode override
+- Spreadsheet linking option
+- Custom naming
+- Real-time selection observer
+
+### 3. Pattern Tools (3 Commands)
+
+| Command | Description | Status |
+|---------|-------------|--------|
+| `ZTools_RotatedLinearPattern` | Linear pattern with incremental rotation per instance | Complete |
+| `ZTools_AssemblyLinearPattern` | Pattern assembly components linearly | Complete |
+| `ZTools_AssemblyPolarPattern` | Pattern assembly components around axis | Complete |
+
+**Assembly Pattern Features:**
+- Multi-component selection via table UI
+- Creates as Links (recommended) or copies
+- Direction/axis presets or custom vectors
+- Spacing modes: Total Length or Fixed Spacing
+- Angle modes: Full Circle or Custom Angle
+- Auto-detects parent assembly container
+
+### 4. Spreadsheet Formatting (9 Commands)
+
+| Command | Description | Status |
+|---------|-------------|--------|
+| `ZTools_SpreadsheetStyleBold` | Toggle bold | Complete |
+| `ZTools_SpreadsheetStyleItalic` | Toggle italic | Complete |
+| `ZTools_SpreadsheetStyleUnderline` | Toggle underline | Complete |
+| `ZTools_SpreadsheetAlignLeft` | Left align | Complete |
+| `ZTools_SpreadsheetAlignCenter` | Center align | Complete |
+| `ZTools_SpreadsheetAlignRight` | Right align | Complete |
+| `ZTools_SpreadsheetBgColor` | Background color picker | Complete |
+| `ZTools_SpreadsheetTextColor` | Text color picker | Complete |
+| `ZTools_SpreadsheetQuickAlias` | Auto-create alias from label | Complete |
+
+### 5. Enhanced Features
+
+| Command | Description | Status |
+|---------|-------------|--------|
+| `ZTools_EnhancedPocket` | Pocket with "Flip Side to Cut" (SOLIDWORKS-style) | 90% Complete |
+
+**Flip Side to Cut:** Removes material OUTSIDE the sketch profile instead of inside, using Boolean Common operation.
+
+### 6. Theme System (Catppuccin Mocha)
+
+- Complete Qt StyleSheet (QSS) for entire FreeCAD interface
+- 26-color palette consistently applied
+- 50+ widget types styled
+- FreeCAD-specific widgets: PropertyEditor, Python Console, Spreadsheet
+- Spreadsheet colors auto-applied on workbench activation
+
+### 7. Icon System (33 Icons)
+
+All icons use Catppuccin Mocha palette:
+- Workbench icon
+- Datum icons (planes, axes, points - 13 total)
+- Pattern icons (3 total)
+- Pocket icons (2 total)
+- Assembly pattern icons (2 total)
+- Spreadsheet formatting icons (9 total)
+
+---
+
+## Known Gaps & Incomplete Features
+
+### Critical (Must Fix)
+
+| Issue | Location | Description | Priority |
+|-------|----------|-------------|----------|
+| Datum Manager stub | `datum_commands.py:853` | Placeholder only - needs full implementation | High |
+| Datum edit recalculation | `datum_viewprovider.py:351,355,359` | Parameter changes don't recalculate placement from source geometry | High |
+
+### Non-Critical (Should Fix)
+
+| Issue | Location | Description | Priority |
+|-------|----------|-------------|----------|
+| Enhanced Pocket incomplete | `pocket_commands.py` | Taper angle disabled for flipped pockets | Medium |
+| Pocket execution logic | `pocket_commands.py` | UI complete but execution needs verification | Medium |
+
+### Future Enhancements (Nice to Have)
+
+| Feature | Description | Priority |
+|---------|-------------|----------|
+| Curve-driven pattern | Sweep instances along spline | Low |
+| Fill pattern | Populate region with instances | Low |
+| Variable spacing pattern | Non-uniform spacing | Low |
+| Enhanced Pad | Multi-body support, draft angles | Low |
+| Body operations | Split, combine, shell improvements | Low |
+
+---
+
+## FreeCAD Ecosystem Alignment
+
+### FreeCAD 1.0 (November 2024) - Current Target
+
+**Key Features ZTools Leverages:**
+- **TNP Mitigation:** Topological Naming Problem largely resolved
+- **Integrated Assembly Workbench:** Ondsel's assembly system now core
+- **Material System Overhaul:** New material handling
+- **UI/UX Improvements:** Dark/light themes, selection filters
+
+**ZTools Alignment:**
+- Custom attachment system complements TNP fix
+- Full integration with native Assembly workbench
+- Catppuccin theme extends FreeCAD's theming
+
+### FreeCAD 1.1 (Expected Late 2025)
+
+**Planned Features:**
+- New Transform Manipulator
+- UI Material Rendering Improvements
+- Continued TNP refinement for Sketcher/PartDesign
+
+**ZTools Opportunities:**
+- Watch for new Assembly API standardization
+- Monitor Sketcher improvements for datum integration
+
+### FreeCAD Strategic Priorities (from Roadmap)
+
+| FreeCAD Priority | ZTools Alignment |
+|------------------|------------------|
+| Model Stability | Custom attachment system prevents fragile models |
+| Assembly Integration | Full native Assembly command exposure |
+| Flatten Learning Curve | Unified toolbar consolidation |
+| UI Modernization | Catppuccin Mocha theme |
+| Streamlined Workflow | Quick formatting toolbars, auto-detection |
+
+### Ondsel Contributions (Note: Ondsel shut down October 2025)
+
+Ondsel's contributions now maintained by FreeCAD community:
+- Assembly workbench (ZTools integrates)
+- VarSets custom properties (potential future integration)
+- Sketcher improvements
+
+---
+
+## Development Roadmap
+
+### Phase 1: v0.3.x - Stability & Completion (Current)
+
+**Timeline:** Q1 2026
+
+| Task | Status | Priority |
+|------|--------|----------|
+| Complete Datum Manager GUI | Not Started | High |
+| Implement datum parameter recalculation | Not Started | High |
+| Verify Enhanced Pocket execution | Partial | Medium |
+| Add comprehensive test coverage | Not Started | Medium |
+| Documentation completion | In Progress | Medium |
+
+### Phase 2: v0.4.0 - Polish & UX
+
+**Timeline:** Q2 2026
+
+| Task | Description |
+|------|-------------|
+| Keyboard shortcuts | Add hotkeys for common operations |
+| Context menus | Right-click menus in 3D view |
+| Undo/redo improvements | Better transaction naming |
+| Error handling | User-friendly error messages |
+| Preferences panel | ZTools configuration UI |
+
+### Phase 3: v0.5.0 - Advanced Features
+
+**Timeline:** Q3 2026
+
+| Task | Description |
+|------|-------------|
+| Curve-driven pattern | Pattern along splines |
+| Enhanced Pad | Draft angles, lip/groove |
+| Body operations | Split, combine, shell |
+| Datum freeze/update | Control source geometry updates |
+
+### Phase 4: v1.0.0 - Production Release
+
+**Timeline:** Q4 2026
+
+| Task | Description |
+|------|-------------|
+| Full test suite | Automated testing |
+| User documentation | Complete user guide |
+| Video tutorials | Getting started series |
+| FreeCAD Addon Manager | Official listing |
+
+---
+
+## Technical Architecture
+
+### Directory Structure
+
+```
+ztools/
+├── Init.py # Startup (non-GUI)
+├── InitGui.py # Workbench registration
+└── ztools/
+ ├── __init__.py
+ ├── commands/ # GUI commands
+ │ ├── __init__.py
+ │ ├── datum_commands.py # Datum Creator/Manager
+ │ ├── datum_viewprovider.py # Custom ViewProvider
+ │ ├── pattern_commands.py # Rotated Linear Pattern
+ │ ├── pocket_commands.py # Enhanced Pocket
+ │ ├── assembly_pattern_commands.py # Assembly patterns
+ │ └── spreadsheet_commands.py # Spreadsheet formatting
+ ├── datums/ # Core datum library
+ │ ├── __init__.py
+ │ └── core.py # 16 datum functions
+ └── resources/ # Assets
+ ├── __init__.py
+ ├── icons.py # 33 SVG icons
+ ├── theme.py # Catppuccin QSS
+ └── icons/ # Generated SVG files
+```
+
+### Key Design Patterns
+
+1. **Command Pattern:** All tools follow FreeCAD's `GetResources()`, `Activated()`, `IsActive()` pattern
+2. **Task Panel Pattern:** Complex UIs use `QWidget` with selection observers
+3. **Feature Python Pattern:** Custom features use `Part::FeaturePython` with ViewProvider
+4. **Metadata System:** JSON properties for tracking ZTools objects
+
+### Metadata Properties
+
+All ZTools objects have:
+- `ZTools_Type`: Feature type identifier
+- `ZTools_Params`: JSON creation parameters
+- `ZTools_SourceRefs`: JSON source geometry references
+
+---
+
+## File Reference
+
+| File | Purpose | Lines |
+|------|---------|-------|
+| `InitGui.py` | Workbench registration, toolbars, menus | ~330 |
+| `datums/core.py` | 16 datum creation functions | ~1300 |
+| `commands/datum_commands.py` | Datum Creator/Manager GUI | ~520 |
+| `commands/datum_viewprovider.py` | Custom ViewProvider, edit panel | ~400 |
+| `commands/pattern_commands.py` | Rotated Linear Pattern | ~206 |
+| `commands/pocket_commands.py` | Enhanced Pocket | ~600 |
+| `commands/assembly_pattern_commands.py` | Assembly patterns | ~580 |
+| `commands/spreadsheet_commands.py` | Spreadsheet formatting | ~480 |
+| `resources/icons.py` | 33 SVG icon definitions | ~540 |
+| `resources/theme.py` | Catppuccin Mocha QSS | ~1500 |
+
+**Total:** ~6,400+ lines of code
+
+---
+
+## Statistics Summary
+
+| Category | Count |
+|----------|-------|
+| Command Files | 6 |
+| Command Classes | 24+ |
+| Datum Creation Functions | 16 |
+| Icons Defined | 33 |
+| Toolbars Registered | 17 |
+| Menu Hierarchies | 7 major |
+| Native Commands Exposed | 66 |
+| Custom ZTools Commands | 14 |
+| Theme Colors | 26 |
+| Styled Widget Types | 50+ |
+
+---
+
+## Contributing
+
+ZTools follows FreeCAD's contribution guidelines. Key areas needing help:
+
+1. **Testing:** Manual testing on different platforms
+2. **Documentation:** User guides and tutorials
+3. **Translations:** Internationalization support
+4. **Bug Reports:** Issue tracking and reproduction
+
+---
+
+## License
+
+LGPL-3.0-or-later
+
+Compatible with FreeCAD's licensing model.
+
+---
+
+## Changelog
+
+### v0.3.0 (2026-01-25)
+- Added zSpreadsheet module with 9 formatting commands
+- Added Spreadsheet workbench integration (6 native commands)
+- Added 9 spreadsheet formatting icons
+
+### v0.2.0 (2026-01-25)
+- Added Assembly workbench integration (21 native commands)
+- Added Assembly Linear Pattern tool
+- Added Assembly Polar Pattern tool
+- Added assembly pattern icons
+
+### v0.1.0 (2026-01-24)
+- Initial release
+- Custom attachment system for datums
+- 16 datum creation functions
+- Datum Creator GUI with auto-detection
+- Rotated Linear Pattern
+- Enhanced Pocket (partial)
+- Catppuccin Mocha theme
+- 21 initial icons
diff --git a/ztools/InitGui.py b/ztools/InitGui.py
index 2507c31..6f2c785 100644
--- a/ztools/InitGui.py
+++ b/ztools/InitGui.py
@@ -60,10 +60,30 @@ class ZToolsWorkbench(Gui.Workbench):
if hasattr(sketcher_wb, "Initialize"):
sketcher_wb.Initialize()
- except Exception as e:
- App.Console.PrintWarning(f"Could not initialize PartDesign/Sketcher: {e}\n")
+ # Initialize Assembly workbench if available (FreeCAD 1.0+)
+ if "AssemblyWorkbench" in wb_list:
+ asm_wb = Gui.getWorkbench("AssemblyWorkbench")
+ if hasattr(asm_wb, "Initialize"):
+ asm_wb.Initialize()
- from ztools.commands import datum_commands, pattern_commands, pocket_commands
+ # Initialize Spreadsheet workbench if available
+ if "SpreadsheetWorkbench" in wb_list:
+ ss_wb = Gui.getWorkbench("SpreadsheetWorkbench")
+ if hasattr(ss_wb, "Initialize"):
+ ss_wb.Initialize()
+
+ except Exception as e:
+ App.Console.PrintWarning(
+ f"Could not initialize PartDesign/Sketcher/Assembly/Spreadsheet: {e}\n"
+ )
+
+ from ztools.commands import (
+ assembly_pattern_commands,
+ datum_commands,
+ pattern_commands,
+ pocket_commands,
+ spreadsheet_commands,
+ )
# =====================================================================
# PartDesign Structure Tools
@@ -176,6 +196,74 @@ class ZToolsWorkbench(Gui.Workbench):
"ZTools_EnhancedPocket",
]
+ # =====================================================================
+ # Assembly Workbench Tools (FreeCAD 1.0+)
+ # =====================================================================
+ self.assembly_structure_tools = [
+ "Assembly_CreateAssembly",
+ "Assembly_InsertLink",
+ "Assembly_InsertNewPart",
+ ]
+
+ self.assembly_joint_tools = [
+ "Assembly_CreateJointFixed",
+ "Assembly_CreateJointRevolute",
+ "Assembly_CreateJointCylindrical",
+ "Assembly_CreateJointSlider",
+ "Assembly_CreateJointBall",
+ "Assembly_CreateJointDistance",
+ "Assembly_CreateJointParallel",
+ "Assembly_CreateJointPerpendicular",
+ "Assembly_CreateJointAngle",
+ "Assembly_CreateJointRackPinion",
+ "Assembly_CreateJointScrew",
+ "Assembly_CreateJointGears",
+ "Assembly_CreateJointBelt",
+ ]
+
+ self.assembly_management_tools = [
+ "Assembly_ToggleGrounded",
+ "Assembly_SolveAssembly",
+ "Assembly_CreateView",
+ "Assembly_CreateBom",
+ "Assembly_ExportASMT",
+ ]
+
+ # =====================================================================
+ # ZTools Assembly Pattern Tools
+ # =====================================================================
+ self.ztools_assembly_tools = [
+ "ZTools_AssemblyLinearPattern",
+ "ZTools_AssemblyPolarPattern",
+ ]
+
+ # =====================================================================
+ # Spreadsheet Workbench Tools
+ # =====================================================================
+ self.spreadsheet_tools = [
+ "Spreadsheet_CreateSheet",
+ "Spreadsheet_Import",
+ "Spreadsheet_Export",
+ "Spreadsheet_SetAlias",
+ "Spreadsheet_MergeCells",
+ "Spreadsheet_SplitCell",
+ ]
+
+ # =====================================================================
+ # ZTools Spreadsheet Formatting Tools
+ # =====================================================================
+ self.ztools_spreadsheet_tools = [
+ "ZTools_SpreadsheetStyleBold",
+ "ZTools_SpreadsheetStyleItalic",
+ "ZTools_SpreadsheetStyleUnderline",
+ "ZTools_SpreadsheetAlignLeft",
+ "ZTools_SpreadsheetAlignCenter",
+ "ZTools_SpreadsheetAlignRight",
+ "ZTools_SpreadsheetBgColor",
+ "ZTools_SpreadsheetTextColor",
+ "ZTools_SpreadsheetQuickAlias",
+ ]
+
# =====================================================================
# Append Toolbars
# =====================================================================
@@ -189,9 +277,15 @@ class ZToolsWorkbench(Gui.Workbench):
self.appendToolbar("Transformations", self.transformation_tools)
self.appendToolbar("Dress-Up", self.dressup_tools)
self.appendToolbar("Boolean", self.boolean_tools)
+ self.appendToolbar("Assembly", self.assembly_structure_tools)
+ self.appendToolbar("Assembly Joints", self.assembly_joint_tools)
+ self.appendToolbar("Assembly Management", self.assembly_management_tools)
self.appendToolbar("ztools Datums", self.ztools_datum_tools)
self.appendToolbar("ztools Patterns", self.ztools_pattern_tools)
self.appendToolbar("ztools Features", self.ztools_pocket_tools)
+ self.appendToolbar("ztools Assembly", self.ztools_assembly_tools)
+ self.appendToolbar("Spreadsheet", self.spreadsheet_tools)
+ self.appendToolbar("ztools Spreadsheet", self.ztools_spreadsheet_tools)
# =====================================================================
# Append Menus
@@ -209,17 +303,32 @@ class ZToolsWorkbench(Gui.Workbench):
self.appendMenu(["PartDesign", "Transformations"], self.transformation_tools)
self.appendMenu(["PartDesign", "Dress-Up"], self.dressup_tools)
self.appendMenu(["PartDesign", "Boolean"], self.boolean_tools)
+ self.appendMenu(["Assembly", "Structure"], self.assembly_structure_tools)
+ self.appendMenu(["Assembly", "Joints"], self.assembly_joint_tools)
+ self.appendMenu(["Assembly", "Management"], self.assembly_management_tools)
+ self.appendMenu(["Spreadsheet", "Edit"], self.spreadsheet_tools)
+ self.appendMenu(["Spreadsheet", "Format"], self.ztools_spreadsheet_tools)
self.appendMenu(
"ztools",
self.ztools_datum_tools
+ self.ztools_pattern_tools
- + self.ztools_pocket_tools,
+ + self.ztools_pocket_tools
+ + self.ztools_assembly_tools
+ + self.ztools_spreadsheet_tools,
)
App.Console.PrintMessage("ztools workbench initialized\n")
def Activated(self):
"""Called when workbench is activated."""
+ # Apply Catppuccin Mocha colors to Spreadsheet preferences
+ try:
+ from ztools.resources.theme import apply_spreadsheet_colors
+
+ apply_spreadsheet_colors()
+ except Exception as e:
+ App.Console.PrintWarning(f"Could not apply spreadsheet colors: {e}\n")
+
App.Console.PrintMessage("ztools workbench activated\n")
def Deactivated(self):
diff --git a/ztools/ztools/commands/__init__.py b/ztools/ztools/commands/__init__.py
index 70d804a..8a1b211 100644
--- a/ztools/ztools/commands/__init__.py
+++ b/ztools/ztools/commands/__init__.py
@@ -1,9 +1,18 @@
# ztools/commands - GUI commands
-from . import datum_commands, datum_viewprovider, pattern_commands, pocket_commands
+from . import (
+ assembly_pattern_commands,
+ datum_commands,
+ datum_viewprovider,
+ pattern_commands,
+ pocket_commands,
+ spreadsheet_commands,
+)
__all__ = [
"datum_commands",
"datum_viewprovider",
"pattern_commands",
"pocket_commands",
+ "assembly_pattern_commands",
+ "spreadsheet_commands",
]
diff --git a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc
index 17471f6..0aa032a 100644
Binary files a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc and b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc differ
diff --git a/ztools/ztools/commands/__pycache__/assembly_pattern_commands.cpython-312.pyc b/ztools/ztools/commands/__pycache__/assembly_pattern_commands.cpython-312.pyc
new file mode 100644
index 0000000..bc84e91
Binary files /dev/null and b/ztools/ztools/commands/__pycache__/assembly_pattern_commands.cpython-312.pyc differ
diff --git a/ztools/ztools/commands/assembly_pattern_commands.py b/ztools/ztools/commands/assembly_pattern_commands.py
new file mode 100644
index 0000000..d3b3790
--- /dev/null
+++ b/ztools/ztools/commands/assembly_pattern_commands.py
@@ -0,0 +1,787 @@
+# ztools/commands/assembly_pattern_commands.py
+# Assembly patterning tools for FreeCAD 1.0+ Assembly workbench
+# Creates linear and polar patterns of assembly components
+
+import math
+
+import FreeCAD as App
+import FreeCADGui as Gui
+from PySide import QtCore, QtGui
+
+from ztools.resources.icons import get_icon
+
+# =============================================================================
+# Assembly Linear Pattern
+# =============================================================================
+
+
+class AssemblyLinearPatternTaskPanel:
+ """Task panel for creating linear patterns of assembly components."""
+
+ def __init__(self):
+ self.form = QtGui.QWidget()
+ self.setup_ui()
+ self.setup_selection_observer()
+ self.selected_components = []
+
+ def setup_ui(self):
+ layout = QtGui.QVBoxLayout(self.form)
+
+ # Component selection section
+ component_group = QtGui.QGroupBox("Components")
+ component_layout = QtGui.QVBoxLayout(component_group)
+
+ # Selection table
+ self.component_table = QtGui.QTableWidget()
+ self.component_table.setColumnCount(2)
+ self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
+ self.component_table.horizontalHeader().setStretchLastSection(False)
+ self.component_table.horizontalHeader().setSectionResizeMode(
+ 0, QtGui.QHeaderView.Stretch
+ )
+ self.component_table.horizontalHeader().setSectionResizeMode(
+ 1, QtGui.QHeaderView.Fixed
+ )
+ self.component_table.setColumnWidth(1, 60)
+ self.component_table.setMaximumHeight(120)
+ component_layout.addWidget(self.component_table)
+
+ # Selection hint
+ hint_label = QtGui.QLabel("Select assembly components in the 3D view")
+ hint_label.setStyleSheet("color: gray; font-style: italic;")
+ component_layout.addWidget(hint_label)
+
+ layout.addWidget(component_group)
+
+ # Pattern parameters section
+ params_group = QtGui.QGroupBox("Pattern Parameters")
+ params_layout = QtGui.QFormLayout(params_group)
+
+ # Direction
+ direction_layout = QtGui.QHBoxLayout()
+ self.dir_x_spin = QtGui.QDoubleSpinBox()
+ self.dir_x_spin.setRange(-1000, 1000)
+ self.dir_x_spin.setValue(1)
+ self.dir_x_spin.setDecimals(3)
+ self.dir_y_spin = QtGui.QDoubleSpinBox()
+ self.dir_y_spin.setRange(-1000, 1000)
+ self.dir_y_spin.setValue(0)
+ self.dir_y_spin.setDecimals(3)
+ self.dir_z_spin = QtGui.QDoubleSpinBox()
+ self.dir_z_spin.setRange(-1000, 1000)
+ self.dir_z_spin.setValue(0)
+ self.dir_z_spin.setDecimals(3)
+ direction_layout.addWidget(QtGui.QLabel("X:"))
+ direction_layout.addWidget(self.dir_x_spin)
+ direction_layout.addWidget(QtGui.QLabel("Y:"))
+ direction_layout.addWidget(self.dir_y_spin)
+ direction_layout.addWidget(QtGui.QLabel("Z:"))
+ direction_layout.addWidget(self.dir_z_spin)
+ params_layout.addRow("Direction:", direction_layout)
+
+ # Occurrences
+ self.occurrences_spin = QtGui.QSpinBox()
+ self.occurrences_spin.setRange(2, 100)
+ self.occurrences_spin.setValue(3)
+ params_layout.addRow("Occurrences:", self.occurrences_spin)
+
+ # Spacing mode
+ self.spacing_mode = QtGui.QComboBox()
+ self.spacing_mode.addItems(["Total Length", "Spacing"])
+ self.spacing_mode.currentIndexChanged.connect(self.on_spacing_mode_changed)
+ params_layout.addRow("Mode:", self.spacing_mode)
+
+ # Length/Spacing value
+ self.length_spin = QtGui.QDoubleSpinBox()
+ self.length_spin.setRange(0.001, 100000)
+ self.length_spin.setValue(100)
+ self.length_spin.setDecimals(3)
+ self.length_spin.setSuffix(" mm")
+ self.length_label = QtGui.QLabel("Total Length:")
+ params_layout.addRow(self.length_label, self.length_spin)
+
+ layout.addWidget(params_group)
+
+ # Options section
+ options_group = QtGui.QGroupBox("Options")
+ options_layout = QtGui.QVBoxLayout(options_group)
+
+ self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
+ self.link_checkbox.setChecked(True)
+ self.link_checkbox.setToolTip(
+ "Links reference the original component, reducing file size"
+ )
+ options_layout.addWidget(self.link_checkbox)
+
+ self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
+ self.hide_original_checkbox.setChecked(False)
+ options_layout.addWidget(self.hide_original_checkbox)
+
+ layout.addWidget(options_group)
+
+ layout.addStretch()
+
+ def setup_selection_observer(self):
+ """Set up observer to track selection changes."""
+
+ class SelectionObserver:
+ def __init__(self, panel):
+ self.panel = panel
+
+ def addSelection(self, doc, obj, sub, pos):
+ self.panel.on_selection_added(doc, obj, sub)
+
+ def removeSelection(self, doc, obj, sub):
+ self.panel.on_selection_removed(doc, obj, sub)
+
+ def clearSelection(self, doc):
+ pass
+
+ self.observer = SelectionObserver(self)
+ Gui.Selection.addObserver(self.observer)
+
+ def on_selection_added(self, doc_name, obj_name, sub):
+ """Handle new selection."""
+ doc = App.getDocument(doc_name)
+ if not doc:
+ return
+
+ obj = doc.getObject(obj_name)
+ if not obj:
+ return
+
+ # Check if this is an assembly component (App::Link or Part)
+ if not self._is_valid_component(obj):
+ return
+
+ # Avoid duplicates
+ if obj in self.selected_components:
+ return
+
+ self.selected_components.append(obj)
+ self._update_table()
+
+ def on_selection_removed(self, doc_name, obj_name, sub):
+ """Handle selection removal."""
+ doc = App.getDocument(doc_name)
+ if not doc:
+ return
+
+ obj = doc.getObject(obj_name)
+ if obj in self.selected_components:
+ self.selected_components.remove(obj)
+ self._update_table()
+
+ def _is_valid_component(self, obj):
+ """Check if object is a valid assembly component."""
+ valid_types = [
+ "App::Link",
+ "App::LinkGroup",
+ "Part::Feature",
+ "PartDesign::Body",
+ "App::Part",
+ ]
+ return obj.TypeId in valid_types
+
+ def _update_table(self):
+ """Update the component table."""
+ self.component_table.setRowCount(len(self.selected_components))
+
+ for i, comp in enumerate(self.selected_components):
+ # Component name
+ name_item = QtGui.QTableWidgetItem(comp.Label)
+ name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
+ self.component_table.setItem(i, 0, name_item)
+
+ # Remove button
+ remove_btn = QtGui.QPushButton("X")
+ remove_btn.setMaximumWidth(40)
+ remove_btn.clicked.connect(
+ lambda checked, idx=i: self._remove_component(idx)
+ )
+ self.component_table.setCellWidget(i, 1, remove_btn)
+
+ def _remove_component(self, index):
+ """Remove component from selection."""
+ if 0 <= index < len(self.selected_components):
+ self.selected_components.pop(index)
+ self._update_table()
+
+ def on_spacing_mode_changed(self, index):
+ """Update label based on spacing mode."""
+ if index == 0:
+ self.length_label.setText("Total Length:")
+ else:
+ self.length_label.setText("Spacing:")
+
+ def accept(self):
+ """Create the linear pattern."""
+ Gui.Selection.removeObserver(self.observer)
+
+ if not self.selected_components:
+ App.Console.PrintError("No components selected for pattern\n")
+ return False
+
+ doc = App.ActiveDocument
+ if not doc:
+ return False
+
+ # Get parameters
+ direction = App.Vector(
+ self.dir_x_spin.value(),
+ self.dir_y_spin.value(),
+ self.dir_z_spin.value(),
+ )
+
+ if direction.Length < 1e-6:
+ App.Console.PrintError("Direction vector cannot be zero\n")
+ return False
+
+ direction.normalize()
+
+ occurrences = self.occurrences_spin.value()
+ length_value = self.length_spin.value()
+
+ if self.spacing_mode.currentIndex() == 0:
+ # Total length mode
+ spacing = length_value / (occurrences - 1) if occurrences > 1 else 0
+ else:
+ # Spacing mode
+ spacing = length_value
+
+ use_links = self.link_checkbox.isChecked()
+ hide_original = self.hide_original_checkbox.isChecked()
+
+ # Find parent assembly
+ assembly = self._find_parent_assembly(self.selected_components[0])
+
+ doc.openTransaction("Assembly Linear Pattern")
+
+ try:
+ created_objects = []
+
+ for comp in self.selected_components:
+ # Get base placement
+ if hasattr(comp, "Placement"):
+ base_placement = comp.Placement
+ else:
+ base_placement = App.Placement()
+
+ # Create pattern instances (skip i=0 as that's the original)
+ for i in range(1, occurrences):
+ offset = direction * spacing * i
+ new_placement = App.Placement(
+ base_placement.Base + offset,
+ base_placement.Rotation,
+ )
+
+ if use_links:
+ # Create link to original
+ if comp.TypeId == "App::Link":
+ # Link to the linked object
+ link_target = comp.LinkedObject
+ else:
+ link_target = comp
+
+ new_obj = doc.addObject("App::Link", f"{comp.Label}_Pattern{i}")
+ new_obj.LinkedObject = link_target
+ new_obj.Placement = new_placement
+ else:
+ # Create copy
+ new_obj = doc.copyObject(comp, False)
+ new_obj.Label = f"{comp.Label}_Pattern{i}"
+ new_obj.Placement = new_placement
+
+ # Add to assembly if found
+ if assembly and hasattr(assembly, "addObject"):
+ assembly.addObject(new_obj)
+
+ created_objects.append(new_obj)
+
+ # Hide original if requested
+ if hide_original and hasattr(comp, "ViewObject"):
+ comp.ViewObject.Visibility = False
+
+ doc.commitTransaction()
+ doc.recompute()
+
+ App.Console.PrintMessage(
+ f"Created {len(created_objects)} pattern instances\n"
+ )
+
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to create pattern: {e}\n")
+ return False
+
+ return True
+
+ def reject(self):
+ """Cancel the operation."""
+ Gui.Selection.removeObserver(self.observer)
+ return True
+
+ def _find_parent_assembly(self, obj):
+ """Find the parent assembly of an object."""
+ doc = obj.Document
+
+ for candidate in doc.Objects:
+ if candidate.TypeId == "Assembly::AssemblyObject":
+ # Check if obj is in this assembly's group
+ if hasattr(candidate, "Group"):
+ if obj in candidate.Group:
+ return candidate
+ # Check nested
+ for member in candidate.Group:
+ if hasattr(member, "Group") and obj in member.Group:
+ return candidate
+
+ return None
+
+ def getStandardButtons(self):
+ return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel)
+
+
+class AssemblyLinearPatternCommand:
+ """Command to create a linear pattern of assembly components."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("assembly_linear_pattern"),
+ "MenuText": "Assembly Linear Pattern",
+ "ToolTip": "Create a linear pattern of assembly components",
+ }
+
+ def IsActive(self):
+ return App.ActiveDocument is not None
+
+ def Activated(self):
+ panel = AssemblyLinearPatternTaskPanel()
+ Gui.Control.showDialog(panel)
+
+
+# =============================================================================
+# Assembly Polar Pattern
+# =============================================================================
+
+
+class AssemblyPolarPatternTaskPanel:
+ """Task panel for creating polar patterns of assembly components."""
+
+ def __init__(self):
+ self.form = QtGui.QWidget()
+ self.setup_ui()
+ self.setup_selection_observer()
+ self.selected_components = []
+ self.axis_selection = None
+
+ def setup_ui(self):
+ layout = QtGui.QVBoxLayout(self.form)
+
+ # Component selection section
+ component_group = QtGui.QGroupBox("Components")
+ component_layout = QtGui.QVBoxLayout(component_group)
+
+ # Selection table
+ self.component_table = QtGui.QTableWidget()
+ self.component_table.setColumnCount(2)
+ self.component_table.setHorizontalHeaderLabels(["Component", "Remove"])
+ self.component_table.horizontalHeader().setStretchLastSection(False)
+ self.component_table.horizontalHeader().setSectionResizeMode(
+ 0, QtGui.QHeaderView.Stretch
+ )
+ self.component_table.horizontalHeader().setSectionResizeMode(
+ 1, QtGui.QHeaderView.Fixed
+ )
+ self.component_table.setColumnWidth(1, 60)
+ self.component_table.setMaximumHeight(100)
+ component_layout.addWidget(self.component_table)
+
+ hint_label = QtGui.QLabel("Select assembly components in the 3D view")
+ hint_label.setStyleSheet("color: gray; font-style: italic;")
+ component_layout.addWidget(hint_label)
+
+ layout.addWidget(component_group)
+
+ # Axis section
+ axis_group = QtGui.QGroupBox("Rotation Axis")
+ axis_layout = QtGui.QFormLayout(axis_group)
+
+ # Axis definition mode
+ self.axis_mode = QtGui.QComboBox()
+ self.axis_mode.addItems(["Custom Axis", "X Axis", "Y Axis", "Z Axis"])
+ self.axis_mode.currentIndexChanged.connect(self.on_axis_mode_changed)
+ axis_layout.addRow("Axis:", self.axis_mode)
+
+ # Custom axis direction
+ self.axis_widget = QtGui.QWidget()
+ axis_dir_layout = QtGui.QHBoxLayout(self.axis_widget)
+ axis_dir_layout.setContentsMargins(0, 0, 0, 0)
+ self.axis_x_spin = QtGui.QDoubleSpinBox()
+ self.axis_x_spin.setRange(-1, 1)
+ self.axis_x_spin.setValue(0)
+ self.axis_x_spin.setDecimals(3)
+ self.axis_x_spin.setSingleStep(0.1)
+ self.axis_y_spin = QtGui.QDoubleSpinBox()
+ self.axis_y_spin.setRange(-1, 1)
+ self.axis_y_spin.setValue(0)
+ self.axis_y_spin.setDecimals(3)
+ self.axis_y_spin.setSingleStep(0.1)
+ self.axis_z_spin = QtGui.QDoubleSpinBox()
+ self.axis_z_spin.setRange(-1, 1)
+ self.axis_z_spin.setValue(1)
+ self.axis_z_spin.setDecimals(3)
+ self.axis_z_spin.setSingleStep(0.1)
+ axis_dir_layout.addWidget(QtGui.QLabel("X:"))
+ axis_dir_layout.addWidget(self.axis_x_spin)
+ axis_dir_layout.addWidget(QtGui.QLabel("Y:"))
+ axis_dir_layout.addWidget(self.axis_y_spin)
+ axis_dir_layout.addWidget(QtGui.QLabel("Z:"))
+ axis_dir_layout.addWidget(self.axis_z_spin)
+ axis_layout.addRow("Direction:", self.axis_widget)
+
+ # Center point
+ center_layout = QtGui.QHBoxLayout()
+ self.center_x_spin = QtGui.QDoubleSpinBox()
+ self.center_x_spin.setRange(-100000, 100000)
+ self.center_x_spin.setValue(0)
+ self.center_x_spin.setDecimals(3)
+ self.center_y_spin = QtGui.QDoubleSpinBox()
+ self.center_y_spin.setRange(-100000, 100000)
+ self.center_y_spin.setValue(0)
+ self.center_y_spin.setDecimals(3)
+ self.center_z_spin = QtGui.QDoubleSpinBox()
+ self.center_z_spin.setRange(-100000, 100000)
+ self.center_z_spin.setValue(0)
+ self.center_z_spin.setDecimals(3)
+ center_layout.addWidget(QtGui.QLabel("X:"))
+ center_layout.addWidget(self.center_x_spin)
+ center_layout.addWidget(QtGui.QLabel("Y:"))
+ center_layout.addWidget(self.center_y_spin)
+ center_layout.addWidget(QtGui.QLabel("Z:"))
+ center_layout.addWidget(self.center_z_spin)
+ axis_layout.addRow("Center:", center_layout)
+
+ layout.addWidget(axis_group)
+
+ # Pattern parameters section
+ params_group = QtGui.QGroupBox("Pattern Parameters")
+ params_layout = QtGui.QFormLayout(params_group)
+
+ # Occurrences
+ self.occurrences_spin = QtGui.QSpinBox()
+ self.occurrences_spin.setRange(2, 100)
+ self.occurrences_spin.setValue(6)
+ params_layout.addRow("Occurrences:", self.occurrences_spin)
+
+ # Angle mode
+ self.angle_mode = QtGui.QComboBox()
+ self.angle_mode.addItems(["Full Circle (360)", "Custom Angle"])
+ self.angle_mode.currentIndexChanged.connect(self.on_angle_mode_changed)
+ params_layout.addRow("Mode:", self.angle_mode)
+
+ # Angle value
+ self.angle_spin = QtGui.QDoubleSpinBox()
+ self.angle_spin.setRange(0.001, 360)
+ self.angle_spin.setValue(360)
+ self.angle_spin.setDecimals(2)
+ self.angle_spin.setSuffix(" deg")
+ self.angle_spin.setEnabled(False)
+ params_layout.addRow("Total Angle:", self.angle_spin)
+
+ layout.addWidget(params_group)
+
+ # Options section
+ options_group = QtGui.QGroupBox("Options")
+ options_layout = QtGui.QVBoxLayout(options_group)
+
+ self.link_checkbox = QtGui.QCheckBox("Create as Links (recommended)")
+ self.link_checkbox.setChecked(True)
+ options_layout.addWidget(self.link_checkbox)
+
+ self.hide_original_checkbox = QtGui.QCheckBox("Hide original components")
+ self.hide_original_checkbox.setChecked(False)
+ options_layout.addWidget(self.hide_original_checkbox)
+
+ layout.addWidget(options_group)
+
+ layout.addStretch()
+
+ def setup_selection_observer(self):
+ """Set up observer to track selection changes."""
+
+ class SelectionObserver:
+ def __init__(self, panel):
+ self.panel = panel
+
+ def addSelection(self, doc, obj, sub, pos):
+ self.panel.on_selection_added(doc, obj, sub)
+
+ def removeSelection(self, doc, obj, sub):
+ self.panel.on_selection_removed(doc, obj, sub)
+
+ def clearSelection(self, doc):
+ pass
+
+ self.observer = SelectionObserver(self)
+ Gui.Selection.addObserver(self.observer)
+
+ def on_selection_added(self, doc_name, obj_name, sub):
+ """Handle new selection."""
+ doc = App.getDocument(doc_name)
+ if not doc:
+ return
+
+ obj = doc.getObject(obj_name)
+ if not obj:
+ return
+
+ # Check if this is an assembly component
+ if not self._is_valid_component(obj):
+ return
+
+ if obj in self.selected_components:
+ return
+
+ self.selected_components.append(obj)
+ self._update_table()
+
+ def on_selection_removed(self, doc_name, obj_name, sub):
+ """Handle selection removal."""
+ doc = App.getDocument(doc_name)
+ if not doc:
+ return
+
+ obj = doc.getObject(obj_name)
+ if obj in self.selected_components:
+ self.selected_components.remove(obj)
+ self._update_table()
+
+ def _is_valid_component(self, obj):
+ """Check if object is a valid assembly component."""
+ valid_types = [
+ "App::Link",
+ "App::LinkGroup",
+ "Part::Feature",
+ "PartDesign::Body",
+ "App::Part",
+ ]
+ return obj.TypeId in valid_types
+
+ def _update_table(self):
+ """Update the component table."""
+ self.component_table.setRowCount(len(self.selected_components))
+
+ for i, comp in enumerate(self.selected_components):
+ name_item = QtGui.QTableWidgetItem(comp.Label)
+ name_item.setFlags(name_item.flags() & ~QtCore.Qt.ItemIsEditable)
+ self.component_table.setItem(i, 0, name_item)
+
+ remove_btn = QtGui.QPushButton("X")
+ remove_btn.setMaximumWidth(40)
+ remove_btn.clicked.connect(
+ lambda checked, idx=i: self._remove_component(idx)
+ )
+ self.component_table.setCellWidget(i, 1, remove_btn)
+
+ def _remove_component(self, index):
+ """Remove component from selection."""
+ if 0 <= index < len(self.selected_components):
+ self.selected_components.pop(index)
+ self._update_table()
+
+ def on_axis_mode_changed(self, index):
+ """Update axis inputs based on mode."""
+ if index == 0:
+ # Custom axis
+ self.axis_widget.setEnabled(True)
+ else:
+ # Preset axis
+ self.axis_widget.setEnabled(False)
+ if index == 1: # X
+ self.axis_x_spin.setValue(1)
+ self.axis_y_spin.setValue(0)
+ self.axis_z_spin.setValue(0)
+ elif index == 2: # Y
+ self.axis_x_spin.setValue(0)
+ self.axis_y_spin.setValue(1)
+ self.axis_z_spin.setValue(0)
+ elif index == 3: # Z
+ self.axis_x_spin.setValue(0)
+ self.axis_y_spin.setValue(0)
+ self.axis_z_spin.setValue(1)
+
+ def on_angle_mode_changed(self, index):
+ """Update angle input based on mode."""
+ if index == 0:
+ # Full circle
+ self.angle_spin.setValue(360)
+ self.angle_spin.setEnabled(False)
+ else:
+ # Custom angle
+ self.angle_spin.setEnabled(True)
+
+ def accept(self):
+ """Create the polar pattern."""
+ Gui.Selection.removeObserver(self.observer)
+
+ if not self.selected_components:
+ App.Console.PrintError("No components selected for pattern\n")
+ return False
+
+ doc = App.ActiveDocument
+ if not doc:
+ return False
+
+ # Get axis
+ axis = App.Vector(
+ self.axis_x_spin.value(),
+ self.axis_y_spin.value(),
+ self.axis_z_spin.value(),
+ )
+
+ if axis.Length < 1e-6:
+ App.Console.PrintError("Axis vector cannot be zero\n")
+ return False
+
+ axis.normalize()
+
+ # Get center
+ center = App.Vector(
+ self.center_x_spin.value(),
+ self.center_y_spin.value(),
+ self.center_z_spin.value(),
+ )
+
+ occurrences = self.occurrences_spin.value()
+ total_angle = self.angle_spin.value()
+
+ # Calculate angle increment
+ if self.angle_mode.currentIndex() == 0:
+ # Full circle - don't include last (would overlap first)
+ angle_step = 360.0 / occurrences
+ else:
+ # Custom angle
+ angle_step = total_angle / (occurrences - 1) if occurrences > 1 else 0
+
+ use_links = self.link_checkbox.isChecked()
+ hide_original = self.hide_original_checkbox.isChecked()
+
+ # Find parent assembly
+ assembly = self._find_parent_assembly(self.selected_components[0])
+
+ doc.openTransaction("Assembly Polar Pattern")
+
+ try:
+ created_objects = []
+
+ for comp in self.selected_components:
+ # Get base placement
+ if hasattr(comp, "Placement"):
+ base_placement = comp.Placement
+ else:
+ base_placement = App.Placement()
+
+ # Create pattern instances
+ for i in range(1, occurrences):
+ angle = angle_step * i
+
+ # Create rotation around axis through center
+ rotation = App.Rotation(axis, angle)
+
+ # Calculate new position
+ # Translate to origin, rotate, translate back
+ base_pos = base_placement.Base
+ relative_pos = base_pos - center
+ rotated_pos = rotation.multVec(relative_pos)
+ new_pos = rotated_pos + center
+
+ # Combine rotations
+ new_rotation = rotation.multiply(base_placement.Rotation)
+
+ new_placement = App.Placement(new_pos, new_rotation)
+
+ if use_links:
+ if comp.TypeId == "App::Link":
+ link_target = comp.LinkedObject
+ else:
+ link_target = comp
+
+ new_obj = doc.addObject("App::Link", f"{comp.Label}_Polar{i}")
+ new_obj.LinkedObject = link_target
+ new_obj.Placement = new_placement
+ else:
+ new_obj = doc.copyObject(comp, False)
+ new_obj.Label = f"{comp.Label}_Polar{i}"
+ new_obj.Placement = new_placement
+
+ if assembly and hasattr(assembly, "addObject"):
+ assembly.addObject(new_obj)
+
+ created_objects.append(new_obj)
+
+ if hide_original and hasattr(comp, "ViewObject"):
+ comp.ViewObject.Visibility = False
+
+ doc.commitTransaction()
+ doc.recompute()
+
+ App.Console.PrintMessage(
+ f"Created {len(created_objects)} polar pattern instances\n"
+ )
+
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to create pattern: {e}\n")
+ return False
+
+ return True
+
+ def reject(self):
+ """Cancel the operation."""
+ Gui.Selection.removeObserver(self.observer)
+ return True
+
+ def _find_parent_assembly(self, obj):
+ """Find the parent assembly of an object."""
+ doc = obj.Document
+
+ for candidate in doc.Objects:
+ if candidate.TypeId == "Assembly::AssemblyObject":
+ if hasattr(candidate, "Group"):
+ if obj in candidate.Group:
+ return candidate
+ for member in candidate.Group:
+ if hasattr(member, "Group") and obj in member.Group:
+ return candidate
+
+ return None
+
+ def getStandardButtons(self):
+ return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel)
+
+
+class AssemblyPolarPatternCommand:
+ """Command to create a polar pattern of assembly components."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("assembly_polar_pattern"),
+ "MenuText": "Assembly Polar Pattern",
+ "ToolTip": "Create a polar (circular) pattern of assembly components",
+ }
+
+ def IsActive(self):
+ return App.ActiveDocument is not None
+
+ def Activated(self):
+ panel = AssemblyPolarPatternTaskPanel()
+ Gui.Control.showDialog(panel)
+
+
+# =============================================================================
+# Register Commands
+# =============================================================================
+
+Gui.addCommand("ZTools_AssemblyLinearPattern", AssemblyLinearPatternCommand())
+Gui.addCommand("ZTools_AssemblyPolarPattern", AssemblyPolarPatternCommand())
diff --git a/ztools/ztools/commands/spreadsheet_commands.py b/ztools/ztools/commands/spreadsheet_commands.py
new file mode 100644
index 0000000..d5bf6ec
--- /dev/null
+++ b/ztools/ztools/commands/spreadsheet_commands.py
@@ -0,0 +1,567 @@
+# ztools/commands/spreadsheet_commands.py
+# Enhanced spreadsheet formatting tools for FreeCAD
+# Provides quick formatting toolbar for cell styling
+
+import FreeCAD as App
+import FreeCADGui as Gui
+from PySide import QtCore, QtGui
+
+from ztools.resources.icons import get_icon
+
+# =============================================================================
+# Helper Functions
+# =============================================================================
+
+
+def get_active_spreadsheet():
+ """Get the currently active spreadsheet object and its view.
+
+ Returns:
+ tuple: (sheet_object, sheet_view) or (None, None)
+ """
+ doc = App.ActiveDocument
+ if not doc:
+ return None, None
+
+ # Get MDI area and active subwindow
+ main_window = Gui.getMainWindow()
+ mdi = main_window.centralWidget()
+ if not mdi:
+ return None, None
+
+ subwindow = mdi.activeSubWindow()
+ if not subwindow:
+ return None, None
+
+ # Get widget from subwindow
+ widget = subwindow.widget()
+ if not widget:
+ return None, None
+
+ # Check if it's a spreadsheet view by looking for the table view
+ # FreeCAD's spreadsheet view contains a QTableView
+ table_view = None
+ if hasattr(widget, "findChild"):
+ table_view = widget.findChild(QtGui.QTableView)
+
+ if not table_view:
+ # Try if widget itself is the table view
+ if isinstance(widget, QtGui.QTableView):
+ table_view = widget
+ else:
+ return None, None
+
+ # Get the spreadsheet object from window title
+ # Window title format varies: "Spreadsheet" or "Spreadsheet - DocName"
+ title = subwindow.windowTitle()
+ sheet_name = title.split(" - ")[0].split(" : ")[0].strip()
+
+ # Try to find the sheet object
+ sheet = doc.getObject(sheet_name)
+ if sheet and sheet.TypeId == "Spreadsheet::Sheet":
+ return sheet, table_view
+
+ # Fallback: search for any spreadsheet object
+ for obj in doc.Objects:
+ if obj.TypeId == "Spreadsheet::Sheet":
+ return obj, table_view
+
+ return None, None
+
+
+def get_selected_cells():
+ """Get list of selected cell addresses from active spreadsheet.
+
+ Returns:
+ tuple: (sheet_object, list_of_cell_addresses) or (None, [])
+ """
+ sheet, table_view = get_active_spreadsheet()
+ if not sheet or not table_view:
+ return None, []
+
+ # Get selection model
+ selection_model = table_view.selectionModel()
+ if not selection_model:
+ return sheet, []
+
+ indexes = selection_model.selectedIndexes()
+ if not indexes:
+ return sheet, []
+
+ cells = []
+ for idx in indexes:
+ col = idx.column()
+ row = idx.row()
+
+ # Convert to cell address (A1 notation)
+ # Handle columns beyond Z (AA, AB, etc.)
+ col_str = ""
+ temp_col = col
+ while temp_col >= 0:
+ col_str = chr(65 + (temp_col % 26)) + col_str
+ temp_col = temp_col // 26 - 1
+
+ cell_addr = f"{col_str}{row + 1}"
+ cells.append(cell_addr)
+
+ return sheet, cells
+
+
+def column_to_index(col_str):
+ """Convert column string (A, B, ..., Z, AA, AB, ...) to index."""
+ result = 0
+ for char in col_str:
+ result = result * 26 + (ord(char) - ord("A") + 1)
+ return result - 1
+
+
+# =============================================================================
+# Style Commands (Bold, Italic, Underline)
+# =============================================================================
+
+
+class ZTools_SpreadsheetStyleBold:
+ """Toggle bold style on selected cells."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_bold"),
+ "MenuText": "Bold",
+ "ToolTip": "Toggle bold style on selected cells (Ctrl+B)",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Toggle Bold")
+
+ try:
+ for cell in cells:
+ current = sheet.getStyle(cell) or ""
+ styles = set(s.strip() for s in current.split("|") if s.strip())
+
+ if "bold" in styles:
+ styles.discard("bold")
+ else:
+ styles.add("bold")
+
+ new_style = "|".join(sorted(styles)) if styles else ""
+ sheet.setStyle(cell, new_style)
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to toggle bold: {e}\n")
+
+
+class ZTools_SpreadsheetStyleItalic:
+ """Toggle italic style on selected cells."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_italic"),
+ "MenuText": "Italic",
+ "ToolTip": "Toggle italic style on selected cells (Ctrl+I)",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Toggle Italic")
+
+ try:
+ for cell in cells:
+ current = sheet.getStyle(cell) or ""
+ styles = set(s.strip() for s in current.split("|") if s.strip())
+
+ if "italic" in styles:
+ styles.discard("italic")
+ else:
+ styles.add("italic")
+
+ new_style = "|".join(sorted(styles)) if styles else ""
+ sheet.setStyle(cell, new_style)
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to toggle italic: {e}\n")
+
+
+class ZTools_SpreadsheetStyleUnderline:
+ """Toggle underline style on selected cells."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_underline"),
+ "MenuText": "Underline",
+ "ToolTip": "Toggle underline style on selected cells (Ctrl+U)",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Toggle Underline")
+
+ try:
+ for cell in cells:
+ current = sheet.getStyle(cell) or ""
+ styles = set(s.strip() for s in current.split("|") if s.strip())
+
+ if "underline" in styles:
+ styles.discard("underline")
+ else:
+ styles.add("underline")
+
+ new_style = "|".join(sorted(styles)) if styles else ""
+ sheet.setStyle(cell, new_style)
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to toggle underline: {e}\n")
+
+
+# =============================================================================
+# Alignment Commands
+# =============================================================================
+
+
+class ZTools_SpreadsheetAlignLeft:
+ """Align cell content to the left."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_align_left"),
+ "MenuText": "Align Left",
+ "ToolTip": "Align selected cells to the left",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Align Left")
+
+ try:
+ for cell in cells:
+ sheet.setAlignment(cell, "left")
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to align left: {e}\n")
+
+
+class ZTools_SpreadsheetAlignCenter:
+ """Align cell content to the center."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_align_center"),
+ "MenuText": "Align Center",
+ "ToolTip": "Align selected cells to the center",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Align Center")
+
+ try:
+ for cell in cells:
+ sheet.setAlignment(cell, "center")
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to align center: {e}\n")
+
+
+class ZTools_SpreadsheetAlignRight:
+ """Align cell content to the right."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_align_right"),
+ "MenuText": "Align Right",
+ "ToolTip": "Align selected cells to the right",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Align Right")
+
+ try:
+ for cell in cells:
+ sheet.setAlignment(cell, "right")
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to align right: {e}\n")
+
+
+# =============================================================================
+# Color Commands
+# =============================================================================
+
+
+class ZTools_SpreadsheetBgColor:
+ """Set background color of selected cells."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_bg_color"),
+ "MenuText": "Background Color",
+ "ToolTip": "Set background color of selected cells",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ # Show color picker dialog
+ color = QtGui.QColorDialog.getColor(
+ QtCore.Qt.white, Gui.getMainWindow(), "Select Background Color"
+ )
+
+ if not color.isValid():
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Set Background Color")
+
+ try:
+ # FreeCAD expects RGB as tuple of floats 0-1
+ rgb = (
+ color.redF(),
+ color.greenF(),
+ color.blueF(),
+ )
+
+ for cell in cells:
+ sheet.setBackground(cell, rgb)
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to set background color: {e}\n")
+
+
+class ZTools_SpreadsheetTextColor:
+ """Set text color of selected cells."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_text_color"),
+ "MenuText": "Text Color",
+ "ToolTip": "Set text color of selected cells",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ # Show color picker dialog with default white for dark theme
+ initial_color = QtGui.QColor(205, 214, 244) # Catppuccin Mocha text color
+ color = QtGui.QColorDialog.getColor(
+ initial_color, Gui.getMainWindow(), "Select Text Color"
+ )
+
+ if not color.isValid():
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Set Text Color")
+
+ try:
+ # FreeCAD expects RGB as tuple of floats 0-1
+ rgb = (
+ color.redF(),
+ color.greenF(),
+ color.blueF(),
+ )
+
+ for cell in cells:
+ sheet.setForeground(cell, rgb)
+
+ doc.commitTransaction()
+ doc.recompute()
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to set text color: {e}\n")
+
+
+# =============================================================================
+# Utility Commands
+# =============================================================================
+
+
+class ZTools_SpreadsheetQuickAlias:
+ """Create alias from cell content."""
+
+ def GetResources(self):
+ return {
+ "Pixmap": get_icon("spreadsheet_quick_alias"),
+ "MenuText": "Quick Alias",
+ "ToolTip": "Create alias for selected cell based on adjacent label cell",
+ }
+
+ def IsActive(self):
+ sheet, cells = get_selected_cells()
+ return sheet is not None and len(cells) > 0
+
+ def Activated(self):
+ sheet, cells = get_selected_cells()
+ if not sheet or not cells:
+ App.Console.PrintWarning("No cells selected\n")
+ return
+
+ doc = App.ActiveDocument
+ doc.openTransaction("Quick Alias")
+
+ try:
+ aliases_created = 0
+
+ for cell in cells:
+ # Parse cell address
+ import re
+
+ match = re.match(r"([A-Z]+)(\d+)", cell)
+ if not match:
+ continue
+
+ col_str = match.group(1)
+ row = int(match.group(2))
+
+ # Get content of cell to the left (label cell)
+ col_idx = column_to_index(col_str)
+ if col_idx > 0:
+ # Convert back to column string
+ label_col_idx = col_idx - 1
+ label_col_str = ""
+ temp = label_col_idx
+ while temp >= 0:
+ label_col_str = chr(65 + (temp % 26)) + label_col_str
+ temp = temp // 26 - 1
+
+ label_cell = f"{label_col_str}{row}"
+ label_content = sheet.getContents(label_cell)
+
+ if label_content:
+ # Clean the label to make a valid alias
+ # Must be alphanumeric + underscore, start with letter
+ alias = "".join(
+ c if c.isalnum() or c == "_" else "_"
+ for c in label_content
+ c if c.isalnum() or c == "_" else "_" for c in label_content
+ )
+ # Ensure it starts with a letter
+ if alias and not alias[0].isalpha():
+ alias = "var_" + alias
+ # Truncate if too long
+ alias = alias[:30]
+
+ if alias:
+ try:
+ sheet.setAlias(cell, alias)
+ aliases_created += 1
+ except Exception as alias_err:
+ App.Console.PrintWarning(
+ f"Could not set alias '{alias}' for {cell}: {alias_err}\n"
+ )
+
+ doc.commitTransaction()
+ doc.recompute()
+
+ if aliases_created > 0:
+ App.Console.PrintMessage(f"Created {aliases_created} alias(es)\n")
+ "No aliases created. Select value cells with labels to the left.\n"
+ )
+
+ except Exception as e:
+ doc.abortTransaction()
+ App.Console.PrintError(f"Failed to create aliases: {e}\n")
+
+
+# =============================================================================
+# Register Commands
+# =============================================================================
+
+Gui.addCommand("ZTools_SpreadsheetStyleBold", ZTools_SpreadsheetStyleBold())
+Gui.addCommand("ZTools_SpreadsheetStyleItalic", ZTools_SpreadsheetStyleItalic())
+Gui.addCommand("ZTools_SpreadsheetStyleUnderline", ZTools_SpreadsheetStyleUnderline())
+Gui.addCommand("ZTools_SpreadsheetAlignLeft", ZTools_SpreadsheetAlignLeft())
+Gui.addCommand("ZTools_SpreadsheetAlignCenter", ZTools_SpreadsheetAlignCenter())
+Gui.addCommand("ZTools_SpreadsheetAlignRight", ZTools_SpreadsheetAlignRight())
+Gui.addCommand("ZTools_SpreadsheetBgColor", ZTools_SpreadsheetBgColor())
+Gui.addCommand("ZTools_SpreadsheetTextColor", ZTools_SpreadsheetTextColor())
+Gui.addCommand("ZTools_SpreadsheetQuickAlias", ZTools_SpreadsheetQuickAlias())
diff --git a/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc b/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc
index 5120d08..7d6bbd2 100644
Binary files a/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc and b/ztools/ztools/resources/__pycache__/icons.cpython-312.pyc differ
diff --git a/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc b/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc
index b5f2b30..98f6975 100644
Binary files a/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc and b/ztools/ztools/resources/__pycache__/theme.cpython-312.pyc differ
diff --git a/ztools/ztools/resources/icons.py b/ztools/ztools/resources/icons.py
index cd711fe..c62db92 100644
--- a/ztools/ztools/resources/icons.py
+++ b/ztools/ztools/resources/icons.py
@@ -294,6 +294,136 @@ ICON_POCKET_FLIPPED_SVG = f'''
'''
+# Assembly Linear Pattern icon - components along a line
+ICON_ASSEMBLY_LINEAR_PATTERN_SVG = f''''''
+
+# Assembly Polar Pattern icon - components around a center
+ICON_ASSEMBLY_POLAR_PATTERN_SVG = f''''''
+
+# =============================================================================
+# Spreadsheet Icons
+# =============================================================================
+
+# Bold text icon
+ICON_SPREADSHEET_BOLD_SVG = f''''''
+
+# Italic text icon
+ICON_SPREADSHEET_ITALIC_SVG = f''''''
+
+# Underline text icon
+ICON_SPREADSHEET_UNDERLINE_SVG = f''''''
+
+# Align left icon
+ICON_SPREADSHEET_ALIGN_LEFT_SVG = f''''''
+
+# Align center icon
+ICON_SPREADSHEET_ALIGN_CENTER_SVG = f''''''
+
+# Align right icon
+ICON_SPREADSHEET_ALIGN_RIGHT_SVG = f''''''
+
+# Background color icon (paint bucket)
+ICON_SPREADSHEET_BG_COLOR_SVG = f''''''
+
+# Text color icon (A with color bar)
+ICON_SPREADSHEET_TEXT_COLOR_SVG = f''''''
+
+# Quick alias icon (tag/label)
+ICON_SPREADSHEET_QUICK_ALIAS_SVG = f''''''
+
# =============================================================================
# Icon Registry - Base64 encoded for FreeCAD
@@ -331,6 +461,17 @@ def get_icon(name: str) -> str:
"rotated_pattern": ICON_ROTATED_PATTERN_SVG,
"pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
"pocket_flipped": ICON_POCKET_FLIPPED_SVG,
+ "assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
+ "assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
+ "spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
+ "spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
+ "spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
+ "spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
+ "spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
+ "spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
+ "spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
+ "spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
+ "spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
}
if name not in icons:
@@ -377,6 +518,17 @@ def save_icons_to_disk(directory: str):
"ztools_rotated_pattern": ICON_ROTATED_PATTERN_SVG,
"ztools_pocket_enhanced": ICON_POCKET_ENHANCED_SVG,
"ztools_pocket_flipped": ICON_POCKET_FLIPPED_SVG,
+ "ztools_assembly_linear_pattern": ICON_ASSEMBLY_LINEAR_PATTERN_SVG,
+ "ztools_assembly_polar_pattern": ICON_ASSEMBLY_POLAR_PATTERN_SVG,
+ "ztools_spreadsheet_bold": ICON_SPREADSHEET_BOLD_SVG,
+ "ztools_spreadsheet_italic": ICON_SPREADSHEET_ITALIC_SVG,
+ "ztools_spreadsheet_underline": ICON_SPREADSHEET_UNDERLINE_SVG,
+ "ztools_spreadsheet_align_left": ICON_SPREADSHEET_ALIGN_LEFT_SVG,
+ "ztools_spreadsheet_align_center": ICON_SPREADSHEET_ALIGN_CENTER_SVG,
+ "ztools_spreadsheet_align_right": ICON_SPREADSHEET_ALIGN_RIGHT_SVG,
+ "ztools_spreadsheet_bg_color": ICON_SPREADSHEET_BG_COLOR_SVG,
+ "ztools_spreadsheet_text_color": ICON_SPREADSHEET_TEXT_COLOR_SVG,
+ "ztools_spreadsheet_quick_alias": ICON_SPREADSHEET_QUICK_ALIAS_SVG,
}
for name, svg in icons.items():
diff --git a/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg b/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg
new file mode 100644
index 0000000..c4ff109
--- /dev/null
+++ b/ztools/ztools/resources/icons/ztools_assembly_linear_pattern.svg
@@ -0,0 +1,17 @@
+
\ No newline at end of file
diff --git a/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg b/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg
new file mode 100644
index 0000000..8bcd089
--- /dev/null
+++ b/ztools/ztools/resources/icons/ztools_assembly_polar_pattern.svg
@@ -0,0 +1,27 @@
+
\ No newline at end of file
diff --git a/ztools/ztools/resources/theme.py b/ztools/ztools/resources/theme.py
index a7c2652..32d2692 100644
--- a/ztools/ztools/resources/theme.py
+++ b/ztools/ztools/resources/theme.py
@@ -1218,6 +1218,43 @@ SpreadsheetGui--SheetTableView QHeaderView::section {{
padding: 4px;
}}
+SpreadsheetGui--SheetTableView::item {{
+ color: {_text};
+ background-color: {_base};
+}}
+
+SpreadsheetGui--SheetTableView::item:selected {{
+ color: {_text};
+ background-color: {_surface1};
+}}
+
+SpreadsheetGui--SheetTableView::item:focus {{
+ color: {_text};
+}}
+
+/* Spreadsheet cell editor */
+SpreadsheetGui--SheetTableView QLineEdit {{
+ color: {_text};
+ background-color: {_surface0};
+ border: 1px solid {_mauve};
+ selection-background-color: {_mauve};
+ selection-color: {_crust};
+}}
+
+/* Spreadsheet module - additional selectors for cell content */
+Spreadsheet--SheetModel {{
+ color: {_text};
+}}
+
+SpreadsheetGui--SheetView {{
+ background-color: {_base};
+ color: {_text};
+}}
+
+SpreadsheetGui--SheetView::item {{
+ color: {_text};
+}}
+
/* Python Console */
Gui--PythonConsole {{
background-color: {_crust};
@@ -1304,3 +1341,53 @@ def get_stylesheet() -> str:
str: Complete QSS stylesheet.
"""
return generate_stylesheet()
+
+
+def apply_spreadsheet_colors():
+ """Apply Catppuccin Mocha colors to FreeCAD Spreadsheet preferences.
+
+ FreeCAD's Spreadsheet module uses internal preference settings for colors
+ rather than pure QSS styling. This function sets those preferences to
+ match the Catppuccin Mocha theme.
+ """
+ import FreeCAD as App
+
+ # Get the parameter group for Spreadsheet colors
+ params = App.ParamGet("User parameter:BaseApp/Preferences/Mod/Spreadsheet")
+
+ # Convert hex color to unsigned int (FreeCAD stores colors as unsigned 32-bit RGBA)
+ def hex_to_rgba_uint(hex_color: str) -> int:
+ """Convert hex color (#RRGGBB) to FreeCAD's unsigned int format (0xRRGGBBAA)."""
+ hex_color = hex_color.lstrip("#")
+ r = int(hex_color[0:2], 16)
+ g = int(hex_color[2:4], 16)
+ b = int(hex_color[4:6], 16)
+ a = 255 # Full opacity
+ # FreeCAD uses RGBA format as unsigned int
+ return (r << 24) | (g << 16) | (b << 8) | a
+
+ # Set text/foreground color to Catppuccin Mocha "text" (#cdd6f4)
+ text_color = hex_to_rgba_uint(MOCHA["text"])
+ params.SetUnsigned("TextColor", text_color)
+
+ # Set background colors
+ bg_color = hex_to_rgba_uint(MOCHA["base"])
+ params.SetUnsigned("BackgroundColor", bg_color)
+
+ # Alternate row background
+ alt_bg_color = hex_to_rgba_uint(MOCHA["surface0"])
+ params.SetUnsigned("AltBackgroundColor", alt_bg_color)
+
+ # Alias text color (for cells with aliases) - use teal for distinction
+ alias_color = hex_to_rgba_uint(MOCHA["teal"])
+ params.SetUnsigned("AliasedTextColor", alias_color)
+
+ # Positive number color - green
+ positive_color = hex_to_rgba_uint(MOCHA["green"])
+ params.SetUnsigned("PositiveNumberColor", positive_color)
+
+ # Negative number color - red
+ negative_color = hex_to_rgba_uint(MOCHA["red"])
+ params.SetUnsigned("NegativeNumberColor", negative_color)
+
+ App.Console.PrintLog("ztools: Applied Catppuccin Mocha spreadsheet colors\n")