From a66dac7afc05f698daee4f1f388c6c55b3864d35 Mon Sep 17 00:00:00 2001 From: Zoe Forbes Date: Sat, 24 Jan 2026 23:24:39 -0600 Subject: [PATCH] Improve datum behaviour --- PLAN.md | 198 ++++ ztools/ztools/commands/__init__.py | 9 +- .../__pycache__/__init__.cpython-312.pyc | Bin 319 -> 350 bytes .../datum_commands.cpython-312.pyc | Bin 30027 -> 44002 bytes .../datum_viewprovider.cpython-312.pyc | Bin 0 -> 20052 bytes ztools/ztools/commands/datum_commands.py | 898 +++++++++++------- ztools/ztools/commands/datum_viewprovider.py | 405 ++++++++ ztools/ztools/datums/__init__.py | 24 +- .../__pycache__/__init__.cpython-312.pyc | Bin 707 -> 823 bytes .../datums/__pycache__/core.cpython-312.pyc | Bin 41705 -> 47055 bytes ztools/ztools/datums/core.py | 732 +++++++++----- 11 files changed, 1675 insertions(+), 591 deletions(-) create mode 100644 PLAN.md create mode 100644 ztools/ztools/commands/__pycache__/datum_viewprovider.cpython-312.pyc create mode 100644 ztools/ztools/commands/datum_viewprovider.py diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4ba5cdc --- /dev/null +++ b/PLAN.md @@ -0,0 +1,198 @@ +# ZTools Development Plan + +## Current Status: v0.1.0 (70% complete) + +### What's Working +- Workbench registration with 10 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) +- Metadata storage system (ZTools_Type, ZTools_Params, ZTools_SourceRefs) +- Spreadsheet linking for parametric control + +### Recent 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 +- Fixed all 3 point functions (`point_on_edge`, `point_center_of_face`, `point_center_of_circle`) to accept source parameters +- Removed redundant "Create Datum" button - OK now creates the datum +- Task panel properly cleans up selection observer on close + +--- + +## ZTools Attachment System + +FreeCAD's vanilla attachment system has reliability issues. ZTools uses a custom approach: + +1. **Calculate placement directly** from source geometry at creation time +2. **Store source references** in `ZTools_SourceRefs` property (JSON) +3. **Use `MapMode='Deactivated'`** to prevent FreeCAD attachment interference +4. **Store creation parameters** in `ZTools_Params` for potential recalculation + +This gives full control over datum positioning while maintaining the ability to update datums when source geometry changes (future feature). + +### Metadata Properties + +All ZTools datums have these custom properties: +- `ZTools_Type`: Creation method identifier (e.g., "offset_from_face", "midplane") +- `ZTools_Params`: JSON-encoded creation parameters +- `ZTools_SourceRefs`: JSON-encoded list of source geometry references + +--- + +## Phase 1: Complete (Datum Tools) + +All datum creation functions now work: + +### Planes (6 modes) +- Offset from Face +- Midplane (2 Faces) +- 3 Points +- Normal to Edge +- Angled from Face +- Tangent to Cylinder + +### Axes (4 modes) +- 2 Points +- From Edge +- Cylinder Center +- Plane Intersection + +### Points (5 modes) +- At Vertex +- XYZ Coordinates +- On Edge (with parameter) +- Face Center +- Circle Center + +--- + +## Phase 2: Complete Enhanced Pocket + +### 2.1 Wire Up Pocket Execution (pocket_commands.py) + +The EnhancedPocketTaskPanel has complete UI but no execute logic. + +Required implementation: +1. Get selected sketch from user +2. Create PartDesign::Pocket with selected type +3. Apply "Flip Side to Cut" by: + - Reversing the pocket direction, OR + - Using a boolean cut approach with inverted profile +4. Handle all pocket types: Dimension, Through All, To First, Up To Face, Two Dimensions + +### 2.2 Register Pocket Command + +Add to InitGui.py toolbar if not already present. + +--- + +## Phase 3: Datum Manager + +### 3.1 Implement DatumManagerTaskPanel + +Replace the stub in datum_commands.py with functional panel: + +Features: +- List all datum objects (planes, axes, points) in document +- Filter by type (ZTools-created vs native) +- Toggle visibility (eye icon per item) +- Rename datums inline +- Delete selected datums +- Jump to datum in model tree + +UI Layout: +``` ++----------------------------------+ +| Filter: [All v] [ZTools only ☐] | ++----------------------------------+ +| ☑ ZPlane_Offset_001 [👁] [🗑] | +| ☑ ZPlane_Mid_001 [👁] [🗑] | +| ☐ ZAxis_Cyl_001 [👁] [🗑] | ++----------------------------------+ +| [Rename] [Show All] [Hide All] | ++----------------------------------+ +``` + +--- + +## Phase 4: Additional Features (Future) + +### 4.1 Module 2 Completion: Enhanced Pad +- Multi-body support +- Draft angles on pad +- Lip/groove profiles + +### 4.2 Module 3: Body Operations +- Split body at plane +- Combine bodies +- Shell improvements + +### 4.3 Module 4: Pattern Tools +- Curve-driven pattern (sweep instances along spline) +- Fill pattern (populate region with instances) +- Pattern with variable spacing + +### 4.4 Datum Update Feature +- Use stored `ZTools_SourceRefs` to recalculate datum positions +- Handle topology changes gracefully +- Option to "freeze" datums (disconnect from sources) + +--- + +## File Reference + +| File | Purpose | Lines | +|------|---------|-------| +| `ztools/ztools/datums/core.py` | Datum creation functions | ~750 | +| `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 | + +--- + +## Testing Checklist + +### Phase 1 Tests (Datum Tools) +- [ ] Create plane offset from face +- [ ] Create midplane between 2 faces +- [ ] Create plane from 3 points +- [ ] Create plane normal to edge at various parameters +- [ ] Create angled plane from face about edge +- [ ] Create plane tangent to cylinder +- [ ] Create axis from 2 points +- [ ] Create axis from edge +- [ ] Create axis at cylinder center +- [ ] Create axis at plane intersection +- [ ] Create point at vertex +- [ ] Create point at XYZ coordinates +- [ ] Create point on edge at parameter 0.0, 0.5, 1.0 +- [ ] Create point at face center (planar and cylindrical) +- [ ] Create point at circle center (full circle and arc) +- [ ] Verify ZTools_Type, ZTools_Params, ZTools_SourceRefs properties exist +- [ ] Verify no "deactivated attachment mode" warnings in console + +### Phase 2 Tests (Enhanced Pocket) +- [ ] Create pocket with Dimension type +- [ ] Create pocket with Through All +- [ ] Create pocket with Flip Side to Cut enabled +- [ ] Verify pocket respects taper angle + +### Phase 3 Tests (Datum Manager) +- [ ] Datum Manager lists all datums +- [ ] Visibility toggle works +- [ ] Rename persists after recompute +- [ ] Delete removes datum cleanly + +--- + +## Notes + +- FreeCAD 1.0+ required (TNP mitigation assumed) +- ZTools uses custom attachment system (not FreeCAD's vanilla attachment) +- Catppuccin Mocha theme is bundled as preference pack +- LGPL-3.0-or-later license diff --git a/ztools/ztools/commands/__init__.py b/ztools/ztools/commands/__init__.py index 6bd2534..70d804a 100644 --- a/ztools/ztools/commands/__init__.py +++ b/ztools/ztools/commands/__init__.py @@ -1,4 +1,9 @@ # ztools/commands - GUI commands -from . import datum_commands, pattern_commands, pocket_commands +from . import datum_commands, datum_viewprovider, pattern_commands, pocket_commands -__all__ = ["datum_commands", "pattern_commands", "pocket_commands"] +__all__ = [ + "datum_commands", + "datum_viewprovider", + "pattern_commands", + "pocket_commands", +] diff --git a/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/commands/__pycache__/__init__.cpython-312.pyc index 0eccf2d48db1b1c6ab6752b68911eeaadc8d8857..17471f63cbcf7c5b3f60ab71bbcf770cb324331a 100644 GIT binary patch delta 171 zcmdnbbdQPmG%qg~0}woPEzNv9kykQQ1<0Askiw9{n8T3E7{!>&6vdRw9L1c=62-#E zkPZ}I#2Cd|$)w5rk`bs>ljRm)N@7WAZhUfnZf;^;O7Sfr2)`^dwY;DxzbrE)wP<3p zstDUHh9YL5jv^Ki!3rYSCQfft;Q_KhCKU?-i4V+-jEv72f-f@!e_~ zp*{}f<>5WNT($5P-a2kMX=T4fCvEK4e$tNLqH)K$;*-VaoF|=DOOa*2g|~gn!rR{~ zl5;r8F+T_ViZ#Cy=I4YTr}>pKzY_SBYJRRFOV~D6rvjz_^2jw@`kvyU#;5WN#7FL7 zSAP5{Yrypxa4|L&eD=-U?JWQDG}k zVgHupq$6C+qpE5_C!P2%HhgnaMFCDKI}siakH#a>iG%U*xkTIX@OdE|3s1yj{*XV$ z!os}&OgMTj92chi5kv|OjoJ+W5j-~hKYWqFGi{x=jA+5rMfkF+pVKycsqkB-En`kK zME_OcMNF-fDa%dkP{1MCqNm0rXKeD+MCcrF6FVC^AC^2Ld^jE!&P67|Bk?JCaDbM* z1?-X?RX8n_AbWubOmM-8QNDgWzZ~y5i+c5(jtZy3v7X@TA)zO9{(MhVI1@ZAgu|mD zKKKlN2l@wl_)t95qkbHT@;xVqqtS6B>Pf`ei}EuXJ$EiN!N(}U`GkAVV;zmXy z6Os7HNTMbS=|R=MjDYKabIFpas+qN4;WD+&_;F{d8}L(_saut<>lW*}lXbya`#&su zh^UrP4ZHZDuEN@4qL^p{7^WyW&z$wr`Z&d658`NeR{bpKVk#leS%n(JL4LW9aLn9) zjL@C)@I~(_a0**X2riQ{#~2I9PB=z5$$896Rr5De-^7RJala7cHZ|v8DdaB|fc!ag ze^Z>>HORO8>TnMDa@vbNFa2J;LiI)rlh)@QV9E+r&RBWpwDomM?6t?IQ-LsMlCLRM zRjRP)zrwCBm!9Y9k=d9}&ry9Ft5x6hUzN)V)~S-I(l{k$k2&_@4QlwrO=&95Nj2vaM5PU$XR0HCO%DkbC)5`J?hTz}ltfp}raN@=TSsTH@tuNSj5l zTP=+t;*f)5Ll{WRrKJQ@*3$B|c<<@GwtD^SBmeaU$?6br@Uf-Vh6rLlNOh{rBO z;%9{_DxmaCIKFQ@d=B!DRI=~lX!txyMv@cWlvHvMs4>ZTVp3oQa*-H;7ZRk>NGvje z>?Wux&mmWVIDy0sC=d$OtO5g*!s~!@K!(%RFN_{Nt$f%ILxK@%5G_z7P*X`xLflA% zk5T3Qe!+tu!3`(Tlp{c7OF&eWST7P?vV7w5yxDiTPjoeX>ZzHFU%fbgak2KUCy=SC zy}IkIU5kS^4qrcGs`X`|c0oKbih; zI@x}B!Ty8N`^A=;hN~~V_0l4LWBmGfdhJu<+NVA^om#u^i4&hqt=<2~#BZ10=PYh7 zRc9I1scBl^-#L5j>=Ji-O|oXo?EZ|WW}!p$wA}O7F4*7Uu5pVUDQ{QCS9kTm{DG@O z^FwK0=N(^Xwuk8JT&fU#>(ahWqHj~$w@dWxO8ItwQrn!VTJ@=`dXB$3Hb3@m+l|2W zK+4tqsi%IS?VYY`UFn8yv7!4G|D$s^&!s%OGPNz4it4Kk^9|{WRAQnB4qiW)DhnDi>Jl5eZVmtF8#ll4$8G7pXT`o}m%9(8Jcl#2 ztM64bE)2f&?6qgptGdNiR9xg{Bvr9JKi5Oo52eaBWU3lJ@znjz&rVn^tJ)t}tkq3Y z-KzUG{Qi%hl|F{oFJoIlI)75O`LNUSr(K&5*INFybMxU2%bz<-4*P6>US%b>w&ZY| z?ay1S($%38(`rY-ryE5oNXXnpeotU3ku5MpEwiv%LeSLay zV!3L|vU^L$Q}yP>%NO5#0P*NCM-uN;2MVC3B+*W&iHt@k{G(?>LTD6ID~23x3P?;TI7tBf_3!^s`TfW8 z@73Rve=mJc`Tn-@{X@yRC*TyCkVqg|Mj$apSWONI9fF^nMmU)I+Q4iD68lv)=pblf zMkXkg$ea=WiP~(`YXL`)gNk>~Qr&<#RHm*eGoRRYIN!2-Y!5!P+Z{a*+`W$a z`=_mz;_}an?2hJ#HoV=ZxBG79=W?uPexDcNP0p0E8pXf%sD0w$AHgeIS8xXEk{^<; z04kD`4yG|+`ht^A(h{8Hc&J29mhc?hQXZ<1lP2`BMH1wAYCP<}a5gN2{gbh<5c7|QCj23u?-9c1 zqOTid8d8KHE6QmYvW{FN5uYD2PG~Y7JqK7uL*wI9ej1$7jd=g5Pz)}5R!r5o_(8#O zQntuB1-t=!!h7`e=@|OpX(4*f&qO0vl=+O1$N^TQ^&&*BFh(X(BrLg=*sT5mtK8qH z(;<50kqFQH{hb?G-m!qhom2eK-&oeh{JBm3=NQ%`Zqta2Kk!FCax*we=H)|C;aq6k zACLN(Ea#eF-y zDEtF}0MaMN;sX-lupxSb8lAO^{~$$;$+|YFoTUIc%3gqqfR`PmG!H|(AH^v$v76z4 zM3HK(tR4`Fsd)gLTZ{hU*H20%a@>nk31Kf~d;$(4mC2Dnf~=mUaxA@~N;IFA7txf@p)hF3v_^|9OR{Qw1zuSu2O%;}PbFlHW;Bq{|3eARRZCnOx6B4~b=sVxMzkYo z&SR!pgi=e%-D)|5(NI?~Vq_%EV9ukKW~GtSL=lx0P7FQg(Zi=Om<=U-vd+-Y*qUGA zYXMFm@FnN5uSGy`aZF{XXl9V*GF(kR`Atf52+k$TJzxFR zBlAbTa};{Xk__j5v*dC~n)8dCf4OB_irb!P_Rrc=Tq6aQT`s%g!AqKJ7rFKn*TLR9 zM6Tm57swZHNGHq)Vtdu22?7mc()emKGeO8i$80171HsW?pY!N?%b6lWOm$o}#GckP zoHI5@eEfe`p~80_6eYxsM9% z7)@Yv9^Rp1x*#koVRJwMGON?Dtq;*q5U^3(7ok4&{LKb1z`>+{31=-W2dlOMyp zAL(!yIKhVe)N0u?c0;_4`SGv}sSI4;8CuJu?9KUc$7l+l^Nj7t@xUii_vQPlkxiIq2*5 z0yJULE{~DQ1b2^-YOe~H{%c5O8{4P*t8dfV$`I;cHLZg-nq}eLY=kUROPg^RsMu_T z)JBV$VnfVlRH*b{zFf@{ZP}*!qb(1p@A~i9A^lx_<2`D6)UVjIex`ZpsSNO~)Nh^B zPP5z%sm*HVRm)L-vv5pPh@3~|6Tk*753>i@pov1xGj=S;1D|{aZ^;@XkLSl3w7d*% z@DgOH8E&RzrgVl@dmcCc%4rv0%vQgi&(B$f9U8o~3Rc-7sP|iy^Rl+BrKAG=p z$VVHY4Eg+qNeaaaCHJbK$orJ~uK$jO^>_7+ujXs?7A%nNj2c`2s>iMssoU5C=uDQQO>0%pH)PeyES*`c8g=*zlx9||ReGw&u9Y^jqnvTIhWf8* zt)LRls+CzfvsyLj@D(U6s+X+3@y&Xw$F3ErJ=ueD&a3bGuW7AvDbOsP8P=bKmNeN3 z@CiCx>f2+%XO`}(f=`p;0G}>stKeB43qG@SkASa$+^`~tdL&O=t z&6F8pzOGeUd*S_Rp5S(xgf(4eHf{rh7qx`ii<&+Qdb5&R)R-vgDK$CrOsMbr@7Rp~ zuDawI@7Qna@9J9tX~3+^Z>eeYU$u_Fb$J0=FoEMSQkj%*NL6Mk zS7`DspfQ&#GKkM{O}PnmW82VYBy>n6Cse)RXQWgM*d+TfRw*Q>V&)*! z!S@+vUhx^F|BO1@XTJ|8;qK>oznnbGOU1|83P_@4U_2ZW{QcwOiBegSN%{S-dKNKwn^=thRL>d*SKm#lxYg=ww_V;S5G4;rNO3p;4?v zNhQbj3(?8*h#|R;4b$S1qU0@sCU%6aHZe`_Ky-Za+{8e15_Tu`NPi$IL=wPNX#4JzT2VJY9X=a+JrWh9ievq!VsTm#CQD(KN2%QC$Deb} zHyJzo^kh5^yBrrHkxqN$R2)V+qvMg$S3#*xEUHbQ@=`Sgk4_2#trH=dy7nTkZ`OqL84)@o1k)CIp|{>af*j%_EgEJ^MHr3`k|HU8csvCy1Uv2LsV_ zr=mm$axB;dpNdLOWJ1W497m20?mHnlCeLHJSgKNBJ_ysMivwqAp^KO5q7$+rAIp%e zi;RpaA@1|kCt@RKh}3|fXRv;tfL5ji!OQ{lDU}}^j7|c(C(cJE2o`l=F@|rpqHvtb zVs9^m#wXDVRE^2grz3zHL0I)>QA=62@;j?3XdSu6!{?>qi?Vm|l=7R9e}RFs;n7#A z)GF9)y$WBgyN!&Vl3ZAB9XW-5MjzCLSaj68e3bP%dHo$>CCA84iYQC+8iT^*Lkagy zP(K41#G_|ukx}w0%ODCaCL;llY+qHZpoMit8GFcJd_={TLRsp!+;R$*m9}PR^=M5Z zr(~=Bl9NS=@RD@|hH6-cITID8q_U{Yca+vDS3i|9UCfw3{)mzGpB9)6k8A_>GV!|t zi#5OiTNnEKh!aZ{v&EW7e2b5i-(fF4YMWpsvFYPYsKodi&M$!-q})wH>^(AuM_LnCF|E?&8M6ecFHd!4_eb% z45heE&1ZwiZMf5WAjKWjylAl}$pzBf9+BJgQ%8z>CO>Gm$nDO!%dSkOJ$}*SPkVYr zPw&rcMI~f$R#GOHre%~DyHoUZK7MSoyw{1`x?B6w-2-CxK)QQK>>f&SM>ROtirm_F zC(|9f#g5(Sjw52nkrX$ig%Vs#wiMT``4C=`Tt}MQC~_NbZ%J`GGF)YvTP1R<7KYz> z<=QKVte~|^tnW(Jch8pJYxJiZyT!)trB{-TyJ!Ke4*8|II+3fBSJ%>9r^t0K`O<*_ zF))zg2Jbburki`j=APSJvU!KP=9cCfMXoW;wTfJ8nxl#>4W_#X#qPlrx9?ulnsif8 zYzi)YGugD0k~FYV8bq!k&8-o+HEFI(Dhdox^(T>hdj-O?+z^rpCt zS$>` zJ=c2}V$}*!Wpso!q_{Rzt!d3WN3R`SI-Fd^n!8?a&Um_Kuh_FU+0@S(6>(R!q#Jw0 z#-3zjZ!R?vJ{H$5VLf((fyS8cniSV=@FR*?1OW#WyavUFV6tID7Sd?tchR>POL6P8 zCSrb-09x~5xue9+N99zp*q`FoYUx-`RWek1^DmC3xGv;WyDDANCDwE$Yq|lGi&D8R zyOgf>!s7D^Ow5nbNf_7pSE`1*mHf)@}>jH)`Qe`{;a;WLu}cR;`%;q^uM#`+MeZgdyKoF-sZ?qv!Gs!nk|%+HL` z^o!m7>F&c~_u&+G#Lz#}&Ano?%sW&P=hMxb#AcaKs63`I#jV$I)&z?IHUg-<{4Up; zZ#6(x0L*HDrg~T9&3{&G;M45&XRJk-TTN@TB+OJ1Q-0z!>0ytVHG!Ea3FfwH62q@y z24;h`&BhZn78|U+RLcyST973DDl;8QWs&V9 zt*}NSYrLDX{krgP5Z7q7DI1v_|H>fL3{$X~!4fM$Y1lZ|v}Vr!y;7M#9Pm;ftp=oT zEM$^AMkJK;sHzRCMX4vMM>UgHK_XYS3F@;8RVoiSWEs;2n^3mnK(b-qqGUTCjl~Eo zCetx#PI6-wpkY7p!dHhLW4_oBD>oic)Goi9y$w~5|ucfC7gTu}#7pRW#A!biw6 zU}v}x{%^d?yD$C>Z`w9Y84DhH7N#TO>t7ura{4vaGre!3cXNRh$%|0_iAaYvq4WZ_DJ|A!jc+gd4eODl|A% z-XQ!d1lp+A)!^k@mIp=FvepO1*0Mh4Jft@{gCxR-uo0J2IgVt*hGyXuyxH_!%@G!m z>YrWF>GmJwLIZhb|~vZ3R&@EP9O8H_si2@vS#+5 zV)_$5_AAB1L8km03KR=>0iWD54LyblK!z|>aYXwY;xv8{^O zUyQH>t|re={~bOO8_ip7CAL{bEk~<&mn@%_SG;-d^0{<*mssAFEbpGR-K(sg8~sk_ z!m;`8Stk=dZ1%IPO9|zy`TjLB{&LMXZN(z_4_IRrnQe!ju}v45H6oOA zE1 z4>saEruFT%PLQ98q29D%x%g%0}!O*qo>9YQSzLaJf-Nw zNx>VFO2QM9=fVPdi10B*EJXx4Zj9~Okg7(|l?)?4Z3&QSg)n4-*ja-=Q}YTWZNk=< zF*%9yv&Lk(z#0+$4Bzr5MLXM9K&*fX2wH!-0-wY>^ZrCo>PGm#M9Ld(*;e?@V8t zPBwPmsqN1ATW+|oyO)k#_bfQ>HFhplEsfsZl5E^DH;DapXs}hZ>)ZHwnPv6P zpH*7w+80kOty$uewVUSb5J1x2PSFcJ#$9jlexs$b`hGK1JZY{?Yy&g4GWs?q zQQl1_5ZVwxDX}GOBT+TAK1~jE;w58mf8?}(A{zIH#z|#5B_m+QLnOH;YD#&R9l^UdG&p5e&^Favqfh%@L29c|J9kb|_?)&qWx3MD$cc2oIgbhpW0x9B*CXlS zQ?@DFXpxn{dx7?U1dhwwm}L5b(14{5d8dp{ag1FBhXYp0DTJ}s5DO0lN`y(|Z`g!D z>q+C1Tg`{902Rlg*t^Y3He||Jrc9iFjg&I?lcxca9pN#_DhU4-A=$G4;rHNGh$v+# zUSX{AGR;;Z%HKsA>~zMiN$i+Rd%8tWchVC~dv=PRoktmzp(hwc)`jk262|c%k7HrX2o6+mzuR{|27rV)KZC$jG6T7IO$&4 zV|wNW&BSsZHAm2fZH#2VoCgD4Lyi|d^;88g+mxSX+KTuKw(<+K&& zZ5j3;6sSeOEInU5?a*t1b2&QgmMLd|qXW5^JZUD=syo=_8Jut$8O!^v-~Qfbgx@R; z9-)Y7@YdqxdscLtBGWzMnp|>1)9SfK6xIvI4FeMAD6Qm%dB(q)K&uEAmt=JTw`)9f z?i3%|o!Dc}7Yc~Tx-L@9;6>sXUcmb|mnD@!(?xBxjsof`J-NG4!3s- zyPFo{>5e|Jqc7RYmd0wanv-s!RlRiWmdpER`)A{y)bPW`9`vC_w2!p zyE^S&E4tUd`}&Qk>r?5DU1G?-$+3rj)yf7S4t)52f8}ME4r3q=lC1 zQ|?V{6-3QdS-q<3r+f>cpjqcg%EF?zFULX9H9h5WJe;Zf{^A0-)%O$BI{nk#ZCS~VtV5%8*h z=D*=R(PBV0(V>iHjir+y{(G_%ic~V4Vg(s zPK&!MU(DAK-G}Ur`+j*W+nA$qP(zoBbat3WFP&I{g%<4G$00*m=TyY}5u9ToTL9^k zs$iaEwh1siq)j`AFouwH_AcXD%pk2>fwv$h24o==2(ZuO&68e@r5x1LsV1q)F7lH`7hLmmwf|b&(n+Xx#6oX zfA3|y{&yx$|5i;ML_NvDa3WI#)y=ToSE@28u8`bq67>{;K>`J|3m%{78(6~H^V4^F zo=*A(lAeKOZXmzGiTYX3+6?zeBw$%rDs+Q5kX7!pQtv`bn)WVT4@Wdk7EYv#2M#wF=m~#x5q>chqF4hV^L?gX>Qt1x@p93o#s%21B-sn)3zDgm35ez z+Jy7dcBMsLowmJ%Hl3j|aC!(GLc^E5ZJQ~XE-^a^ZrZ6It2$Hq_!ymbeBU6O7D#7? zQ`dAUb|1NakiB!-M8eLa2eVew6tX&=d2J8e7iw#x)$e1pb-)AFr*0F=Bto298fUh( z`r#t^>`qqj3)E|bSK)m2w|IW(Y#lun+Io5$UUqB_Z{)Y&<@Ap5#?a}THvJ$9c5(#8 z<@v3rH~+Gsum!JV-I-_}iu!Tz$>>=+hK20k*ugB?>@u;YaPrpEo3@_{ZQltEEmU*Me~sS=Db&Q5)0=9*NF-~vYyzC#H~E+c_X_%7A=pOLe`G8|!$jl@QA zu&zMsdjXd$h5ZW(mMwB&${dqQ39E8!p&8jL+}?zy}t?dlL+9Z6SU zssD~^{UoQ zrreOJ6t95j4lH*aO1TfSd|E_z%VJx~-GO|d2dQb8EzMNd&X#_%XW%D^KTe45oy=YI83zbt_nI5g7P>Vys~E#k%qW z6K#}455jvDrGpbJ+kBAYFttr~*ueIpbOn7`rV2HaCNVq-Tn)mI=~q7Yo)a zm_Lw~+fW+~VuPPvLnuWB2k)4+CjcuBX|qFKD^C1&soEj1aydFVjujUFS?D1zV0xqr zX)+(d!ll~Z{H(vp2PIJk$6;I}OyEFC>d*e&Px&`=`_E!$0nRJK@js`+aV*>f{Tuw} z!Xd<(P#BY*o^i!z*sOW@GadlKWP?O#$xb?VfhLYp8SCUSD*$7F^~QPVLfO=<0Zkw@ z!69>L*bE~LOn!;?F%@X|HOX-r_WZo?DKZjh#fMp_s8v_iNokoTvBsPe82Qzkh!ldF2}xOy^GKFp+ibqgim*)z*wVQ1F)msKsZ1E2WnXZPJ} zXk9$;7Y*y@i|4F!n{e=Bx~g5QYF`{(+H|+7C*xan_3-@R#ftA9&G;G@UYkEWw?9K` zJ|Fw)@4GCG!3S=Oum0oOy8G1@-tUR z9z=Sqdl1W|8P#+VEt=ruTfFh|J7f#q_PXk4XW``Mko~5L1I|P*vk0fPkdQ(p&|ecx zXp|Dlrj_6@JI@N11*EXjkDPejy$FjH==3Nn_fe%*MXojGq(og&UM5-CWHy>-d$9HZ&Q-&_3rC*gAUt`* zSB>HIuB=)Z*x51w8{UN)04>zQQE~5}gIq5PG!|#CQ+*#kg$zIn?TPr1&`-lPq2D{# z|GRx)^_s4J#ewp!TR!?POL?ZTcyPmla~CjUL6Np0mD3VFmf2Y=f$f&7$23ANF2!K9E^4Kc3<+^W|_< zWa9~%cw?=dNqTg`o?>ExgEWP5YG4mJUUDkPsU(L?JOm#cGC3j1jLBk5b`$C-Oq1Kx z<#M74c~6B6XVnRROrIon39BhOjk+-opDJsWyDHIEqO(9(&66Mhlrnh(nSfw|8-Fx* zYfN-^LHuZ4cx|yW9UDFfd=wFzUWD{gx8|MlYvqdk2;o*9bXQfNO^~u?xbDxwlAJZd4pIZ{oG^e zQB3iSy_MRM_51$`_e;#0cYvOdRqkWP1&pU-6-Z@5y(-61=7iQOJEpCdXnm9VPR?UW zjhaxS@T|v8XVlosBSuF7s7)vveZX`DR_%uj7Mw=Uc;{=l;$O>w5!z!XZjEOhH92_9cqGj{lQ=!t?OJ z-25Nm2hx$U&?1${3lvy*5#FX`|A2yvVUa{Wi{$ezIsb&58{{mJa}$ng@W}>zYC%=T zq8WQ)tvTaXCdb-NzJE__`+IPhQHrN3UG5jl{ps?6SRP20uUiV;DW{bObyYxK07$vl zVPB3(xljv@e*ZP;L*$PG_TehZ6bF!t<;xIO#eB!EHuGu2GwiwE3 z7u_`L^`(3J#oqqA?x!DZWp~Qjb7Cy7-S&a+C#yeP zeb;@kK$OMoOk8)$T5~d4R;pec3{MvUEc_pG1; zrrFb%7|j|Aa1GKi=RE4T#Lfsek!jU%()P!!Orwh9w6O+nYLN>WZ>=KZLB4}V)vjq& zO-2{Yb##p?&Wf70X&O~0N^r~+(K_!mCi~)@c$&jm8@h9E^cGoc#qvSiKFVNAH98Kx>ou{moaMOsA;6Qr1MMe|sp8BV?+ zC3#8zuRy_eAQ$SKx$*vg%8&R(0euywI#>71?@3n$#j0SkYQwU71L&x_UN#?O8mf<> zp~AGOx?a&WW!Crp=;W=FP)uP(^1?#-l2z6YBJF$>_V0ghRUT7zwXAkFay)aI;kiSeR?{ zPxEPYEEB3>x@e}g)+8rd+mNYGzazv9DrT8tB7LA&RFk?0=i!-*^}K^dQOtp=rtM^n zcEW=BgB6OP4x({|s%h&t&Fz_O!Kvs)(%~p}2EiezTt0|lFggm8T%3{M0js zd7g4ec%Q@78-qCV;LJo{A068eC>3r2w^B(Y#`Fg;TWG?JJea8PS0T!VdDV z^`AeF^C6g7a7>`ZHgz+PO9po2bvi)7P6g_FT5@;w;1;qt+t6NUqqZ83eQB21@1YcQ z6AVh8)uLy0+7l2xfs|(*sQ*(}<##tN*L9{`fm@^L;NCmIz4scLMJMDn2|A*EJq#_dP{9_@{|GP%J5gQXHr^yVE}@C% z8oSZWa&h^>hXR}L)o>rR=~KvvvA(pcS#&k0U7ex}t=L6trGsM6V7lj^*mE$|a|n%` zuId!4I@49_#j5qms-9&SOn9oyITMXx569tL#Ua+{xu)|v^vE^nkz`3m&6e}9yS1!W zDq;VnaST0*u3p9}cN@kh8j-Atjf75ZDMzs$)E0ZrU}&1iS$wCfG+si!Y-L>d|L~^l z1d~eCEBbJk4y@%T$Kx@1&74}A=J9L}FI5menMM4_sVSz8OLUkEhItVis09=ST%`}& zMQG)tBjxLu9i;m;W-iYxmfXQTINsW;<@4nW@w?tO(0+9%Mj&tPY4Y9sqkWZzU4|sH&W1Y+rDj`%kQazsWStY&vFk z1hi5rhe53o#pgU&wzimEk~T&v-<(Iq$+VsAhcSf;u=;b7;qz9>dx|It1p)T`@TF{` zc(MRSwZn4r@Tnc^1bkY}3=&l=n_=upYkI5fJ%wHs=*tOuT=Yov_(+O~x2yPoaI1_D zyysQ)H^E3L+99VprI=EQN2{TU=rGZ)g?@R8DM4NpM6m_oC>YA%kVC&b9HnUK4i$&` z?{vv@DWgQCUzxVJ8s^v2sc%~COd!<4Ow!$|d`}TT6>6wbsNwGypa$*WQmjdu(L!|| zE!3!V@++nVP<8I3a1WP;5fiGzKDres3x+Z{9OW1DAS=RA+33&%=W(!%^X+2V7h zT4`Y>>29rlPche1daZxOUaReG&g-)+e@CO$N>pMsJ?xZZ-6w0CC+)Rsa^&7trPtOL z=#h_-74qfAw!b6n7akjbePjje*iMuF@^Vm}>z&^zKsWU&73B5K zwQ9(()rd;Eo}34q^Gjg+R!0uLI#gG;X5^-G zn0&s}WuQCjSicGUEA!NXv&To(nqRk)6wI&7o4<9hNGS%6oTJ-WZ>XVKmqN26rui|G zo!G2I4t-pyq%h~LfUHVu*g4O1#iMYx(yStnQi2R$zk#y$pq}D@9w&RA(!*-`hO^?p6)DMxZpg3l+Or{KIBMbb8*OD)Ww zU*fF|IWkW#!}50vVA(iN$;GmX_SCEoG5y0=*~3Nwj zi2x4ru#<`WP+}`~=e!;okMOb$fqE}-&`(Ft==&yf&QA);W<;6|b)xJ9J6g#f^3zqY z0f%I#4a>q_=z=A0?%Ceh_#B-SM#DI9k{z>Du6*J!M3#zJF57LC@M+n~)spWowh4!5 zQI0Yf=s*~OxUp~sPNG~%u#v$LW0w4|gwW#SxDsR0z1W^De=BA8=0w<-8)2icp9=Gn zGfE{Y2jTfQ`Q?-A!!fLI>E*eU{IS11#%!x}J97ZzbI7ALJ z)T-BTLa1h!SgRR04XFq*8t@d8u$}FJI%^&TKvoRqff&s6E1?&P8;xO=EzlADa$6Tm z4t5NDPT2;qiDhee+Nj}4ASPpIV&qG=NHA2`4Y}#{e~z$DT`f_qwX=aRV(d6C;W$O` zXjr0Vff4yIrDXO}Oy1nV<> z{y;z~OT=*wKrG0hi3!hA0dx?JRLTy`-zNxBLFgynL2_u7NnTk#K%Xx9q(wiWik!7@ zq#8!+BkDEZbW<<8WqFYT9q6)?VS#iTQf+=j<|A)hDVIal`@ALY1%?i6T&a>n*d4&? zJ>HUo-P$*#6$ zMQv2B3|FqZW`5Mbd*xrG5OU3)#kw%bJo zJ~Dw#M6nzTL#a%TglyzOomGU+pYF)|Bwiq%VM3f{sWiE$J zer$1?EnTxsCfM_X-q$PtY?O*#@5Mj;ZepxJ_D3>;CnnjcGBFadvErTc$P3_ZcBg%K zgppdIeL}`KYiQZK9yE5z@}If2%$}r8^t2^CYiI2lmuGg@y~-!Wlg%WORVflS8f(7HzzB%&N{Iw?)J_-b;spr>*!sguWM=R?Y^XM_w2rp ztLpC6_!n0vYkHTxy_u@Ig^Ky@SY>Z%ztQl+hPlmG``+ri?fY@v`*lBVe!n^0yHD)h z_tTKrdjN6BG_ym*mO`(O8N~ZVuRrZwD|*)^aiBnTJ^i(GW}4T$bN1TV%<5J;fexDm z%Q}B<;mXSI`z+;^bFYxWBdzW;sJ112n^c%^6?L*^`?6QIdL-k|jJJ9&G|ydi&%0MB z5Br|V0zbDDqXaLNa57o8S}#GZ_dV?0X)Y7@r^sVQ#hP zUX2>jpNy*2ZHvdQT_hxB{J50iI+ylu5d9l61^n+n5>v)3R@K z<_SXXJMB%CFfJ@>|GC8lR5lPQU%pee#z-NQt) zzqR{=>JMJ~u8D81vDxZ~LJ@e|^XVWr2Ac`@~vhGx3IAl=X-HsBV6 zUWEvnTNnGUoqcEg+PKp0>D4{r>K=k{?awVvKv+o-zL+d)GD0YKW}}Y=^K}O!@LkE8 zrWu5@)CrCo=NcO}sYk({7uc0k7NpFYF8)QPK(%^LM$(r>#=!IGe3~rsw;C@ZJCDo2LTz^%a>%PN=E(EUIX5dEO zAbWX@uWwEIHtH>-^7SXAE17Zn9?@X{54fW~3fwV{0$(Yms2y77;)Tcp@Sp>$@GMui z({=$3LGr+t%dWR4Yj!Mqcd&t$cC=>=fcjXNKYXmo8Z_3>P|+nfP91TN>_Jx^Dr7-m zxn(2H9$5BndQ3hs48FGf{0riauPNM(j*X&j^GIa0=*XC3#6o1Lpz^9~-j(*Yh~Ac@ z7i8*N`CBfkr8AdDzI3OoMaS?~Sk!)nT$~Q7qI#NhagurpFmkYm)du+|7f4g7>Lw6P;u)3=PRkxvMZDl0ycHXtCPqu}7-s-}fEmiVO?mYX2SZdrDlnEP`Oz?o&| z9Ax_^IU^jJKt&IWoV4dX09w+?~d-To3`hkaEx8M*X z>l@aoV)^%i^@8Q1?dz5cR&4xVZ#>}s=pnWD6RIWL5S37DhHw@BJ>1ckCiPO^rx&JrsQp(f|!iB+tE3Rb6ULh4=hE9uf?Hb{Fd7>k^AY1kBXLCDe^rxj>eU@fDzwVf%d5z&zX6|^+hJU=@BW2Bj!8_*gLBnO+6XZA=yCB>gk$;k_Dq*jG0?H#kB=B;ISfRc8`7t*T_PPMyB@4uz?`}y~1;naA zx~f~O>b^CZ?%5~y>`V6?7V%egIO#dE%pGCo{6m4}+{;gPBNzD$0%D)S%jCR5&c7q) z3ORp84sFj7=E(VLa{e1RE~0@EdZR6N0v!z^e4jq)m+r|FFx+B^|^3(mJJpBLF3+yk$#toYKt2i?Vvt_R+4S{-c<61ECQ z?}N2=$NC5ED)@F4I|2{9C63^O8aqDSm5!YcIvw~dFLDf8A8;Lx&IiY>s~n9Fc9lA+ zAJjRKu(HHa`=Dl(W5a`8KF8Jv-4zZW<>-6hE=GWN+6K>QX9+xNY!2VUQmbRgDuYWv zj^bZ?P#g926pAtbg-HA?E?aq>SPAb}?)kiFlZu~b!f{% zXwVpA8}%PD>%3(7f4NPc;{v_a3%{AH>B-b}WNL>$ciO$B_bv7kiMG>Fjmf=_7 z#9A2Xxaoo1Q6#@XlA+efdCYct>GA;V_LMV+?Tq{x!(}6CT!@b2a#x%ZC36|!-y-8I z9F(wKe9!{(i6#?@3TnZ7srIXo9NdGc5@sEA7ZAH0uvz4q7bfp=?fKh(s9FZnifa`! z+h4_7CansSM5B>hAkbFBJT}N3jLDl16RXT%Rzn9-@I#W=7{g0@T0~FFl5g3wewkY@ zbGDm#*E%uI_5&*PIyvu>vlm$y*vvfnkwXV^#(W6;mgQsnrU!PXqx3;#kz?&c&guxt z2`JhqVrUP(+4F=Q{YYqn#G*u-c}GXzr{J%+j|<-c)N1EeicmYZ3eeWWILNCT@*v$E z?0=nZqmGP6;!|?h#!b>#>XdsvJ6jgFyRjXq!tWz%;Sb1p8;+4Q1!{%tUa1t8)hiEC zBNXbDM<1(K5;uMl`6z2HfrA-_5^bt@EOR~uUfdyt=Axq=W6|hDDb_w2X5To#2`H>B%6ms<|p3$Rzpvsc!4s>soc~h&l*aVFS;6yNtqR literal 30027 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®epabav#y&J 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 0 + self.add_sel_btn.setEnabled(has_sel) - # Build selection description - desc = [] + def add_current_selection(self): + """Add current FreeCAD selection to the selection table.""" + sel = Gui.Selection.getSelectionEx() for s in sel: + obj = s.Object if s.SubElementNames: - for sub in s.SubElementNames: - desc.append(f"{s.ObjectName}.{sub}") + for i, sub in enumerate(s.SubElementNames): + # Get the shape if available + shape = None + if i < len(s.SubObjects): + shape = s.SubObjects[i] + item = SelectionItem(obj, sub, shape) + self._add_selection_item(item) else: - desc.append(s.ObjectName) + # Object selected without sub-element + item = SelectionItem(obj, "", None) + self._add_selection_item(item) - text = ", ".join(desc) if desc else "None" + self.refresh_selection_table() + self.update_mode_from_selection() - # Update appropriate label - tab = self.tabs.currentIndex() - if tab == 0: - self.plane_selection_label.setText(f"Selection: {text}") - elif tab == 1: - self.axis_selection_label.setText(f"Selection: {text}") - elif tab == 2: - self.point_selection_label.setText(f"Selection: {text}") + def _add_selection_item(self, item): + """Add item to selection list if not already present.""" + # Check for duplicates + for existing in self.selection_list: + if existing.obj == item.obj and existing.subname == item.subname: + return # Already in list + self.selection_list.append(item) - def on_tab_changed(self, index): - self.on_selection_changed() + def remove_selected_row(self): + """Remove selected row from selection table.""" + rows = self.sel_table.selectionModel().selectedRows() + if rows: + # Remove in reverse order to maintain indices + for row in sorted([r.row() for r in rows], reverse=True): + if row < len(self.selection_list): + del self.selection_list[row] + self.refresh_selection_table() + self.update_mode_from_selection() - def on_plane_mode_changed(self, index): - """Update plane parameter UI based on mode.""" + def clear_selection(self): + """Clear all items from selection table.""" + self.selection_list.clear() + self.refresh_selection_table() + self.update_mode_from_selection() + + def refresh_selection_table(self): + """Refresh the selection table display.""" + self.sel_table.setRowCount(len(self.selection_list)) + for i, item in enumerate(self.selection_list): + # Type icon + type_item = QtGui.QTableWidgetItem(item.type_icon) + type_item.setTextAlignment(QtCore.Qt.AlignCenter) + type_item.setToolTip(item.geo_type) + self.sel_table.setItem(i, 0, type_item) + + # Element name + name_item = QtGui.QTableWidgetItem(item.display_name) + self.sel_table.setItem(i, 1, name_item) + + # Remove button + remove_btn = QtGui.QPushButton("✕") + remove_btn.setMaximumWidth(30) + remove_btn.clicked.connect(lambda checked, row=i: self._remove_row(row)) + self.sel_table.setCellWidget(i, 2, remove_btn) + + def _remove_row(self, row): + """Remove a specific row.""" + if row < len(self.selection_list): + del self.selection_list[row] + self.refresh_selection_table() + self.update_mode_from_selection() + + def get_selection_types(self): + """Get tuple of geometry types in current selection.""" + return tuple(item.geo_type for item in self.selection_list) + + def update_mode_from_selection(self): + """Auto-detect the best mode based on current selection.""" + if self.mode_combo.currentIndex() > 0: + # Manual override is active + mode_id = self.mode_combo.currentData() + self._set_detected_mode(mode_id) + return + + sel_types = self.get_selection_types() + + if not sel_types: + self.mode_label.setText("Select geometry to auto-detect mode") + self.mode_label.setStyleSheet("font-weight: bold; color: #888;") + self.update_params_ui(None) + return + + # Find matching modes + best_match = None + best_score = -1 + + for display_name, mode_id, required_types, category in self.MODES: + if not required_types: + continue # Skip modes with no requirements (like XYZ point) + + score = self._match_score(sel_types, required_types) + if score > best_score: + best_score = score + best_match = (display_name, mode_id, category) + + if best_match and best_score > 0: + display_name, mode_id, category = best_match + cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"} + color = cat_colors.get(category, "#cdd6f4") + self.mode_label.setText(f"{display_name}") + self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};") + self.update_params_ui(mode_id) + else: + self.mode_label.setText("No matching mode for selection") + self.mode_label.setStyleSheet("font-weight: bold; color: #f38ba8;") + self.update_params_ui(None) + + def _match_score(self, sel_types, required_types): + """ + Calculate how well selection matches required types. + Returns score >= 0, higher is better. 0 means no match. + """ + if len(sel_types) < len(required_types): + return 0 # Not enough items + + # Check if we can satisfy all requirements + sel_list = list(sel_types) + matched = 0 + for req in required_types: + # Try to find a matching type in selection + found = False + for i, sel in enumerate(sel_list): + if self._type_matches(sel, req): + sel_list.pop(i) + matched += 1 + found = True + break + if not found: + return 0 # Can't satisfy this requirement + + # Score based on how exact the match is + # Exact match (same count) scores higher + if len(sel_types) == len(required_types): + return 100 + matched + else: + return matched + + def _type_matches(self, sel_type, req_type): + """Check if a selected type matches a required type.""" + if sel_type == req_type: + return True + # Face can match cylinder (cylinder is a special face) + if req_type == "face" and sel_type in ("face", "cylinder"): + return True + # Edge can match circle (circle is a special edge) + if req_type == "edge" and sel_type in ("edge", "circle"): + return True + return False + + def on_mode_override_changed(self, index): + """Handle manual mode override selection.""" + if index == 0: + # Auto-detect + self.update_mode_from_selection() + else: + mode_id = self.mode_combo.currentData() + self._set_detected_mode(mode_id) + + def _set_detected_mode(self, mode_id): + """Set the mode and update UI.""" + for display_name, mid, _, category in self.MODES: + if mid == mode_id: + cat_colors = {"plane": "#cba6f7", "axis": "#94e2d5", "point": "#f9e2af"} + color = cat_colors.get(category, "#cdd6f4") + self.mode_label.setText(f"{display_name}") + self.mode_label.setStyleSheet(f"font-weight: bold; color: {color};") + self.update_params_ui(mode_id) + return + + def update_params_ui(self, mode_id): + """Update parameters UI based on mode.""" # Clear existing params - while self.plane_params_layout.rowCount() > 0: - self.plane_params_layout.removeRow(0) + while self.params_layout.rowCount() > 0: + self.params_layout.removeRow(0) - mode = self.PLANE_MODES[index][1] + if mode_id is None: + self.params_group.setVisible(False) + return - if mode == "offset_face": - self.plane_params_layout.addRow("Offset:", self.plane_offset_spin) - elif mode == "angled": - self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) - elif mode == "normal_edge": - self.plane_params_layout.addRow("Position (0-1):", self.plane_param_spin) - elif mode == "tangent_cyl": - self.plane_params_layout.addRow("Angle:", self.plane_angle_spin) + self.params_group.setVisible(True) - def on_axis_mode_changed(self, index): - pass # Axes don't have extra parameters currently + if mode_id in ("offset_face", "offset_plane"): + self.params_layout.addRow("Offset:", self.offset_spin) + elif mode_id == "angled": + self.params_layout.addRow("Angle:", self.angle_spin) + elif mode_id == "normal_edge": + self.params_layout.addRow("Position (0-1):", self.param_spin) + elif mode_id == "tangent_cyl": + self.params_layout.addRow("Angle:", self.angle_spin) + elif mode_id == "point_xyz": + self.params_layout.addRow("X:", self.x_spin) + self.params_layout.addRow("Y:", self.y_spin) + self.params_layout.addRow("Z:", self.z_spin) + elif mode_id == "point_edge": + self.params_layout.addRow("Position (0-1):", self.param_spin) + else: + # No parameters needed + self.params_group.setVisible(False) - def on_point_mode_changed(self, index): - mode = self.POINT_MODES[index][1] - self.point_xyz_group.setVisible(mode == "point_xyz") + def get_current_mode(self): + """Get the currently active mode ID.""" + if self.mode_combo.currentIndex() > 0: + return self.mode_combo.currentData() + + # Auto-detected mode + sel_types = self.get_selection_types() + if not sel_types: + return None + + best_match = None + best_score = -1 + for _, mode_id, required_types, _ in self.MODES: + if not required_types: + continue + score = self._match_score(sel_types, required_types) + if score > best_score: + best_score = score + best_match = mode_id + + return best_match if best_score > 0 else None def get_body(self): """Get active body if checkbox is checked.""" if not self.use_body_cb.isChecked(): return None - # Try to get active body if hasattr(Gui, "ActiveDocument") and Gui.ActiveDocument: active_view = Gui.ActiveDocument.ActiveView if hasattr(active_view, "getActiveObject"): @@ -269,7 +522,6 @@ class DatumCreatorTaskPanel: if body: return body - # Fallback: find a body in document doc = App.ActiveDocument for obj in doc.Objects: if obj.TypeId == "PartDesign::Body": @@ -283,131 +535,79 @@ class DatumCreatorTaskPanel: return self.custom_name_edit.text() return None - def get_selected_geometry(self, geo_type): - """Extract geometry of specified type from selection. - - Returns: - List of tuples: (shape, source_object, subname) - """ + def get_items_by_type(self, *geo_types): + """Get selection items matching given geometry types.""" results = [] - for sel in self.selected_items: - obj = sel.Object - if not hasattr(obj, "Shape"): - continue - - if sel.SubElementNames: - for sub in sel.SubElementNames: - # Only process valid sub-element names (Face#, Edge#, Vertex#) - # Skip invalid names like "Plane" from datum objects - if not ( - sub.startswith("Face") - or sub.startswith("Edge") - or sub.startswith("Vertex") - ): - # Try to use the whole object's shape instead - shape = obj.Shape - if geo_type == "face" and shape.Faces: - # Use the first face of the object (e.g., datum plane) - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) - continue - - try: - shape = obj.Shape.getElement(sub) - if geo_type == "face" and isinstance(shape, Part.Face): - results.append((shape, obj, sub)) - elif geo_type == "edge" and isinstance(shape, Part.Edge): - results.append((shape, obj, sub)) - elif geo_type == "vertex" and isinstance(shape, Part.Vertex): - results.append((shape, obj, sub)) - except Exception: - # If getElement fails, try to use the whole shape - shape = obj.Shape - if geo_type == "face" and shape.Faces: - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) - else: - # No sub-element selected, use the whole object's shape - shape = obj.Shape - if geo_type == "face" and shape.Faces: - results.append((shape.Faces[0], obj, "Face1")) - elif geo_type == "edge" and shape.Edges: - results.append((shape.Edges[0], obj, "Edge1")) - elif geo_type == "vertex" and shape.Vertexes: - results.append((shape.Vertexes[0], obj, "Vertex1")) + for item in self.selection_list: + if item.geo_type in geo_types: + results.append(item) return results - def on_create(self): + def create_datum(self): """Create the datum based on current settings.""" from ztools.datums import core - tab = self.tabs.currentIndex() + mode = self.get_current_mode() + if mode is None: + raise ValueError("No valid mode detected. Add geometry to the selection.") + body = self.get_body() name = self.get_name() link_ss = self.link_spreadsheet_cb.isChecked() - try: - if tab == 0: # Planes - self.create_plane(core, body, name, link_ss) - elif tab == 1: # Axes - self.create_axis(core, body, name) - elif tab == 2: # Points - self.create_point(core, body, name, link_ss) - - App.Console.PrintMessage("Datum created successfully\n") - - except Exception as e: - App.Console.PrintError(f"Failed to create datum: {e}\n") - QtGui.QMessageBox.warning(self.form, "Error", str(e)) - - def create_plane(self, core, body, name, link_ss): - mode = self.PLANE_MODES[self.plane_mode.currentIndex()][1] - + # Planes if mode == "offset_face": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("face", "cylinder") + if not items: raise ValueError("Select a face") - face, src_obj, src_sub = faces[0] + item = items[0] + face = item.shape if item.shape else item.obj.Shape.Faces[0] core.plane_offset_from_face( face, - self.plane_offset_spin.value(), + self.offset_spin.value(), + name=name, + body=body, + link_spreadsheet=link_ss, + source_object=item.obj, + source_subname=item.subname, + ) + + elif mode == "offset_plane": + items = self.get_items_by_type("plane") + if not items: + raise ValueError("Select a datum plane") + core.plane_offset_from_plane( + items[0].obj, + self.offset_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_object=src_obj, - source_subname=src_sub, ) elif mode == "midplane": - faces = self.get_selected_geometry("face") - if len(faces) < 2: + items = self.get_items_by_type("face", "cylinder") + if len(items) < 2: raise ValueError("Select 2 faces") - face1, src_obj1, src_sub1 = faces[0] - face2, src_obj2, src_sub2 = faces[1] + face1 = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] + face2 = items[1].shape if items[1].shape else items[1].obj.Shape.Faces[0] core.plane_midplane( face1, face2, name=name, body=body, - source_object1=src_obj1, - source_subname1=src_sub1, - source_object2=src_obj2, - source_subname2=src_sub2, + source_object1=items[0].obj, + source_subname1=items[0].subname, + source_object2=items[1].obj, + source_subname2=items[1].subname, ) elif mode == "3_points": - verts = self.get_selected_geometry("vertex") - if len(verts) < 3: + items = self.get_items_by_type("vertex") + if len(items) < 3: raise ValueError("Select 3 vertices") - v1, src_obj1, src_sub1 = verts[0] - v2, src_obj2, src_sub2 = verts[1] - v3, src_obj3, src_sub3 = verts[2] + v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] + v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0] + v3 = items[2].shape if items[2].shape else items[2].obj.Shape.Vertexes[0] core.plane_from_3_points( v1.Point, v2.Point, @@ -415,185 +615,197 @@ class DatumCreatorTaskPanel: name=name, body=body, source_refs=[ - (src_obj1, src_sub1), - (src_obj2, src_sub2), - (src_obj3, src_sub3), + (items[0].obj, items[0].subname), + (items[1].obj, items[1].subname), + (items[2].obj, items[2].subname), ], ) elif mode == "normal_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge", "circle") + if not items: raise ValueError("Select an edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.plane_normal_to_edge( edge, - parameter=self.plane_param_spin.value(), + parameter=self.param_spin.value(), name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "angled": - faces = self.get_selected_geometry("face") - edges = self.get_selected_geometry("edge") + faces = self.get_items_by_type("face", "cylinder") + edges = self.get_items_by_type("edge", "circle") if not faces or not edges: raise ValueError("Select a face and an edge") - face, face_obj, face_sub = faces[0] - edge, edge_obj, edge_sub = edges[0] + face = faces[0].shape if faces[0].shape else faces[0].obj.Shape.Faces[0] + edge = edges[0].shape if edges[0].shape else edges[0].obj.Shape.Edges[0] core.plane_angled( face, edge, - self.plane_angle_spin.value(), + self.angle_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_face_obj=face_obj, - source_face_sub=face_sub, - source_edge_obj=edge_obj, - source_edge_sub=edge_sub, + source_face_obj=faces[0].obj, + source_face_sub=faces[0].subname, + source_edge_obj=edges[0].obj, + source_edge_sub=edges[0].subname, ) elif mode == "tangent_cyl": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("cylinder") + if not items: raise ValueError("Select a cylindrical face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.plane_tangent_to_cylinder( face, - angle=self.plane_angle_spin.value(), + angle=self.angle_spin.value(), name=name, body=body, link_spreadsheet=link_ss, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) - def create_axis(self, core, body, name): - mode = self.AXIS_MODES[self.axis_mode.currentIndex()][1] - - if mode == "axis_2pt": - verts = self.get_selected_geometry("vertex") - if len(verts) < 2: + # Axes + elif mode == "axis_2pt": + items = self.get_items_by_type("vertex") + if len(items) < 2: raise ValueError("Select 2 vertices") - v1, obj1, sub1 = verts[0] - v2, obj2, sub2 = verts[1] + v1 = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] + v2 = items[1].shape if items[1].shape else items[1].obj.Shape.Vertexes[0] core.axis_from_2_points( v1.Point, v2.Point, name=name, body=body, - source_refs=[(obj1, sub1), (obj2, sub2)], + source_refs=[ + (items[0].obj, items[0].subname), + (items[1].obj, items[1].subname), + ], ) elif mode == "axis_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge") + if not items: raise ValueError("Select a linear edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.axis_from_edge( edge, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "axis_cyl": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("cylinder") + if not items: raise ValueError("Select a cylindrical face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.axis_cylinder_center( face, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "axis_intersect": - # Need 2 plane objects selected - if len(self.selected_items) < 2: + items = self.get_items_by_type("plane") + if len(items) < 2: raise ValueError("Select 2 datum planes") core.axis_intersection_planes( - self.selected_items[0].Object, - self.selected_items[1].Object, + items[0].obj, + items[1].obj, name=name, body=body, + source_object1=items[0].obj, + source_subname1="", + source_object2=items[1].obj, + source_subname2="", ) - def create_point(self, core, body, name, link_ss): - mode = self.POINT_MODES[self.point_mode.currentIndex()][1] - - if mode == "point_vertex": - verts = self.get_selected_geometry("vertex") - if not verts: + # Points + elif mode == "point_vertex": + items = self.get_items_by_type("vertex") + if not items: raise ValueError("Select a vertex") - vert, src_obj, src_sub = verts[0] + vert = items[0].shape if items[0].shape else items[0].obj.Shape.Vertexes[0] core.point_at_vertex( vert, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_xyz": core.point_at_coordinates( - self.point_x_spin.value(), - self.point_y_spin.value(), - self.point_z_spin.value(), + self.x_spin.value(), + self.y_spin.value(), + self.z_spin.value(), name=name, body=body, link_spreadsheet=link_ss, ) elif mode == "point_edge": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("edge", "circle") + if not items: raise ValueError("Select an edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.point_on_edge( edge, - parameter=self.point_param_spin.value(), + parameter=self.param_spin.value(), name=name, - source_object=src_obj, - source_subname=src_sub, body=body, link_spreadsheet=link_ss, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_face": - faces = self.get_selected_geometry("face") - if not faces: + items = self.get_items_by_type("face", "cylinder") + if not items: raise ValueError("Select a face") - face, src_obj, src_sub = faces[0] + face = items[0].shape if items[0].shape else items[0].obj.Shape.Faces[0] core.point_center_of_face( face, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) elif mode == "point_circle": - edges = self.get_selected_geometry("edge") - if not edges: + items = self.get_items_by_type("circle") + if not items: raise ValueError("Select a circular edge") - edge, src_obj, src_sub = edges[0] + edge = items[0].shape if items[0].shape else items[0].obj.Shape.Edges[0] core.point_center_of_circle( edge, name=name, body=body, - source_object=src_obj, - source_subname=src_sub, + source_object=items[0].obj, + source_subname=items[0].subname, ) + else: + raise ValueError(f"Unknown mode: {mode}") + def accept(self): - """Called when OK is clicked.""" + """Called when OK is clicked. Creates the datum.""" Gui.Selection.removeObserver(self.observer) + try: + self.create_datum() + App.Console.PrintMessage("ZTools: Datum created successfully\n") + except Exception as e: + App.Console.PrintError(f"ZTools: Failed to create datum: {e}\n") + QtGui.QMessageBox.warning(self.form, "Error", str(e)) return True def reject(self): @@ -602,7 +814,7 @@ class DatumCreatorTaskPanel: return True def getStandardButtons(self): - return QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel + return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel) class ZTools_DatumCreator: @@ -639,7 +851,7 @@ class ZTools_DatumManager: def Activated(self): # TODO: Implement datum manager panel - App.Console.PrintMessage("Datum Manager - Coming soon\n") + App.Console.PrintMessage("ZTools: Datum Manager - Coming soon\n") def IsActive(self): return App.ActiveDocument is not None diff --git a/ztools/ztools/commands/datum_viewprovider.py b/ztools/ztools/commands/datum_viewprovider.py new file mode 100644 index 0000000..913ed80 --- /dev/null +++ b/ztools/ztools/commands/datum_viewprovider.py @@ -0,0 +1,405 @@ +# ztools/commands/datum_viewprovider.py +# Custom ViewProvider for ZTools datum objects + +import json + +import FreeCAD as App +import FreeCADGui as Gui +import Part +from PySide import QtCore, QtGui + + +class ZToolsDatumViewProvider: + """ + Custom ViewProvider for ZTools datum objects. + + Features: + - Overrides double-click to open ZTools editor instead of vanilla attachment + - Hides attachment properties from property editor + - Provides custom icons based on datum type + """ + + _is_ztools = True # Marker to identify ZTools ViewProviders + + def __init__(self, vobj): + """Initialize and attach to ViewObject.""" + vobj.Proxy = self + self.Object = vobj.Object if vobj else None + + def attach(self, vobj): + """Called when ViewProvider is attached to object.""" + self.Object = vobj.Object + self._hide_attachment_props(vobj) + + def _hide_attachment_props(self, vobj): + """Hide FreeCAD attachment properties.""" + if not vobj or not vobj.Object: + return + + obj = vobj.Object + attachment_props = [ + "MapMode", + "MapPathParameter", + "MapReversed", + "AttachmentOffset", + "Support", + ] + + for prop in attachment_props: + try: + if hasattr(obj, prop): + vobj.setEditorMode(prop, 2) # 2 = Hidden + except Exception: + pass + + def updateData(self, obj, prop): + """Called when data properties change.""" + pass + + def onChanged(self, vobj, prop): + """Called when view properties change.""" + # Re-hide attachment properties if they become visible + if prop in ("MapMode", "Support"): + self._hide_attachment_props(vobj) + + def doubleClicked(self, vobj): + """ + Handle double-click - open ZTools datum editor. + Returns True if handled, False to let FreeCAD handle it. + """ + if Gui.Control.activeDialog(): + # Task panel already open + return False + + # Check if this is a ZTools datum + obj = vobj.Object + if not hasattr(obj, "ZTools_Type"): + # Not a ZTools datum, let FreeCAD handle it + return False + + # Open ZTools editor + panel = DatumEditTaskPanel(obj) + Gui.Control.showDialog(panel) + return True + + def setEdit(self, vobj, mode=0): + """ + Called when entering edit mode. + Mode 0 = default edit, Mode 1 = transform + """ + if mode == 0: + obj = vobj.Object + if hasattr(obj, "ZTools_Type"): + panel = DatumEditTaskPanel(obj) + Gui.Control.showDialog(panel) + return True + return False + + def unsetEdit(self, vobj, mode=0): + """Called when exiting edit mode.""" + return False + + def getIcon(self): + """Return icon for tree view based on datum type.""" + from ztools.resources.icons import get_icon + + if not self.Object: + return get_icon("datum_creator") + + ztools_type = getattr(self.Object, "ZTools_Type", "") + + # Map ZTools type to icon + icon_map = { + "offset_from_face": "plane_offset", + "offset_from_plane": "plane_offset", + "midplane": "plane_midplane", + "3_points": "plane_3pt", + "normal_to_edge": "plane_normal", + "angled": "plane_angled", + "tangent_cylinder": "plane_tangent", + "2_points": "axis_2pt", + "from_edge": "axis_edge", + "cylinder_center": "axis_cyl", + "plane_intersection": "axis_intersect", + "vertex": "point_vertex", + "coordinates": "point_xyz", + "on_edge": "point_edge", + "face_center": "point_face", + "circle_center": "point_circle", + } + + icon_name = icon_map.get(ztools_type, "datum_creator") + return get_icon(icon_name) + + def __getstate__(self): + """Serialization - don't save proxy state.""" + return None + + def __setstate__(self, state): + """Deserialization.""" + return None + + +class DatumEditTaskPanel: + """ + Task panel for editing existing ZTools datum objects. + + Allows modification of: + - Offset distance (for offset-type datums) + - Angle (for angled/tangent datums) + - Parameter position (for edge-based datums) + - Source references (future) + """ + + def __init__(self, datum_obj): + self.datum_obj = datum_obj + self.form = QtGui.QWidget() + self.form.setWindowTitle(f"Edit {datum_obj.Label}") + self.original_placement = datum_obj.Placement.copy() + self.setup_ui() + self.load_current_values() + + def setup_ui(self): + """Create the edit panel UI.""" + layout = QtGui.QVBoxLayout(self.form) + layout.setSpacing(8) + + # Header with datum info + info_group = QtGui.QGroupBox("Datum Info") + info_layout = QtGui.QFormLayout(info_group) + + self.name_edit = QtGui.QLineEdit(self.datum_obj.Label) + info_layout.addRow("Name:", self.name_edit) + + ztools_type = getattr(self.datum_obj, "ZTools_Type", "unknown") + type_label = QtGui.QLabel(self._format_type_name(ztools_type)) + type_label.setStyleSheet("color: #cba6f7; font-weight: bold;") + info_layout.addRow("Type:", type_label) + + layout.addWidget(info_group) + + # Parameters group + self.params_group = QtGui.QGroupBox("Parameters") + self.params_layout = QtGui.QFormLayout(self.params_group) + + # Offset spinner + self.offset_spin = QtGui.QDoubleSpinBox() + self.offset_spin.setRange(-10000, 10000) + self.offset_spin.setSuffix(" mm") + self.offset_spin.valueChanged.connect(self.on_param_changed) + + # Angle spinner + self.angle_spin = QtGui.QDoubleSpinBox() + self.angle_spin.setRange(-360, 360) + self.angle_spin.setSuffix(" °") + self.angle_spin.valueChanged.connect(self.on_param_changed) + + # Parameter spinner (0-1) + self.param_spin = QtGui.QDoubleSpinBox() + self.param_spin.setRange(0, 1) + self.param_spin.setSingleStep(0.1) + self.param_spin.valueChanged.connect(self.on_param_changed) + + # XYZ spinners for point coordinates + self.x_spin = QtGui.QDoubleSpinBox() + self.x_spin.setRange(-10000, 10000) + self.x_spin.setSuffix(" mm") + self.x_spin.valueChanged.connect(self.on_param_changed) + + self.y_spin = QtGui.QDoubleSpinBox() + self.y_spin.setRange(-10000, 10000) + self.y_spin.setSuffix(" mm") + self.y_spin.valueChanged.connect(self.on_param_changed) + + self.z_spin = QtGui.QDoubleSpinBox() + self.z_spin.setRange(-10000, 10000) + self.z_spin.setSuffix(" mm") + self.z_spin.valueChanged.connect(self.on_param_changed) + + layout.addWidget(self.params_group) + + # Source references (read-only for now) + refs_group = QtGui.QGroupBox("Source References") + refs_layout = QtGui.QVBoxLayout(refs_group) + + self.refs_list = QtGui.QListWidget() + self.refs_list.setMaximumHeight(80) + refs_layout.addWidget(self.refs_list) + + layout.addWidget(refs_group) + + # Placement info (read-only) + placement_group = QtGui.QGroupBox("Current Placement") + placement_layout = QtGui.QFormLayout(placement_group) + + pos = self.datum_obj.Placement.Base + self.pos_label = QtGui.QLabel(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})") + placement_layout.addRow("Position:", self.pos_label) + + layout.addWidget(placement_group) + + layout.addStretch() + + def _format_type_name(self, ztools_type): + """Format ZTools type string for display.""" + type_names = { + "offset_from_face": "Offset from Face", + "offset_from_plane": "Offset from Plane", + "midplane": "Midplane", + "3_points": "3 Points", + "normal_to_edge": "Normal to Edge", + "angled": "Angled from Face", + "tangent_cylinder": "Tangent to Cylinder", + "2_points": "2 Points", + "from_edge": "From Edge", + "cylinder_center": "Cylinder Center", + "plane_intersection": "Plane Intersection", + "vertex": "At Vertex", + "coordinates": "XYZ Coordinates", + "on_edge": "On Edge", + "face_center": "Face Center", + "circle_center": "Circle Center", + } + return type_names.get(ztools_type, ztools_type) + + def load_current_values(self): + """Load current values from datum object.""" + ztools_type = getattr(self.datum_obj, "ZTools_Type", "") + params_json = getattr(self.datum_obj, "ZTools_Params", "{}") + refs_json = getattr(self.datum_obj, "ZTools_SourceRefs", "[]") + + try: + params = json.loads(params_json) + except json.JSONDecodeError: + params = {} + + try: + refs = json.loads(refs_json) + except json.JSONDecodeError: + refs = [] + + # Clear params layout + while self.params_layout.rowCount() > 0: + self.params_layout.removeRow(0) + + # Add appropriate parameter controls based on type + if ztools_type in ("offset_from_face", "offset_from_plane"): + distance = params.get("distance", 10) + self.offset_spin.setValue(distance) + self.params_layout.addRow("Offset:", self.offset_spin) + + elif ztools_type == "angled": + angle = params.get("angle", 45) + self.angle_spin.setValue(angle) + self.params_layout.addRow("Angle:", self.angle_spin) + + elif ztools_type == "tangent_cylinder": + angle = params.get("angle", 0) + self.angle_spin.setValue(angle) + self.params_layout.addRow("Angle:", self.angle_spin) + + elif ztools_type in ("normal_to_edge", "on_edge"): + parameter = params.get("parameter", 0.5) + self.param_spin.setValue(parameter) + self.params_layout.addRow("Position (0-1):", self.param_spin) + + elif ztools_type == "coordinates": + x = params.get("x", 0) + y = params.get("y", 0) + z = params.get("z", 0) + self.x_spin.setValue(x) + self.y_spin.setValue(y) + self.z_spin.setValue(z) + self.params_layout.addRow("X:", self.x_spin) + self.params_layout.addRow("Y:", self.y_spin) + self.params_layout.addRow("Z:", self.z_spin) + else: + # No editable parameters + no_params_label = QtGui.QLabel("No editable parameters") + no_params_label.setStyleSheet("color: #888;") + self.params_layout.addRow(no_params_label) + + # Load source references + self.refs_list.clear() + for ref in refs: + obj_name = ref.get("object", "?") + subname = ref.get("subname", "") + if subname: + self.refs_list.addItem(f"{obj_name}.{subname}") + else: + self.refs_list.addItem(obj_name) + + if not refs: + self.refs_list.addItem("(no references)") + + def on_param_changed(self): + """Handle parameter value changes - update datum in real-time.""" + ztools_type = getattr(self.datum_obj, "ZTools_Type", "") + + # For coordinate-based points, update placement directly + if ztools_type == "coordinates": + new_pos = App.Vector( + self.x_spin.value(), self.y_spin.value(), self.z_spin.value() + ) + self.datum_obj.Placement.Base = new_pos + self._update_params({"x": new_pos.x, "y": new_pos.y, "z": new_pos.z}) + + elif ztools_type in ("offset_from_face", "offset_from_plane"): + # For offset types, we need to recalculate from source + # This is more complex - for now just update the stored param + self._update_params({"distance": self.offset_spin.value()}) + # TODO: Recalculate placement from source geometry + + elif ztools_type in ("angled", "tangent_cylinder"): + self._update_params({"angle": self.angle_spin.value()}) + # TODO: Recalculate placement from source geometry + + elif ztools_type in ("normal_to_edge", "on_edge"): + self._update_params({"parameter": self.param_spin.value()}) + # TODO: Recalculate placement from source geometry + + # Update position display + pos = self.datum_obj.Placement.Base + self.pos_label.setText(f"({pos.x:.2f}, {pos.y:.2f}, {pos.z:.2f})") + + App.ActiveDocument.recompute() + + def _update_params(self, new_values): + """Update stored parameters with new values.""" + params_json = getattr(self.datum_obj, "ZTools_Params", "{}") + try: + params = json.loads(params_json) + except json.JSONDecodeError: + params = {} + + params.update(new_values) + + # Re-serialize (handle vectors) + serializable = {} + for k, v in params.items(): + if hasattr(v, "x") and hasattr(v, "y") and hasattr(v, "z"): + serializable[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z} + else: + serializable[k] = v + + self.datum_obj.ZTools_Params = json.dumps(serializable) + + def accept(self): + """Handle OK button - apply changes.""" + # Update label if changed + new_label = self.name_edit.text().strip() + if new_label and new_label != self.datum_obj.Label: + self.datum_obj.Label = new_label + + App.ActiveDocument.recompute() + return True + + def reject(self): + """Handle Cancel button - restore original placement.""" + self.datum_obj.Placement = self.original_placement + App.ActiveDocument.recompute() + return True + + def getStandardButtons(self): + """Return dialog buttons.""" + return int(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel) diff --git a/ztools/ztools/datums/__init__.py b/ztools/ztools/datums/__init__.py index fcbfac7..e9755d3 100644 --- a/ztools/ztools/datums/__init__.py +++ b/ztools/ztools/datums/__init__.py @@ -1,27 +1,29 @@ # ztools/datums - Datum creation tools from .core import ( - # Planes - plane_offset_from_face, - plane_midplane, - plane_from_3_points, - plane_normal_to_edge, - plane_angled, - plane_tangent_to_cylinder, + axis_cylinder_center, # Axes axis_from_2_points, axis_from_edge, - axis_cylinder_center, axis_intersection_planes, + plane_angled, + plane_from_3_points, + plane_midplane, + plane_normal_to_edge, + # Planes + plane_offset_from_face, + plane_offset_from_plane, + plane_tangent_to_cylinder, + point_at_coordinates, # Points point_at_vertex, - point_at_coordinates, - point_on_edge, - point_center_of_face, point_center_of_circle, + point_center_of_face, + point_on_edge, ) __all__ = [ "plane_offset_from_face", + "plane_offset_from_plane", "plane_midplane", "plane_from_3_points", "plane_normal_to_edge", diff --git a/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc b/ztools/ztools/datums/__pycache__/__init__.cpython-312.pyc index 8461c8a0d4b5f26bd6f18a7bc0838c633fed0dcc..dfeaa09b22378b9ac0488b47b647c295e490d1ae 100644 GIT binary patch delta 432 zcmZY5ze>a~90%~G?fucao^7pH&s~m!E;zZ0yCs)}+Wlhj!H9g%g?3sk5%P2 D3Ko3) delta 296 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

v2 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 int: """Get next available index for auto-naming.""" @@ -26,47 +31,198 @@ def _get_next_index(doc: App.Document, prefix: str) -> int: return max(indices, default=0) + 1 -def _setup_datum_attachment(obj, support, map_mode: str, offset: App.Placement = None): +# ============================================================================= +# ZTOOLS CUSTOM ATTACHMENT SYSTEM +# ============================================================================= +# +# FreeCAD's vanilla attachment system has reliability issues. ZTools uses its +# own attachment approach: +# +# 1. Store source references as properties (ZTools_SourceRefs) +# 2. Store creation method and parameters (ZTools_Type, ZTools_Params) +# 3. Calculate placement directly from geometry at creation time +# 4. Use MapMode='Deactivated' to prevent FreeCAD attachment interference +# +# This gives us full control over datum positioning while maintaining +# the ability to update datums when source geometry changes (future feature). +# ============================================================================= + + +def _style_ztools_plane(obj): """ - Set up a PartDesign datum object with proper attachment. + Apply ZTools default styling to a datum plane. + Makes the plane transparent purple (Catppuccin Mocha mauve). + """ + if hasattr(obj, "ViewObject") and obj.ViewObject is not None: + vo = obj.ViewObject + # Set shape color to mauve purple + if hasattr(vo, "ShapeColor"): + vo.ShapeColor = ZTOOLS_PLANE_COLOR + # Set transparency (0 = opaque, 100 = fully transparent) + if hasattr(vo, "Transparency"): + vo.Transparency = ZTOOLS_PLANE_TRANSPARENCY + # Also set line color for edges + if hasattr(vo, "LineColor"): + vo.LineColor = ZTOOLS_PLANE_COLOR + + +def _hide_attachment_properties(obj): + """ + Hide FreeCAD's vanilla attachment properties from the property editor. + This prevents user confusion since ZTools uses its own attachment system. + + Editor modes: + 0 = Normal (visible, editable) + 1 = Read-only + 2 = Hidden + """ + if not hasattr(obj, "ViewObject") or obj.ViewObject is None: + return + + vo = obj.ViewObject + + # Hide attachment-related properties + attachment_props = [ + "MapMode", + "MapPathParameter", + "MapReversed", + "AttachmentOffset", + "Support", + ] + + for prop in attachment_props: + try: + if hasattr(obj, prop): + vo.setEditorMode(prop, 2) # 2 = Hidden + except Exception: + pass # Property might not exist on all datum types + + +def _setup_ztools_viewprovider(obj): + """ + Set up a custom ViewProvider proxy for ZTools datums. + This enables custom double-click behavior to open ZTools editor. + """ + if not hasattr(obj, "ViewObject") or obj.ViewObject is None: + return + + vo = obj.ViewObject + + # Only set up if not already a ZTools ViewProvider + if hasattr(vo, "Proxy") and vo.Proxy is not None: + if hasattr(vo.Proxy, "_is_ztools"): + return + + # Import here to avoid circular imports + from ztools.commands.datum_viewprovider import ZToolsDatumViewProvider + + ZToolsDatumViewProvider(vo) + + +def _setup_ztools_datum( + obj, + placement: App.Placement, + datum_type: str, + params: Dict[str, Any], + source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None, + is_plane: bool = False, +): + """ + Set up a ZTools datum with custom attachment system. Args: obj: The datum object (PartDesign::Plane, Line, or Point) - support: Attachment support - list of tuples [(object, 'SubElement'), ...] - map_mode: Attachment mode string (e.g., 'FlatFace', 'ObjectXY', 'Translate') - offset: Optional offset from the attachment point - """ - if hasattr(obj, "Support"): - obj.Support = support - - if hasattr(obj, "MapMode"): - obj.MapMode = map_mode - - if offset and hasattr(obj, "MapPathParameter"): - obj.AttachmentOffset = offset - - -def _setup_datum_placement(obj, placement: App.Placement): - """ - Set up a PartDesign datum object with placement only (no attachment). - Use this when we can't determine a proper attachment reference. - - For PartDesign datums (Plane, Line, Point), we need to either: - 1. Set up proper attachment (Support + MapMode), or - 2. Explicitly set MapMode to 'Deactivated' to indicate we're using placement only - - This function sets MapMode to 'Deactivated' and applies the placement. + placement: Calculated placement for the datum + datum_type: ZTools creation method identifier + params: Creation parameters to store + source_refs: List of (object, subname) tuples for source geometry + is_plane: If True, apply transparent purple styling """ + # Disable FreeCAD's attachment system if hasattr(obj, "MapMode"): obj.MapMode = "Deactivated" - - # Clear any attachment support if hasattr(obj, "Support"): obj.Support = None - # Apply placement + # Apply calculated placement obj.Placement = placement + # Store ZTools metadata + _add_ztools_metadata(obj, datum_type, params, source_refs) + + # Apply plane styling if this is a plane + if is_plane: + _style_ztools_plane(obj) + + # Hide vanilla attachment properties from property editor + _hide_attachment_properties(obj) + + # Set up custom ViewProvider for edit behavior + _setup_ztools_viewprovider(obj) + + +def _add_ztools_metadata( + obj, + datum_type: str, + params: Dict[str, Any], + source_refs: Optional[List[Tuple[App.DocumentObject, str]]] = None, +): + """Store ZTools metadata in object properties.""" + # Add ZTools_Type property + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Type", + "ZTools", + "Datum creation method", + ) + + # Add ZTools_Params property + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}Params", + "ZTools", + "Creation parameters (JSON)", + ) + + # Add ZTools_SourceRefs property for storing references + if not hasattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs"): + obj.addProperty( + "App::PropertyString", + f"{ZTOOLS_META_PREFIX}SourceRefs", + "ZTools", + "Source geometry references (JSON)", + ) + + setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type) + + # Convert vectors/placements to serializable format + serializable_params = {} + for k, v in params.items(): + if isinstance(v, App.Vector): + serializable_params[k] = {"_type": "Vector", "x": v.x, "y": v.y, "z": v.z} + elif isinstance(v, App.Placement): + serializable_params[k] = { + "_type": "Placement", + "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z}, + "rotation": list(v.Rotation.Q), + } + else: + serializable_params[k] = v + + setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params)) + + # Store source references + if source_refs: + ref_data = [] + for src_obj, subname in source_refs: + if src_obj: + ref_data.append({"object": src_obj.Name, "subname": subname or ""}) + setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", json.dumps(ref_data)) + else: + setattr(obj, f"{ZTOOLS_META_PREFIX}SourceRefs", "[]") + def _get_subname_from_shape(parent_obj, shape): """ @@ -127,44 +283,6 @@ def _find_shape_owner(doc, shape): return None, None -def _add_ztools_metadata(obj, datum_type: str, params: dict): - """Store ztools metadata in object properties.""" - # Add custom properties for ztools tracking - if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Type"): - obj.addProperty( - "App::PropertyString", - f"{ZTOOLS_META_PREFIX}Type", - "ztools", - "Datum creation method", - ) - if not hasattr(obj, f"{ZTOOLS_META_PREFIX}Params"): - obj.addProperty( - "App::PropertyString", - f"{ZTOOLS_META_PREFIX}Params", - "ztools", - "Creation parameters (JSON)", - ) - - setattr(obj, f"{ZTOOLS_META_PREFIX}Type", datum_type) - - import json - - # Convert vectors/placements to serializable format - serializable_params = {} - for k, v in params.items(): - if isinstance(v, App.Vector): - serializable_params[k] = {"x": v.x, "y": v.y, "z": v.z} - elif isinstance(v, App.Placement): - serializable_params[k] = { - "base": {"x": v.Base.x, "y": v.Base.y, "z": v.Base.z}, - "rotation": list(v.Rotation.Q), - } - else: - serializable_params[k] = v - - setattr(obj, f"{ZTOOLS_META_PREFIX}Params", json.dumps(serializable_params)) - - def _link_to_spreadsheet( doc: App.Document, obj, param_name: str, value: float, alias: str ): @@ -211,8 +329,8 @@ def plane_offset_from_face( name: Optional custom name body: Optional body to add plane to (None = document level) link_spreadsheet: Create spreadsheet alias for distance - source_object: The object containing the face (for attachment) - source_subname: The sub-element name like 'Face1' (for attachment) + source_object: The object containing the face (for reference tracking) + source_subname: The sub-element name like 'Face1' (for reference tracking) Returns: Created datum plane object @@ -236,46 +354,123 @@ def plane_offset_from_face( idx = _get_next_index(doc, "ZPlane_Offset") name = f"ZPlane_Offset_{idx:03d}" - # Create plane + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(base, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Try to use proper attachment if we have the source reference - if source_object and source_subname: - # Use FlatFace mode with offset - _setup_datum_attachment( - plane, [(source_object, source_subname)], "FlatFace" - ) - # Set the offset along the normal - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, distance), App.Rotation() - ) - else: - # Fallback to placement-only mode - _setup_datum_placement(plane, App.Placement(base, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - # Center the plane visually (for Part::Plane) - plane.Placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) + # Adjust placement for Part::Plane (centered differently) + placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) - # Store metadata - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( plane, + placement, "offset_from_face", {"distance": distance, "base": base, "normal": normal}, + source_refs, + is_plane=True, ) # Spreadsheet link - if link_spreadsheet and not body: - # Part::Plane doesn't have Offset property, would need expression on Placement - pass - elif link_spreadsheet and body: + if link_spreadsheet and body: alias = f"{name}_offset" - _link_to_spreadsheet(doc, plane, "AttachmentOffset.Base.z", distance, alias) + _link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias) + + doc.recompute() + return plane + + +def plane_offset_from_plane( + source_plane: App.DocumentObject, + distance: float, + name: Optional[str] = None, + body: Optional[App.DocumentObject] = None, + link_spreadsheet: bool = False, +) -> App.DocumentObject: + """ + Create datum plane offset from another datum plane. + + Args: + source_plane: Source datum plane object (PartDesign::Plane or Part::Plane) + distance: Offset distance in mm (positive = along normal) + name: Optional custom name + body: Optional body to add plane to (None = document level) + link_spreadsheet: Create spreadsheet alias for distance + + Returns: + Created datum plane object + """ + doc = App.ActiveDocument + + # Get the plane's shape and extract normal/position + if not hasattr(source_plane, "Shape"): + raise ValueError("Source must be a plane object with a Shape") + + shape = source_plane.Shape + if not shape.Faces: + raise ValueError("Source plane has no faces") + + face = shape.Faces[0] + if not face.Surface.isPlanar(): + raise ValueError("Source must be a planar object") + + # Get normal from the plane's face + uv = face.Surface.parameter(face.CenterOfMass) + normal = face.normalAt(uv[0], uv[1]) + + # Get the plane's center position + center = face.CenterOfMass + + # Calculate offset position + base = center + normal * distance + + # Auto-name + if name is None: + idx = _get_next_index(doc, "ZPlane_Offset") + name = f"ZPlane_Offset_{idx:03d}" + + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(base, rot) + + # Create plane + if body: + plane = body.newObject("PartDesign::Plane", name) + else: + plane = doc.addObject("Part::Plane", name) + plane.Length = 50 + plane.Width = 50 + # Adjust placement for Part::Plane (centered differently) + placement = App.Placement(base - rot.multVec(App.Vector(25, 25, 0)), rot) + + # Set up with ZTools attachment system + source_refs = [(source_plane, "")] + _setup_ztools_datum( + plane, + placement, + "offset_from_plane", + { + "distance": distance, + "base": base, + "normal": normal, + "source_plane": source_plane.Name, + }, + source_refs, + is_plane=True, + ) + + # Spreadsheet link + if link_spreadsheet and body: + alias = f"{name}_offset" + _link_to_spreadsheet(doc, plane, "Placement.Base.z", distance, alias) doc.recompute() return plane @@ -331,22 +526,33 @@ def plane_midplane( idx = _get_next_index(doc, "ZPlane_Mid") name = f"ZPlane_Mid_{idx:03d}" - # Create plane + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), n1) + placement = App.Placement(mid, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - # No direct "midplane" attachment mode exists in FreeCAD - # Use placement-only mode with calculated midpoint - _setup_datum_placement(plane, App.Placement(mid, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot) + placement = App.Placement(mid - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( - plane, "midplane", {"center1": c1, "center2": c2, "midpoint": mid} + # Set up with ZTools attachment system + source_refs = [] + if source_object1: + source_refs.append((source_object1, source_subname1)) + if source_object2: + source_refs.append((source_object2, source_subname2)) + + _setup_ztools_datum( + plane, + placement, + "midplane", + {"center1": c1, "center2": c2, "midpoint": mid}, + source_refs if source_refs else None, + is_plane=True, ) doc.recompute() @@ -391,28 +597,27 @@ def plane_from_3_points( idx = _get_next_index(doc, "ZPlane_3Pt") name = f"ZPlane_3Pt_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), normal) + placement = App.Placement(center, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use ThreePointPlane attachment if we have references - if source_refs and len(source_refs) >= 3: - _setup_datum_attachment(plane, source_refs[:3], "ThreePointPlane") - else: - _setup_datum_placement(plane, App.Placement(center, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( - center - rot.multVec(App.Vector(25, 25, 0)), rot - ) + placement = App.Placement(center - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # Set up with ZTools attachment system + _setup_ztools_datum( plane, + placement, "3_points", {"p1": p1, "p2": p2, "p3": p3, "center": center, "normal": normal}, + source_refs, + is_plane=True, ) doc.recompute() @@ -454,30 +659,26 @@ def plane_normal_to_edge( # Plane normal = edge tangent rot = App.Rotation(App.Vector(0, 0, 1), tangent) + placement = App.Placement(point, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use NormalToPath attachment if we have a reference - if source_object and source_subname: - _setup_datum_attachment( - plane, [(source_object, source_subname)], "NormalToPath" - ) - # Set parameter along path - if hasattr(plane, "MapPathParameter"): - plane.MapPathParameter = parameter - else: - _setup_datum_placement(plane, App.Placement(point, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot) + placement = App.Placement(point - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( plane, + placement, "normal_to_edge", {"parameter": parameter, "point": point, "tangent": tangent}, + source_refs, + is_plane=True, ) doc.recompute() @@ -540,32 +741,29 @@ def plane_angled( idx = _get_next_index(doc, "ZPlane_Angled") name = f"ZPlane_Angled_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), new_normal) + placement = App.Placement(edge_mid, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use FlatFace on the reference face with rotation offset - if source_face_obj and source_face_sub: - _setup_datum_attachment( - plane, [(source_face_obj, source_face_sub)], "FlatFace" - ) - # Apply rotation as attachment offset - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, 0), App.Rotation(App.Vector(1, 0, 0), angle) - ) - else: - _setup_datum_placement(plane, App.Placement(edge_mid, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( - edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot - ) + placement = App.Placement(edge_mid - rot.multVec(App.Vector(25, 25, 0)), rot) - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [] + if source_face_obj: + source_refs.append((source_face_obj, source_face_sub)) + if source_edge_obj: + source_refs.append((source_edge_obj, source_edge_sub)) + + _setup_ztools_datum( plane, + placement, "angled", { "angle": angle, @@ -573,13 +771,14 @@ def plane_angled( "original_normal": face_normal, "new_normal": new_normal, }, + source_refs if source_refs else None, + is_plane=True, ) if link_spreadsheet and body: alias = f"{name}_angle" - _link_to_spreadsheet( - doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias - ) + # For ZTools system, we'd need custom expression handling + # _link_to_spreadsheet(doc, plane, "...", angle, alias) doc.recompute() return plane @@ -639,40 +838,32 @@ def plane_tangent_to_cylinder( idx = _get_next_index(doc, "ZPlane_Tangent") name = f"ZPlane_Tangent_{idx:03d}" + # Calculate placement rot = App.Rotation(App.Vector(0, 0, 1), plane_normal) + placement = App.Placement(tangent_point, rot) + # Create plane if body: plane = body.newObject("PartDesign::Plane", name) - - # Use Tangent attachment mode for cylindrical face - if source_object and source_subname: - _setup_datum_attachment(plane, [(source_object, source_subname)], "Tangent") - # Set rotation angle via attachment offset - plane.AttachmentOffset = App.Placement( - App.Vector(0, 0, 0), App.Rotation(App.Vector(0, 0, 1), angle) - ) - else: - _setup_datum_placement(plane, App.Placement(tangent_point, rot)) else: plane = doc.addObject("Part::Plane", name) plane.Length = 50 plane.Width = 50 - plane.Placement = App.Placement( + placement = App.Placement( tangent_point - rot.multVec(App.Vector(25, 25, 0)), rot ) - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( plane, + placement, "tangent_cylinder", {"angle": angle, "radius": radius, "tangent_point": tangent_point}, + source_refs, + is_plane=True, ) - if link_spreadsheet and body: - alias = f"{name}_angle" - _link_to_spreadsheet( - doc, plane, "AttachmentOffset.Rotation.Angle", angle, alias - ) - doc.recompute() return plane @@ -715,22 +906,25 @@ def axis_from_2_points( idx = _get_next_index(doc, "ZAxis_2Pt") name = f"ZAxis_2Pt_{idx:03d}" + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - - # Use TwoPointLine attachment if we have references - if source_refs and len(source_refs) >= 2: - _setup_datum_attachment(axis, source_refs[:2], "TwoPointLine") - else: - rot = App.Rotation(App.Vector(0, 0, 1), direction) - _setup_datum_placement(axis, App.Placement(midpoint, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( - axis, "2_points", {"p1": p1, "p2": p2, "direction": direction, "length": length} + # Set up with ZTools attachment system + _setup_ztools_datum( + axis, + placement, + "2_points", + {"p1": p1, "p2": p2, "direction": direction, "length": length}, + source_refs, ) doc.recompute() @@ -770,22 +964,26 @@ def axis_from_edge( direction = (p2 - p1).normalize() midpoint = (p1 + p2) * 0.5 + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - - # Use ObjectXY attachment for edge - if source_object and source_subname: - _setup_datum_attachment(axis, [(source_object, source_subname)], "ObjectXY") - else: - rot = App.Rotation(App.Vector(0, 0, 1), direction) - _setup_datum_placement(axis, App.Placement(midpoint, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( - axis, "from_edge", {"p1": p1, "p2": p2, "direction": direction} + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + axis, + placement, + "from_edge", + {"p1": p1, "p2": p2, "direction": direction}, + source_refs, ) doc.recompute() @@ -806,8 +1004,8 @@ def axis_cylinder_center( face: Cylindrical face name: Optional custom name body: Optional body to add axis to - source_object: Object that owns the face (for attachment) - source_subname: Sub-element name like 'Face1' (for attachment) + source_object: Object that owns the face (for reference tracking) + source_subname: Sub-element name like 'Face1' (for reference tracking) Returns: Created datum axis object @@ -822,8 +1020,6 @@ def axis_cylinder_center( axis_dir = cyl.Axis # Get cylinder extent from face bounds - bbox = face.BoundBox - # Project to axis p1 = center + axis_dir * (-50) # Arbitrary length p2 = center + axis_dir * 50 @@ -831,24 +1027,26 @@ def axis_cylinder_center( idx = _get_next_index(doc, "ZAxis_Cyl") name = f"ZAxis_Cyl_{idx:03d}" + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), axis_dir) + placement = App.Placement(center, rot) + + # Create axis if body: axis = body.newObject("PartDesign::Line", name) - if source_object and source_subname: - # Use 'ObjectZ' to align axis with cylindrical face's axis - support = [(source_object, source_subname)] - _setup_datum_attachment(axis, support, "ObjectZ") - else: - rot = App.Rotation(App.Vector(0, 0, 1), axis_dir) - _setup_datum_placement(axis, App.Placement(center, rot)) else: axis = doc.addObject("Part::Line", name) axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z - _add_ztools_metadata( + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( axis, + placement, "cylinder_center", {"center": center, "direction": axis_dir, "radius": cyl.Radius}, + source_refs, ) doc.recompute() @@ -872,10 +1070,10 @@ def axis_intersection_planes( plane1, plane2: Two non-parallel planes name: Optional custom name body: Optional body to add axis to - source_object1: Object that owns plane1 (for attachment) - source_subname1: Sub-element name for plane1 (for attachment) - source_object2: Object that owns plane2 (for attachment) - source_subname2: Sub-element name for plane2 (for attachment) + source_object1: Object that owns plane1 (for reference tracking) + source_subname1: Sub-element name for plane1 (for reference tracking) + source_object2: Object that owns plane2 (for reference tracking) + source_subname2: Sub-element name for plane2 (for reference tracking) Returns: Created datum axis object @@ -894,32 +1092,42 @@ def axis_intersection_planes( edge = common.Edges[0] p1 = edge.valueAt(edge.FirstParameter) p2 = edge.valueAt(edge.LastParameter) + direction = (p2 - p1).normalize() + midpoint = (p1 + p2) * 0.5 if name is None: idx = _get_next_index(doc, "ZAxis_Intersect") name = f"ZAxis_Intersect_{idx:03d}" - if ( - body - and source_object1 - and source_subname1 - and source_object2 - and source_subname2 - ): - # Create axis with TwoFace attachment (intersection of two planes) - axis = body.newObject("PartDesign::Line", name) - support = [(source_object1, source_subname1), (source_object2, source_subname2)] - _setup_datum_attachment(axis, support, "TwoFace") + # Calculate placement + rot = App.Rotation(App.Vector(0, 0, 1), direction) + placement = App.Placement(midpoint, rot) - _add_ztools_metadata( - axis, - "plane_intersection", - {"point1": p1, "point2": p2}, - ) - doc.recompute() - return axis + # Create axis + if body: + axis = body.newObject("PartDesign::Line", name) else: - return axis_from_2_points(p1, p2, name, body) + axis = doc.addObject("Part::Line", name) + axis.X1, axis.Y1, axis.Z1 = p1.x, p1.y, p1.z + axis.X2, axis.Y2, axis.Z2 = p2.x, p2.y, p2.z + + # Set up with ZTools attachment system + source_refs = [] + if source_object1: + source_refs.append((source_object1, source_subname1)) + if source_object2: + source_refs.append((source_object2, source_subname2)) + + _setup_ztools_datum( + axis, + placement, + "plane_intersection", + {"point1": p1, "point2": p2, "direction": direction}, + source_refs if source_refs else None, + ) + + doc.recompute() + return axis # ============================================================================= @@ -941,8 +1149,8 @@ def point_at_vertex( vertex: Source vertex name: Optional custom name body: Optional body to add point to - source_object: Object that owns the vertex (for attachment) - source_subname: Sub-element name like 'Vertex1' (for attachment) + source_object: Object that owns the vertex (for reference tracking) + source_subname: Sub-element name like 'Vertex1' (for reference tracking) Returns: Created datum point object @@ -954,19 +1162,25 @@ def point_at_vertex( idx = _get_next_index(doc, "ZPoint_Vtx") name = f"ZPoint_Vtx_{idx:03d}" + # Calculate placement + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - if source_object and source_subname: - # Use 'Vertex' attachment mode to attach to the vertex - support = [(source_object, source_subname)] - _setup_datum_attachment(point, support, "Vertex") - else: - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "vertex", {"position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "vertex", + {"position": pos}, + source_refs, + ) doc.recompute() return point @@ -998,16 +1212,24 @@ def point_at_coordinates( idx = _get_next_index(doc, "ZPoint_XYZ") name = f"ZPoint_XYZ_{idx:03d}" + pos = App.Vector(x, y, z) + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement( - point, App.Placement(App.Vector(x, y, z), App.Rotation()) - ) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = x, y, z - _add_ztools_metadata(point, "coordinates", {"x": x, "y": y, "z": z}) + # Set up with ZTools attachment system (no source refs for explicit coordinates) + _setup_ztools_datum( + point, + placement, + "coordinates", + {"x": x, "y": y, "z": z}, + None, + ) if link_spreadsheet and not body: _link_to_spreadsheet(doc, point, "X", x, f"{name}_X") @@ -1024,6 +1246,8 @@ def point_on_edge( name: Optional[str] = None, body: Optional[App.DocumentObject] = None, link_spreadsheet: bool = False, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point on edge at parameter. @@ -1034,6 +1258,8 @@ def point_on_edge( name: Optional custom name body: Optional body to add point to link_spreadsheet: Create spreadsheet alias for parameter + source_object: Object that owns the edge (for reference tracking) + source_subname: Sub-element name like 'Edge1' (for reference tracking) Returns: Created datum point object @@ -1047,14 +1273,24 @@ def point_on_edge( idx = _get_next_index(doc, "ZPoint_Edge") name = f"ZPoint_Edge_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "on_edge", {"parameter": parameter, "position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "on_edge", + {"parameter": parameter, "position": pos}, + source_refs, + ) doc.recompute() return point @@ -1064,6 +1300,8 @@ def point_center_of_face( face: Part.Face, name: Optional[str] = None, body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point at center of mass of face. @@ -1072,6 +1310,8 @@ def point_center_of_face( face: Source face name: Optional custom name body: Optional body to add point to + source_object: Object that owns the face (for reference tracking) + source_subname: Sub-element name like 'Face1' (for reference tracking) Returns: Created datum point object @@ -1083,14 +1323,24 @@ def point_center_of_face( idx = _get_next_index(doc, "ZPoint_FaceCenter") name = f"ZPoint_FaceCenter_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata(point, "face_center", {"position": pos}) + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "face_center", + {"position": pos}, + source_refs, + ) doc.recompute() return point @@ -1100,6 +1350,8 @@ def point_center_of_circle( edge: Part.Edge, name: Optional[str] = None, body: Optional[App.DocumentObject] = None, + source_object: Optional[App.DocumentObject] = None, + source_subname: Optional[str] = None, ) -> App.DocumentObject: """ Create datum point at center of circular edge. @@ -1108,6 +1360,8 @@ def point_center_of_circle( edge: Circular edge (circle or arc) name: Optional custom name body: Optional body to add point to + source_object: Object that owns the edge (for reference tracking) + source_subname: Sub-element name like 'Edge1' (for reference tracking) Returns: Created datum point object @@ -1123,15 +1377,23 @@ def point_center_of_circle( idx = _get_next_index(doc, "ZPoint_CircleCenter") name = f"ZPoint_CircleCenter_{idx:03d}" + placement = App.Placement(pos, App.Rotation()) + + # Create point if body: point = body.newObject("PartDesign::Point", name) - _setup_datum_placement(point, App.Placement(pos, App.Rotation())) else: point = doc.addObject("Part::Vertex", name) point.X, point.Y, point.Z = pos.x, pos.y, pos.z - _add_ztools_metadata( - point, "circle_center", {"position": pos, "radius": edge.Curve.Radius} + # Set up with ZTools attachment system + source_refs = [(source_object, source_subname)] if source_object else None + _setup_ztools_datum( + point, + placement, + "circle_center", + {"position": pos, "radius": edge.Curve.Radius}, + source_refs, ) doc.recompute()