From 05cb4199098806b75e75ec78ad9943d93c0bb6fe Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 12 Aug 2025 23:02:52 +0700 Subject: [PATCH] working --- Archive.zip | Bin 20675 -> 33396 bytes __init__.py | 1520 ++++++++++++++++++++++++++++++++-- test_positioning_system.py | 558 ------------- test_preset_unicode.py | 150 ---- test_ui_and_compatibility.py | 231 ------ 5 files changed, 1456 insertions(+), 1003 deletions(-) delete mode 100644 test_positioning_system.py delete mode 100644 test_preset_unicode.py delete mode 100644 test_ui_and_compatibility.py diff --git a/Archive.zip b/Archive.zip index b5fea3c6f6c5a09e673c5db73a60f0c8391ff85e..00aca0d2e4f39c4cf8d5c60b7b722e025238812a 100644 GIT binary patch delta 33115 zcmV)0K+eCzp#k)S0u4}00|XQR2mlBGyM+vq4KROF4V!3q4V!3E4V!4}z1wmlNwOgJ z-d_>J(piGjAd0M-9-BiuoLv+NvUr+B^6*mKJ$359l0bsYE&z!^0wmG3C6i>*zO0$n zv@5fHk=dH`12VnHB$M9uPv!@-pCEUS`zn!}^n;&7VJCrx&B zbet74U3mhpRppD>G#lT@8clXIp6MS4!#tV2O?v>`d8mKB%JZTA)fvx6`qy4Q9763Z zAM0mlc{ZM<3ZCp>UdV5=bTk=c!&Lq*=GT9KaW5@YiDIF?{#fK=`F%1>W`le>Qh>5i z`qfv@Si9fP$1EEU@~^IkUHq5rvX5Ue_^geSQM$In)-KbB8N)yGY03`MF{kKSlb1^7 zvs?HD<=!OI9(#j57_z?`!H<76q93hM`g>XWE}a(8WhmZw(PS^1?8Sz7b3IJQ{StxCrCmZnV5-80slWX>`*~M)Fa9F3G z0hUHVtB@dZ2AuMH_9g+MXUR|$k+}BLqBqSZvT4!t6u2X~Nnt36AO$1d!C&k?o87Xc z*PBzZK9G2foh?{0?gNdBSu&onVRnC)vdJ{PgKo%{pnvI2K3#Asa%AA$Cmf;OC;R}o ztIMO~qst%I!PUv$<Lh*f^iD z*(hVA->yV|tW6^wPFm+n#L)x3q5S1od+Z?UI-$^#Xe?*t~6NLHu-R-Rw{BQ03 z2JiGuI_m-qn4{f6KAsguw-3`9>!&m5W!eW0C}2(xbgvn&#i$nNqDeBlErQe_b^}^S zvK%2d9zac(UN^)1T0PU%sSSUac$tZD3I z6Wx9`#b(}FZ-^RuJI_VE=ZJRkT+9OVlNT+d01U?j=FYrGr;WA$`gwaY*-vK4^J5SQ z!{=|Z-ZU@r!OR7>VS#&Z{eo#KCnX@a_UqPcHozgpFFpPxhbOvHI<$Ygyq`?(Gn5DH z85#*0i{F>|1@NKKY|jXTEnjV8af1UH=1k;!V%W*yL9(*I!^_3`;U9skntf6|SqwC5gWW-fw`KG)U&d8Ip0I ze`P3r@b^`r);3X!z^$Qj$A^&v<66LNQY5q4v=P9$$<})btkr$KvaTDf7bx8_%L0=A z!mQgi%anmJYrucRZL)^qhNk9)4Vr@hfi%TN0ppYQsbtaO#0L)E`DW|A(E;98GMRw> z+t94uWM%_vNYWklQ9b(vM9kRNYLk@m|n+6hKdh)_AnyT{mG);!oVz+;b808EaM1iy5Czrx!GpSW>XmhQPJT!5V+xgRad?VgWGP z1}=QRVMTvVa<(1D2s4~Xfjx$ zkzJ5&{{G_h1lEvgk=pxf`~)>X(=g?}BRG9IVlbvy3KaRIUs$y8E7t4I#k_|*{=s}W zT!_Y~0|CviE`c}@iLu2TR%a~!cw8erm@j`b!6i;3E)!U9o3KjtW*an7+4{-z_Id~> zf556ihPB$|cnmd%0(%APy3!kN#kn})SSKy=zE`gH2=&y`$=luNid`EJ+no#ishL~vDEdrmq#srHuCU0r%V)|!7FXjyJ7ELl@d0M=zNdq^1hkhC1+W=f&36`%k2Qpxi%zUEYkr zGg6p*x`+b;-gl%S!qwV6Q)?UjOIi->2SWNWewNaamu5zs3bOI07Nh7$;;GBFTc-7}*7gfhTSR{9;Z;xn%6dlsOm6_rKDuQGd zTNq%|B`g#HlS61{@R`jJ7wvzy9e+$p%@9xptq_-|Cxa!~X zO_}gS3u%}uQKBEn7&xEg^Q4KZ-95z<(gni)J{Tr1)X#0mV-K`-+Fxo zb7>4h3Z;)gEp4)7St`dy=!&KR$Wj!c-^q0GtFWOCO`~K!HVnASPNaWBA#7bZuBp&A zifF?|%mlnF9v!!R%Qz4)J*)=T*`3F?=;(h)1=Qigcd>GQveU*a*$0&w)Cp26MOA|1i^-Ar(?He z({Kufu)wOvLm*x)5Voic9&M=d!2=8nn!joH#kmVEY-e*yL7Q&9xBy>rFwQ&e{a5Y1 zZ!0Bw%pCm)N$!7i>M{1+M+)^)OqMt7K2E-uBD0i5H*U#YPA89?RG@CI&YmUKBzk}4 zAoF-jWi34S^7*h&XptFJdO#vJxq}2)jGN{9=`fvD z80HMmJu0oOamv%@&uEXU*l5?@3mYKM(@}nB?k34ZC7Q8ZYr=aYd-ei}t9Lndl(A}s z)MO4mR?&$Y_ck&ZMNKCB#N|>QX=%V9kz=Hf*UOPjHp3QkRK;~^i197cDP{Y1pmOeTsO+U>_l ziyNjV+%#%6tmV;bTyTWA-xmgNJu8ld^wNGKD=#KA-kd%_Uq;EFn9%`N^z; zA~Yad9N!K#F&rlpo5&9I7Vw@Wo~^>ZP;yD?>om{U6}O+cgT)C$)0}xZo`r z*AftZwaeD^3b-Ct0c%0B0ogiTP`Ttb!f`6S-~t+*GDR6oMN|%*D3b;({QeCDViq>mQW}g0W>PxNNe!pSqLZ09*>U+GmIpr$RWmpQVIF zxN;rg5vf^ChRP5F4$V}d&~SuoVGA9h=P31vmT6a=@^xlqVa3CTaSlMoH|dm*uC)qg zv_((gZG)e0806dlU240@Ht9b{RM3<#%D&6qx6G;O%(qbI&4HT|+KsHlw@Xq>D}#T1 zc_w4x{7Ln_L2oS?0dF0Nvmwa1l(w6V1Ny2Ql&25jhK=gmqt18iuyfKm=XSWy8_hjS z3lSVO${t`4dr6UYi}?gKWeQko@*)LvfA__fR@5)yDY8Z)qR4oaf)SWa1OuunM*Va7 zhbF|~qJGqKlmD|nP3}XyPpy+9h0%Xrk&Gro9=)%M67m$mzsuIz$*b$V-u8=^YxetW zbOW!c!4^oS#`^i;tMw*pY;SEf**9Ao@b$$v-!z&0v$0{rte0y^G-HCth7z~Zv z)$n0l$5q``156nBy=6r`Qh>Ar1vDR={4St~F5^HKl^Y?;U_fppg@5$4cXe@j`i32x zo(Ox+4lx6g%wz@5jw;GghSwo75pjA6+Y%8Ubd6hRI*G_1Krqg3GYs7y4nZl*hNNZD zh~#N8URSKotupFVNppxxnQMQ9x~n;d_JR1Y(cq~uG}H1x!Cm|lHaP^S@kUdD_@0_8 z)BzDt0-sps&wxP0GTqdUKV7Ba1M=JwjdFmll{#w9t7wnRc&S2~>en*n;m5OqxZHpO z21arY3$&JFn~bJ9jx==-c##`#kdcGma$ zjmE%}{SPrF2?2u-+u&1s^GM6$SMq zS-^@0YI6(@yPl&jG)j9hg0!qK8e4B(6Tc3u0oyeQAKwTB$mw?<mPMFGvGXb^BdEl#HCscn*3o)BTwiGXm30elUS%7Tn zs20mw(m;SaF@J-$Vj8r_M!GY;MbFq}Hqj;I5A=P*jN#Y`_y+{QFr7jMBR$lQV4O$1 zK9X7PV)1*(J0Vz`62b7J2&f;*JVn`Ug1h2Ksw%2r&n|xSsH_-t=7*bYlr+fnI1>FV zxf$o6YV?2X)x9jAKru^v6EU3$;+uk>SKwCzvt<+_4vb0aD!?sa^ZLZD7clRZ)3O9m zkY=N-D5TRcfNkOPQQl7*c1!rEG?PHQ5tS9p7+igHqiFJO`@IW4B$x=J*aBOMOi%e; z-FS>ZPz4DJUw)eG7RUt@V>yD;9TjN1<4KfwYs!BKLQWT&ZEJNGgO3VsM8==wm|#Vf z3JE2g3u+2*-uEPr$Mwr_^^T!wk#`DfNRh~&ST4kiQMR@)b>{s(&0WReSK9Cu7m1>4v`OzJ3`e@AT54B73YSNO}|cucPU!tM5bcVvU&_%=?xRuko2Myav-*Y6G8%( z(;_qSo!7zhQjB45l`#y1Y~Q8hZ0di&1Ptl1nq^A@#ajXHeD39s^*mb+=vgwvc;}pO z)j;KAlrrd&7?v@T?SdJ)#qTHMF*lpH)byT%=t{WTr!&5&y!m8ro}(Z)a_%AcRWpAgCH#t!rR1~xia7+Rcqvxma#p{~Y7?xmIBqm&+-m&WB^!!cxn7wJA{d~SvH{w>2 z?ie>3!|o`VbkVliO@@=(q>+D(ZYYH(MJVrXx3-$>dN`kUr=UpA3;rOZK=&E$bi~ zf_m#Jr{no(vOrCFJn=V5DmLqcWBewWyk{4)golX>QlX|zf=iL*qx^sNiBo6yqd}uj zia;Ovzmffa=K;0knL`mkl#>IgXYRFNU|Kl4$*uPM@KxJ!sO9crSgOYZ-5b1AG%JbK zu6jpLyAm4?kPIq~jdw5K)AB|2vVv?EzrlkN2gH5WN6Ufe#;y&rg|>v6198UhOu?b} zNI*Y5eB@9++1N*Nf=quV!v&2GY4LoYVE8w~Lu1(3@7b13QI`~UKL*5Y4TJMqhshkU zO~$VPYC}vIqlJyI3B~F%;tV))tcmFab{2<)`zsP0;GRb?{dAfv$k2C#6>*CIntyLT zB%aphZcbB_1klUzq($`_R(G4MkMeXE-VX4}sh2M{*mLwcY;AwVNiSy!8_kEa3_CUr zwfXB^RWjHufpV&PQ3$zlkzd2WVhCT51h*hne!^TTLk&mOD6o9@ z!R-FKcj$MF$L8;w?A_)I`u>8x%jfR{RPm7b!9;=*Q1nVrSokPl)0>2ZIH||4@ccz3 z3I;A`!q%u|3$lL+jBOu{sQnu=okr5{r~R&)z5p2>HS~ZrS)&2%Z=(k){flk|Aena8 z5l~p*1O`#P#V3;XKc&;W=nk_FsnOtuU35{as9rK`FLPej^EjQdA9WcLHi6g_Ewo=g zykuj9be&Ic^V|=bFkhmD!6fRtY$yBrWt085^W)bq{pf!cIelX6ZPV>t27umkk`(h% z!)o}~lC%9o98StwwHIZ%NKEOaM!Bztxm;H1DUDK#gSw;34b16$qV)+_)6lp!7a5Ft zy2Ye0tup7(r-tAxytD#3v(@^BvCZEjm;!?ewz18v=C-*mVy#WVWk$6?E!Ow}7s0_W zn@sRXi7tP&fccb6`-c8gm;&Jy1_b^QR_1L`F*$Dk^g@1J{1b>&Xy*^KS;)tfFgY3C zCyRnj@R~l9c=Lb`1H>FAqlrz*X_^%InC|)_mJUv~9^fu$#G@>>!!u2nTX^L>q0e=m z=!(l<+fz&7#3}#azg(!7wwGJ!8cI#k<$o0-$xwe+#d2pd-#Pk(@y9!`q^PBIl+eH< zH*9Ji5?VEe<24NaYi&tZ`Zf9Lw~~U+CxE(h=)&*9TG}*)Es1LThe+G3A((K(#o~sr zF!*^TG(eq6H5LYcvWCH!8$r_kfotsFllR3Bi0~-MD4`i{9A_dElFfCXQNgQuKkXKO z#HfEpBI2)q{Q^3`djj9>8b1jEhI0%49VHLh2!t6f(yc8y!A!6l9;vRCy!fd;ZK{Nz zEzlxth<9S}Bm)K?(ss$kkt=9^M2MwuZ$#wn=sFvxK7XU!TGUt_AF5YJpp*MG(e&)P(hhC8Ej~;LsSWlp0U&z+dpJ^ zo<_ku(ex7vXjJUx>LwvEF_DROITA3(G3mk(hSCNoTA@413-bEq5qeE{J-$ndu9z<37w7mUdHl*(^xc{YYiny-sF8E91kQiB z*@Dvk3P&&bN(Y%?o+@e2pg%f%QIsWb8?@K2ziFRbwU5_LW#&yXMqf=6E*3Y}RSs=L z1y#qpBx4ciS?7GOb8^`}bmMRz#3XBk0X)DiZ+mNtuNx-)>}j|thq4Gj5#&{1l>PYZ z==;v`MG(mdj$vFPsBCC!wYfjy@w|U9ey~pzmMDYVs^ln{-ek~Zcj6I_zo@2{D7lD% zs6(vaT6;|=*dAN*K5-q=a8-~aFh^*0jW84td`O)F)C4rgL z!FV<`|Iq##Y)CK&GIHlU473r2y1|}5XD_T;7BwzU&+6m9(8O5y#CUajd3k^OW;tlq zKug5cuW(y}9&e8J_m4Yau#FP9WfYOx`~Z}O&DoqpPCgh|NtKY^g79C9j`AXg2EUMH z7H*k^7KXAhjw>e2!DKoaZe=jn0BvLXw%Al+M*GfewiJ-zXcSDiG(24GUGH z)}mQ9CV@*3QR5If?ib@;R}6m{->a$>=oU4ozIbUQw}%?HQxG*jrDCk*CingpwgAIy z{2}eDt1z{+$C79R!Ph$`JKGJ>67aC@I^$SEXV-q<@N@v)q72@H3vZ#_JIvVt0B6(e z!Eo;2ccPGig}$u&x9uGrzQftwC3%0>q?z93GDQC3 z((8TGsK6(csv~~tYT`klCZGMPrueCAiU*=5RebLp&B*jivxrnf?HCfK~;ti2bVqDzZK)GVJSPTd>_>XRK_>% zvSr{c`C${L#vehd%*Y|q!M~}B6+GWO&-ssYSn{K+VL$cz?_hrt-ct#=t{Mp(OvbW9 zgPxy?3=cdfTaIH+@qNREOs;j8r8-MPfmOPL1UKS9OHT9WEYLk;XDmElyR@YQ

1SWl#|+RAebJ=^4mb+@ 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 IawEtP0Fgg{&Hw-a delta 20294 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 diff --git a/__init__.py b/__init__.py index 842490f..b9cbbdd 100644 --- a/__init__.py +++ b/__init__.py @@ -94,7 +94,7 @@ def get_font_enum_items(self, context): return items def get_preset_path(): - """Get path for storing presets""" + """Get path for storing presets (legacy local storage)""" addon_dir = os.path.dirname(os.path.realpath(__file__)) presets_dir = os.path.join(addon_dir, "presets") if not os.path.exists(presets_dir): @@ -104,6 +104,173 @@ def get_preset_path(): print(f"Error creating presets directory: {e}") return presets_dir +def get_blend_file_presets(): + """Get presets stored in the current blend file""" + try: + # Get custom properties from the scene + import bpy + scene = bpy.context.scene + + # DETAILED LOGGING: Function entry + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ==================== LOADING FROM BLEND FILE ==================") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Function called to get presets from blend file") + + # Store presets in scene custom properties + if "text_texture_presets" not in scene: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] No 'text_texture_presets' found in scene, creating empty") + scene["text_texture_presets"] = "{}" + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Found 'text_texture_presets' in scene") + + preset_data = scene.get("text_texture_presets", "{}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data type: {type(preset_data)}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw preset data length: {len(str(preset_data))} characters") + + if isinstance(preset_data, str): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is string, attempting JSON parse...") + try: + import json + parsed_presets = json.loads(preset_data) + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Successfully parsed JSON") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets type: {type(parsed_presets)}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Parsed presets count: {len(parsed_presets) if isinstance(parsed_presets, dict) else 'N/A'}") + + if isinstance(parsed_presets, dict): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset names: {list(parsed_presets.keys())}") + + # DETAILED VERIFICATION: Check shader properties in each preset + for preset_name, preset_content in parsed_presets.items(): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset '{preset_name}' analysis:") + if isinstance(preset_content, dict): + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Keys: {list(preset_content.keys())}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_shadows: {preset_content.get('disable_shadows', 'NOT FOUND')}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_reflections: {preset_content.get('disable_reflections', 'NOT FOUND')}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] disable_backfacing: {preset_content.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ERROR: Preset content is not a dict: {type(preset_content)}") + + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Returning parsed presets") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================") + return parsed_presets + + except json.JSONDecodeError as json_err: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] JSON decode error: {json_err}") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Raw data causing error: {preset_data[:200]}...") # First 200 chars + print("Error parsing blend file presets, resetting to empty") + scene["text_texture_presets"] = "{}" + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Reset to empty, returning empty dict") + return {} + else: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Preset data is not string, returning empty dict") + print(f"[GET_BLEND_FILE_PRESETS DEBUG] ================================================================") + return {} + except Exception as e: + print(f"[GET_BLEND_FILE_PRESETS DEBUG] EXCEPTION occurred: {e}") + import traceback + print(f"[GET_BLEND_FILE_PRESETS DEBUG] Full traceback: {traceback.format_exc()}") + print(f"Error getting blend file presets: {e}") + return {} + +def save_blend_file_preset(preset_name, preset_data): + """Save a preset to the current blend file""" + try: + import bpy + import json + scene = bpy.context.scene + + # DETAILED LOGGING: Function entry + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ==================== SAVING TO BLEND FILE ===================") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Function called with preset_name: '{preset_name}'") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data type: {type(preset_data)}") + if isinstance(preset_data, dict): + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Preset data keys: {list(preset_data.keys())}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Shader properties in preset_data:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: preset_data is not a dictionary!") + + # Get existing presets + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Getting existing presets from blend file...") + presets = get_blend_file_presets() + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing presets count: {len(presets)}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Existing preset names: {list(presets.keys())}") + + # Add or update the preset + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Adding/updating preset '{preset_name}'...") + presets[preset_name] = preset_data + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Presets now contains {len(presets)} items") + + # VERIFY: Check that shader properties are in the preset we're about to save + if preset_name in presets and isinstance(presets[preset_name], dict): + saved_preset = presets[preset_name] + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] VERIFICATION - Shader properties in final preset:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {saved_preset.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {saved_preset.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {saved_preset.get('disable_backfacing', 'NOT FOUND')}") + + # Save back to scene custom properties + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Converting presets to JSON...") + json_data = json.dumps(presets, indent=2) + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] JSON data length: {len(json_data)} characters") + + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Saving to scene custom properties...") + scene["text_texture_presets"] = json_data + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Successfully saved to scene['text_texture_presets']") + + # FINAL VERIFICATION: Read back from scene to confirm save + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] FINAL VERIFICATION - Reading back from scene...") + readback_data = scene.get("text_texture_presets", "{}") + if readback_data: + try: + readback_presets = json.loads(readback_data) + if preset_name in readback_presets: + readback_preset = readback_presets[preset_name] + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] READBACK VERIFICATION - Shader properties:") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_shadows: {readback_preset.get('disable_shadows', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_reflections: {readback_preset.get('disable_reflections', 'NOT FOUND')}") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] disable_backfacing: {readback_preset.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Preset '{preset_name}' not found in readback data!") + except json.JSONDecodeError as json_err: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: Could not parse readback JSON: {json_err}") + else: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ERROR: No data in scene['text_texture_presets'] after save!") + + print(f"Saved preset '{preset_name}' to blend file") + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] ================================================================") + return True + except Exception as e: + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] EXCEPTION occurred: {e}") + import traceback + print(f"[SAVE_BLEND_FILE_PRESET DEBUG] Full traceback: {traceback.format_exc()}") + print(f"Error saving preset to blend file: {e}") + return False + +def delete_blend_file_preset(preset_name): + """Delete a preset from the current blend file""" + try: + import bpy + import json + scene = bpy.context.scene + + # Get existing presets + presets = get_blend_file_presets() + + # Remove the preset if it exists + if preset_name in presets: + del presets[preset_name] + scene["text_texture_presets"] = json.dumps(presets, indent=2) + print(f"Deleted preset '{preset_name}' from blend file") + return True + else: + print(f"Preset '{preset_name}' not found in blend file") + return False + except Exception as e: + print(f"Error deleting preset from blend file: {e}") + return False + def initialize_presets(): """Initialize presets during addon registration - prevents race conditions""" try: @@ -115,19 +282,53 @@ def initialize_presets(): if not props: return - # Clear existing presets - props.presets.clear() + # Get existing preset names for optimization + existing_presets = {p.name: p for p in props.presets} + # Load presets from blend file first (primary source) + blend_presets = get_blend_file_presets() + updated_presets = set() + + for preset_name in blend_presets.keys(): + updated_presets.add(preset_name) + if preset_name in existing_presets: + # Update existing preset source if needed + if existing_presets[preset_name].source != 'BLEND_FILE': + existing_presets[preset_name].source = 'BLEND_FILE' + else: + # Add new preset + preset = props.presets.add() + preset.name = preset_name + preset.source = 'BLEND_FILE' + + # Load legacy local presets as fallback preset_dir = get_preset_path() if os.path.exists(preset_dir): try: for filename in os.listdir(preset_dir): if filename.endswith('.json'): preset_name = os.path.splitext(filename)[0] - preset = props.presets.add() - preset.name = preset_name + # Only add if not already present from blend file + if preset_name not in blend_presets: + updated_presets.add(preset_name) + if preset_name in existing_presets: + # Update existing preset source if needed + if existing_presets[preset_name].source != 'LOCAL_FILE': + existing_presets[preset_name].source = 'LOCAL_FILE' + else: + # Add new preset + preset = props.presets.add() + preset.name = preset_name + preset.source = 'LOCAL_FILE' except OSError as e: - print(f"Error loading presets during initialization: {e}") + print(f"Error loading local presets during initialization: {e}") + + # Remove presets that no longer exist + for i in range(len(props.presets) - 1, -1, -1): + if props.presets[i].name not in updated_presets: + props.presets.remove(i) + + print(f"Initialized {len(props.presets)} presets ({len(blend_presets)} from blend file)") except Exception as e: print(f"Error in initialize_presets: {e}") @@ -315,6 +516,113 @@ def get_system_fallback_fonts(): "/usr/share/fonts/truetype/opensans/OpenSans-Regular.ttf" # Open Sans - mixed-case ] +# ============================================================================ +# NORMAL MAP GENERATION FUNCTIONS +# ============================================================================ + +def generate_normal_map_from_alpha(img, strength=1.0, blur_radius=1.0, invert=False): + """Generate a normal map from an image's alpha channel using height-based algorithm""" + try: + from PIL import Image, ImageFilter + import numpy as np + + print(f"[Normal Map] Starting generation with strength={strength}, blur={blur_radius}, invert={invert}") + + # Extract alpha channel as height map + if img.mode != 'RGBA': + img = img.convert('RGBA') + + # Get alpha channel + alpha_channel = img.split()[3] # Alpha is the 4th channel + width, height = alpha_channel.size + + print(f"[Normal Map] Processing {width}x{height} alpha channel") + + # Apply blur if specified + if blur_radius > 0: + alpha_channel = alpha_channel.filter(ImageFilter.GaussianBlur(radius=blur_radius)) + print(f"[Normal Map] Applied blur with radius {blur_radius}") + + # Convert to numpy array for gradient calculations + height_map = np.array(alpha_channel, dtype=np.float32) / 255.0 + + # Apply strength multiplier + height_map *= strength + + # Calculate gradients using Sobel operators + # Sobel X kernel for horizontal gradients + sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32) + # Sobel Y kernel for vertical gradients + sobel_y = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float32) + + # Pad the height map to handle edges + padded_height = np.pad(height_map, ((1, 1), (1, 1)), mode='edge') + + # Calculate gradients + grad_x = np.zeros_like(height_map) + grad_y = np.zeros_like(height_map) + + for i in range(height): + for j in range(width): + # Extract 3x3 neighborhood + neighborhood = padded_height[i:i+3, j:j+3] + # Apply Sobel operators + grad_x[i, j] = np.sum(neighborhood * sobel_x) + grad_y[i, j] = np.sum(neighborhood * sobel_y) + + print(f"[Normal Map] Calculated gradients") + + # Convert gradients to normal vectors + # Normal map RGB values are calculated as: + # R = (grad_x + 1) * 0.5 -> maps -1,1 to 0,1 + # G = (-grad_y + 1) * 0.5 -> maps -1,1 to 0,1 (Y is flipped for standard normal maps) + # B = sqrt(1 - grad_x^2 - grad_y^2) -> Z component, always pointing up + + # Clamp gradients to reasonable range + grad_x = np.clip(grad_x, -1, 1) + grad_y = np.clip(grad_y, -1, 1) + + # Apply invert if specified + if invert: + grad_x = -grad_x + grad_y = -grad_y + print(f"[Normal Map] Applied inversion") + + # Calculate normal map channels + # Red channel: X gradient mapped to 0-1 + normal_r = ((grad_x + 1.0) * 0.5 * 255).astype(np.uint8) + + # Green channel: Y gradient mapped to 0-1 (flipped) + normal_g = ((-grad_y + 1.0) * 0.5 * 255).astype(np.uint8) + + # Blue channel: Z component (pointing up) + # Calculate Z from X and Y to maintain unit length + grad_magnitude_sq = grad_x**2 + grad_y**2 + grad_z = np.sqrt(np.maximum(0, 1.0 - grad_magnitude_sq)) + normal_b = (grad_z * 255).astype(np.uint8) + + print(f"[Normal Map] Calculated normal vectors") + + # Create the normal map image + normal_map = Image.new('RGB', (width, height)) + + # Combine channels + for y in range(height): + for x in range(width): + r = int(normal_r[y, x]) + g = int(normal_g[y, x]) + b = int(normal_b[y, x]) + normal_map.putpixel((x, y), (r, g, b)) + + print(f"[Normal Map] Normal map generation completed successfully") + return normal_map + + except Exception as e: + print(f"[Normal Map] Error generating normal map: {e}") + import traceback + traceback.print_exc() + return None + # ============================================================================ # ENHANCED POSITIONING FUNCTIONS # ============================================================================ @@ -378,7 +686,8 @@ def get_anchor_point_matrix(): ] def generate_texture_image(props, width, height): - """Generate the actual texture image with overlays - width and height are REQUIRED""" + """Generate the actual texture image with overlays - width and height are REQUIRED + Returns: (diffuse_image, normal_map_image) or (diffuse_image, None) if normal map disabled""" try: install_pillow() from PIL import Image, ImageDraw, ImageFont @@ -392,6 +701,7 @@ def generate_texture_image(props, width, height): height = max(16, height) print(f"DEBUG: Generating texture - requested: {width}x{height}, texture settings: {props.texture_width}x{props.texture_height}") + print(f"DEBUG: Normal map generation enabled: {props.generate_normal_map}") # Scale font size proportionally for preview scale_factor = width / props.texture_width @@ -687,13 +997,29 @@ def generate_texture_image(props, width, height): final_img = Image.alpha_composite(final_img, temp_img) print(f"Final composite image created with {len(elements)} elements") - return final_img + + # Generate normal map if enabled + normal_map_img = None + if props.generate_normal_map: + print(f"[Normal Map] Generating normal map with settings: strength={props.normal_map_strength}, blur={props.normal_map_blur_radius}, invert={props.normal_map_invert}") + normal_map_img = generate_normal_map_from_alpha( + final_img, + strength=props.normal_map_strength, + blur_radius=props.normal_map_blur_radius, + invert=props.normal_map_invert + ) + if normal_map_img: + print(f"[Normal Map] Successfully generated normal map") + else: + print(f"[Normal Map] Failed to generate normal map") + + return final_img, normal_map_img except Exception as e: print(f"Error in generate_texture_image: {e}") import traceback traceback.print_exc() - return None + return None, None def generate_preview(context): """Generate preview texture""" @@ -715,12 +1041,24 @@ def generate_preview(context): print(f"Starting preview generation at {preview_w}x{preview_h} (full resolution)") print(f"DEBUG: Texture size is {props.texture_width}x{props.texture_height}, preview at {preview_w}x{preview_h}") - # Generate the image - img = generate_texture_image(props, preview_w, preview_h) - if not img: + # Generate the images (diffuse and normal map) + result = generate_texture_image(props, preview_w, preview_h) + if not result: print("Failed to generate PIL image") return None + # Extract diffuse image (and optionally normal map) + if isinstance(result, tuple): + img, normal_map_img = result + else: + # Backward compatibility in case function returns single image + img = result + normal_map_img = None + + if not img: + print("Failed to generate diffuse image") + return None + # Apply preview background if needed if props.preview_bg_type != 'transparent': from PIL import Image, ImageDraw @@ -923,6 +1261,16 @@ class TEXT_TEXTURE_Preset(PropertyGroup): description="Name of the preset", default="New Preset" ) + + source: EnumProperty( + name="Source", + description="Where this preset is stored", + items=[ + ('BLEND_FILE', 'Blend File', 'Preset stored in the blend file (travels with the file)'), + ('LOCAL_FILE', 'Local File', 'Preset stored locally (legacy system)') + ], + default='BLEND_FILE' + ) class TEXT_TEXTURE_Properties(PropertyGroup): """Properties for text texture generation""" @@ -1359,6 +1707,120 @@ class TEXT_TEXTURE_Properties(PropertyGroup): description="Internal flag for Enter key save requests", default=False ) + + # ============================================================================ + # SHADER GENERATION PROPERTIES + # ============================================================================ + + expand_shader: BoolProperty( + name="Shader Settings", + description="Show/hide Shader Settings section", + default=False + ) + + shader_type: EnumProperty( + name="Shader Type", + description="Type of shader to generate", + items=[ + ('PRINCIPLED', 'Principled BSDF', 'Standard PBR shader with text texture'), + ('EMISSION', 'Emission', 'Emissive shader for glowing text effects'), + ('TRANSPARENT', 'Transparent', 'Transparent shader with alpha blending'), + ], + default='PRINCIPLED' + ) + + shader_connection: EnumProperty( + name="Texture Connection", + description="How to connect the texture to the shader", + items=[ + ('BASE_COLOR', 'Base Color', 'Connect texture to base color input'), + ('EMISSION', 'Emission', 'Connect texture to emission input'), + ('ALPHA', 'Alpha', 'Connect texture alpha to transparency'), + ], + default='BASE_COLOR' + ) + + use_alpha: BoolProperty( + name="Use Alpha Channel", + description="Use texture alpha channel for transparency", + default=True + ) + + emission_strength: FloatProperty( + name="Emission Strength", + description="Strength of emission shader", + default=1.0, + min=0.0, + max=10.0 + ) + + auto_assign_material: BoolProperty( + name="Auto-Assign Material", + description="Automatically assign generated material to active object", + default=True + ) + + disable_shadows: BoolProperty( + name="Disable Shadows", + description="Make material not cast shadows using light path", + default=False + ) + + disable_reflections: BoolProperty( + name="Disable Reflections", + description="Make material not appear in reflections using light path", + default=False + ) + + disable_backfacing: BoolProperty( + name="Disable Backfacing", + description="Make material not render on backfacing geometry using light path", + default=False + ) + + # ============================================================================ + # NORMAL MAP GENERATION PROPERTIES + # ============================================================================ + + expand_normal_maps: BoolProperty( + name="Normal Maps", + description="Show/hide Normal Maps section", + default=False + ) + + generate_normal_map: BoolProperty( + name="Generate Normal Map", + description="Automatically generate normal map from text alpha channel", + default=False, + update=update_live_preview + ) + + normal_map_strength: FloatProperty( + name="Normal Strength", + description="Intensity of the normal map effect (higher = more pronounced)", + default=1.0, + min=0.1, + max=5.0, + precision=2, + update=update_live_preview + ) + + normal_map_blur_radius: FloatProperty( + name="Blur Radius", + description="Blur radius for smoothing normal map (0 = no blur)", + default=1.0, + min=0.0, + max=10.0, + precision=1, + update=update_live_preview + ) + + normal_map_invert: BoolProperty( + name="Invert Normal Map", + description="Invert the normal map for different height effects (emboss vs deboss)", + default=False, + update=update_live_preview + ) # ============================================================================ # OPERATORS @@ -1382,19 +1844,34 @@ class TEXT_TEXTURE_OT_generate(Operator): print(f"Generating texture at {final_width}x{final_height}px") # Generate at FULL resolution specified by user - img = generate_texture_image(props, final_width, final_height) + result = generate_texture_image(props, final_width, final_height) + if not result: + self.report({'ERROR'}, "Failed to generate texture image") + return {'CANCELLED'} - # Create final name + # Extract diffuse and normal map images + if isinstance(result, tuple): + img, normal_map_img = result + else: + # Backward compatibility + img = result + normal_map_img = None + + if not img: + self.report({'ERROR'}, "Failed to generate diffuse image") + return {'CANCELLED'} + + # Create diffuse texture img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}" - # Remove existing + # Remove existing diffuse texture if img_name in bpy.data.images: bpy.data.images.remove(bpy.data.images[img_name]) - # Create new image with USER SPECIFIED dimensions + # Create new diffuse image with USER SPECIFIED dimensions blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True) - # Convert PIL to Blender + # Convert PIL to Blender for diffuse texture pixels = [] for y in range(final_height): for x in range(final_width): @@ -1404,25 +1881,511 @@ class TEXT_TEXTURE_OT_generate(Operator): blender_img.pixels = pixels blender_img.pack() # PACK FINAL IMAGE + # Create normal map texture if enabled and generated + normal_map_blender_img = None + if normal_map_img and props.generate_normal_map: + normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}" + + # Remove existing normal map texture + if normal_map_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[normal_map_name]) + + # Create new normal map image + normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False) + normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps + + # Convert PIL to Blender for normal map + normal_pixels = [] + for y in range(final_height): + for x in range(final_width): + r, g, b = normal_map_img.getpixel((x, final_height - 1 - y)) + normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps + + normal_map_blender_img.pixels = normal_pixels + normal_map_blender_img.pack() # PACK NORMAL MAP + print(f"[Normal Map] Created and packed normal map texture: {normal_map_name}") + # Add to shader if context.space_data and context.space_data.type == 'NODE_EDITOR': if context.space_data.tree_type == 'ShaderNodeTree': material = context.space_data.id if material and material.use_nodes: nodes = material.node_tree.nodes + + # Add diffuse texture node img_node = nodes.new(type='ShaderNodeTexImage') img_node.image = blender_img img_node.location = (0, 0) img_node.select = True nodes.active = img_node + + # Add normal map node if normal map was created + if normal_map_blender_img: + normal_node = nodes.new(type='ShaderNodeTexImage') + normal_node.image = normal_map_blender_img + normal_node.location = (0, -300) + normal_node.label = "Normal Map" + print(f"[Normal Map] Added normal map node to shader editor") - self.report({'INFO'}, f"Generated and packed: {img_name} ({final_width}x{final_height}px)") + # Provide comprehensive feedback + if normal_map_blender_img: + self.report({'INFO'}, f"Generated and packed: {img_name} + {normal_map_blender_img.name} ({final_width}x{final_height}px)") + else: + self.report({'INFO'}, f"Generated and packed: {img_name} ({final_width}x{final_height}px)") return {'FINISHED'} except Exception as e: self.report({'ERROR'}, f"Failed: {str(e)}") return {'CANCELLED'} +class TEXT_TEXTURE_OT_generate_shader(Operator): + """Generate complete shader with text texture""" + bl_idname = "text_texture.generate_shader" + bl_label = "Generate Shader" + bl_description = "Generate a complete material shader with the text texture" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + props = context.scene.text_texture_props + + # Generate texture first + final_width = props.texture_width + final_height = props.texture_height + + print(f"Generating shader with texture at {final_width}x{final_height}px") + + result = generate_texture_image(props, final_width, final_height) + if not result: + self.report({'ERROR'}, "Failed to generate texture image") + return {'CANCELLED'} + + # Extract diffuse and normal map images + if isinstance(result, tuple): + img, normal_map_img = result + else: + # Backward compatibility + img = result + normal_map_img = None + + if not img: + self.report({'ERROR'}, "Failed to generate diffuse image") + return {'CANCELLED'} + + # Create diffuse texture name + img_name = f"TextTexture_{props.text[:20].replace(' ', '_')}" + + # Remove existing diffuse texture if it exists + if img_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[img_name]) + + # Create new diffuse texture + blender_img = bpy.data.images.new(img_name, final_width, final_height, alpha=True) + + # Convert PIL to Blender for diffuse texture + pixels = [] + for y in range(final_height): + for x in range(final_width): + r, g, b, a = img.getpixel((x, final_height - 1 - y)) + pixels.extend([r/255.0, g/255.0, b/255.0, a/255.0]) + + blender_img.pixels = pixels + blender_img.pack() + + # Create normal map texture if enabled and generated + normal_map_blender_img = None + if normal_map_img and props.generate_normal_map: + normal_map_name = f"TextTexture_Normal_{props.text[:20].replace(' ', '_')}" + + # Remove existing normal map texture + if normal_map_name in bpy.data.images: + bpy.data.images.remove(bpy.data.images[normal_map_name]) + + # Create new normal map image + normal_map_blender_img = bpy.data.images.new(normal_map_name, final_width, final_height, alpha=False) + normal_map_blender_img.colorspace_settings.name = 'Non-Color' # Important for normal maps + + # Convert PIL to Blender for normal map + normal_pixels = [] + for y in range(final_height): + for x in range(final_width): + r, g, b = normal_map_img.getpixel((x, final_height - 1 - y)) + normal_pixels.extend([r/255.0, g/255.0, b/255.0, 1.0]) # Alpha always 1.0 for normal maps + + normal_map_blender_img.pixels = normal_pixels + normal_map_blender_img.pack() + print(f"[Normal Map] Created normal map texture for shader: {normal_map_name}") + + # Create or get material + material_name = f"TextTexture_Mat_{props.text[:15].replace(' ', '_')}" + + if material_name in bpy.data.materials: + mat = bpy.data.materials[material_name] + # Clear existing nodes + mat.node_tree.nodes.clear() + else: + mat = bpy.data.materials.new(name=material_name) + mat.use_nodes = True + mat.node_tree.nodes.clear() + + # Set up shader nodes + nodes = mat.node_tree.nodes + links = mat.node_tree.links + + # Create nodes + output_node = nodes.new(type='ShaderNodeOutputMaterial') + output_node.location = (400, 0) + + # Shader type based on settings + if props.shader_type == 'PRINCIPLED': + shader_node = nodes.new(type='ShaderNodeBsdfPrincipled') + shader_node.location = (200, 0) + + # Create diffuse texture node + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.image = blender_img + tex_node.location = (-200, 0) + + # Connect texture to shader + if props.shader_connection == 'BASE_COLOR': + links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color']) + elif props.shader_connection == 'EMISSION': + links.new(tex_node.outputs['Color'], shader_node.inputs['Emission Color']) + shader_node.inputs['Emission Strength'].default_value = props.emission_strength + elif props.shader_connection == 'ALPHA': + links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha']) + # Also connect color for visibility + links.new(tex_node.outputs['Color'], shader_node.inputs['Base Color']) + mat.blend_method = 'BLEND' + + # Connect alpha if enabled + if props.use_alpha: + links.new(tex_node.outputs['Alpha'], shader_node.inputs['Alpha']) + mat.blend_method = 'BLEND' + + # Add normal map nodes if normal map was generated + if normal_map_blender_img: + # Create normal map texture node + normal_tex_node = nodes.new(type='ShaderNodeTexImage') + normal_tex_node.image = normal_map_blender_img + normal_tex_node.location = (-200, -300) + normal_tex_node.label = "Normal Map" + + # Create normal map node + normal_map_node = nodes.new(type='ShaderNodeNormalMap') + normal_map_node.location = (0, -300) + + # Connect normal map texture to normal map node + links.new(normal_tex_node.outputs['Color'], normal_map_node.inputs['Color']) + + # Connect normal map node to shader + links.new(normal_map_node.outputs['Normal'], shader_node.inputs['Normal']) + + print(f"[Normal Map] Added normal map nodes to Principled BSDF shader") + + # Handle shadow/reflection disabling + current_shader = shader_node + final_x_location = 400 + + # Disable shadows if enabled + if props.disable_shadows: + # Create light path node + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 150) + + # Create transparent BSDF + transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + transparent_node.location = (200, 150) + + # Create mix shader for shadows + shadow_mix_node = nodes.new(type='ShaderNodeMixShader') + shadow_mix_node.location = (350, 50) + + # Connect light path "Is Shadow Ray" to mix factor + links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) + + # Mix principled BSDF with transparent BSDF + links.new(current_shader.outputs['BSDF'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) + links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) + + current_shader = shadow_mix_node + final_x_location = 500 + + # Disable reflections if enabled + if props.disable_reflections: + # Create or reuse light path node + if not props.disable_shadows: + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 200) + + # Create transparent BSDF for reflections + reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + reflection_transparent_node.location = (200, 200) + + # Create mix shader for reflections + reflection_mix_node = nodes.new(type='ShaderNodeMixShader') + reflection_mix_node.location = (final_x_location - 50, 100) + + # Connect light path "Is Reflection Ray" to mix factor + links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + links.new(current_shader.outputs['Shader' if props.disable_shadows else 'BSDF'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) + links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) + + current_shader = reflection_mix_node + final_x_location = final_x_location + 50 + + # Disable backfacing if enabled + if props.disable_backfacing: + # Create or reuse light path node + if not props.disable_shadows and not props.disable_reflections: + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 250) + + # Create geometry node for backfacing detection + geometry_node = nodes.new(type='ShaderNodeNewGeometry') + geometry_node.location = (50, 300) + + # Create transparent BSDF for backfaces + backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + backface_transparent_node.location = (200, 250) + + # Create mix shader for backfaces + backface_mix_node = nodes.new(type='ShaderNodeMixShader') + backface_mix_node.location = (final_x_location - 50, 150) + + # Connect geometry "Backfacing" to mix factor + links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + current_output = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF' + links.new(current_shader.outputs[current_output], backface_mix_node.inputs[1]) # Shader 1 (front face) + links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface) + + current_shader = backface_mix_node + final_x_location = final_x_location + 50 + + # Update output node position + output_node.location = (final_x_location, 0) + + # Connect final shader to output + output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'BSDF' + links.new(current_shader.outputs[output_socket], output_node.inputs['Surface']) + + elif props.shader_type == 'EMISSION': + shader_node = nodes.new(type='ShaderNodeEmission') + shader_node.location = (200, 0) + + # Create texture node + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.image = blender_img + tex_node.location = (-200, 0) + + # Connect texture to emission + links.new(tex_node.outputs['Color'], shader_node.inputs['Color']) + shader_node.inputs['Strength'].default_value = props.emission_strength + + # Handle shadow/reflection disabling + current_shader = shader_node + final_x_location = 400 + + # Disable shadows if enabled + if props.disable_shadows: + # Create light path node + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 150) + + # Create transparent BSDF + transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + transparent_node.location = (200, 150) + + # Create mix shader for shadows + shadow_mix_node = nodes.new(type='ShaderNodeMixShader') + shadow_mix_node.location = (350, 50) + + # Connect light path "Is Shadow Ray" to mix factor + links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) + + # Mix emission with transparent BSDF + links.new(current_shader.outputs['Emission'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) + links.new(transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) + + current_shader = shadow_mix_node + final_x_location = 500 + + # Disable reflections if enabled + if props.disable_reflections: + # Create or reuse light path node + if not props.disable_shadows: + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 200) + + # Create transparent BSDF for reflections + reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + reflection_transparent_node.location = (200, 200) + + # Create mix shader for reflections + reflection_mix_node = nodes.new(type='ShaderNodeMixShader') + reflection_mix_node.location = (final_x_location - 50, 100) + + # Connect light path "Is Reflection Ray" to mix factor + links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + links.new(current_shader.outputs['Shader' if props.disable_shadows else 'Emission'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) + links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) + + current_shader = reflection_mix_node + final_x_location = final_x_location + 50 + + # Update output node position + output_node.location = (final_x_location, 0) + + # Connect final shader to output + output_socket = 'Shader' if (props.disable_shadows or props.disable_reflections) else 'Emission' + links.new(current_shader.outputs[output_socket], output_node.inputs['Surface']) + + elif props.shader_type == 'TRANSPARENT': + shader_node = nodes.new(type='ShaderNodeBsdfTransparent') + mix_node = nodes.new(type='ShaderNodeMixShader') + principled_node = nodes.new(type='ShaderNodeBsdfPrincipled') + + shader_node.location = (200, 100) + mix_node.location = (350, 0) + principled_node.location = (200, -100) + + # Create texture node + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.image = blender_img + tex_node.location = (-200, 0) + + # Connect texture + links.new(tex_node.outputs['Color'], principled_node.inputs['Base Color']) + links.new(tex_node.outputs['Alpha'], mix_node.inputs['Fac']) + + # Connect shaders to mix + links.new(shader_node.outputs['BSDF'], mix_node.inputs[1]) + links.new(principled_node.outputs['BSDF'], mix_node.inputs[2]) + + # Handle shadow/reflection disabling + current_shader = mix_node + final_x_location = 500 + + # Disable shadows if enabled + if props.disable_shadows: + # Create light path node + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 200) + + # Create transparent BSDF for shadows (reuse existing transparent node) + shadow_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + shadow_transparent_node.location = (350, 150) + + # Create mix shader for shadows + shadow_mix_node = nodes.new(type='ShaderNodeMixShader') + shadow_mix_node.location = (500, 50) + + # Connect light path "Is Shadow Ray" to mix factor + links.new(light_path_node.outputs['Is Shadow Ray'], shadow_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + links.new(current_shader.outputs['Shader'], shadow_mix_node.inputs[1]) # Shader 1 (not shadow) + links.new(shadow_transparent_node.outputs['BSDF'], shadow_mix_node.inputs[2]) # Shader 2 (shadow) + + current_shader = shadow_mix_node + final_x_location = 650 + + # Disable reflections if enabled + if props.disable_reflections: + # Create or reuse light path node + if not props.disable_shadows: + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 250) + + # Create transparent BSDF for reflections + reflection_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + reflection_transparent_node.location = (350, 200) + + # Create mix shader for reflections + reflection_mix_node = nodes.new(type='ShaderNodeMixShader') + reflection_mix_node.location = (final_x_location - 50, 100) + + # Connect light path "Is Reflection Ray" to mix factor + links.new(light_path_node.outputs['Is Reflection Ray'], reflection_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + links.new(current_shader.outputs['Shader'], reflection_mix_node.inputs[1]) # Shader 1 (not reflection) + links.new(reflection_transparent_node.outputs['BSDF'], reflection_mix_node.inputs[2]) # Shader 2 (reflection) + + current_shader = reflection_mix_node + final_x_location = final_x_location + 50 + + # Disable backfacing if enabled + if props.disable_backfacing: + # Create or reuse light path node + if not props.disable_shadows and not props.disable_reflections: + light_path_node = nodes.new(type='ShaderNodeLightPath') + light_path_node.location = (50, 300) + + # Create geometry node for backfacing detection + geometry_node = nodes.new(type='ShaderNodeNewGeometry') + geometry_node.location = (50, 350) + + # Create transparent BSDF for backfaces + backface_transparent_node = nodes.new(type='ShaderNodeBsdfTransparent') + backface_transparent_node.location = (350, 300) + + # Create mix shader for backfaces + backface_mix_node = nodes.new(type='ShaderNodeMixShader') + backface_mix_node.location = (final_x_location + 50, 150) + + # Connect geometry "Backfacing" to mix factor + links.new(geometry_node.outputs['Backfacing'], backface_mix_node.inputs['Fac']) + + # Mix current shader with transparent BSDF + links.new(current_shader.outputs['Shader'], backface_mix_node.inputs[1]) # Shader 1 (front face) + links.new(backface_transparent_node.outputs['BSDF'], backface_mix_node.inputs[2]) # Shader 2 (backface) + + current_shader = backface_mix_node + final_x_location = final_x_location + 150 + + # Update output node position + output_node.location = (final_x_location, 0) + + # Connect final shader to output + links.new(current_shader.outputs['Shader'], output_node.inputs['Surface']) + + mat.blend_method = 'BLEND' + + # Assign material to active object if enabled and possible + if props.auto_assign_material and context.active_object and context.active_object.type in ['MESH', 'CURVE']: + obj = context.active_object + # Always create a new material slot + obj.data.materials.append(mat) + + self.report({'INFO'}, f"Generated shader '{material_name}' and assigned to {obj.name}") + else: + self.report({'INFO'}, f"Generated shader '{material_name}' (assign manually to object)") + + # Switch to Shader Editor if available and set material + for area in context.screen.areas: + if area.type == 'NODE_EDITOR': + for space in area.spaces: + if space.type == 'NODE_EDITOR': + space.shader_type = 'OBJECT' + space.id = mat + area.tag_redraw() + break + + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Shader generation failed: {str(e)}") + import traceback + traceback.print_exc() + return {'CANCELLED'} + class TEXT_TEXTURE_OT_refresh_preview(Operator): """Force refresh the preview""" bl_idname = "text_texture.refresh_preview" @@ -1599,15 +2562,25 @@ class TEXT_TEXTURE_OT_save_preset(Operator): default=False ) + preset_name: bpy.props.StringProperty( + name="Preset Name", + description="Name of preset to save/overwrite", + default="" + ) + def execute(self, context): props = context.scene.text_texture_props - # Validate preset name - preset_name = props.new_preset_name.strip() + # Use provided preset name or fallback to input field + preset_name = self.preset_name.strip() if self.preset_name else props.new_preset_name.strip() if not preset_name: self.report({'ERROR'}, "Please enter a preset name") return {'CANCELLED'} + # When overwriting from button, update the input field for consistency + if self.preset_name and self.preset_name.strip(): + props.new_preset_name = preset_name + # Validate preset name characters (avoid filesystem issues) import re # Allow Unicode letters, numbers, spaces, hyphens, underscores, and common accented characters @@ -1636,6 +2609,18 @@ class TEXT_TEXTURE_OT_save_preset(Operator): self.report({'ERROR'}, f"Preset '{preset_name}' already exists. Suggestion: try '{suggested_name}' or delete the existing preset first.") return {'CANCELLED'} + # DETAILED LOGGING: Current shader properties before saving + print(f"[PRESET SAVE DEBUG] ==================== SAVING PRESET: '{preset_name}' ====================") + print(f"[PRESET SAVE DEBUG] Current shader properties from props:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {props.disable_shadows}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {props.disable_reflections}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {props.disable_backfacing}") + print(f"[PRESET SAVE DEBUG] shader_type: {props.shader_type}") + print(f"[PRESET SAVE DEBUG] shader_connection: {props.shader_connection}") + print(f"[PRESET SAVE DEBUG] use_alpha: {props.use_alpha}") + print(f"[PRESET SAVE DEBUG] emission_strength: {props.emission_strength}") + print(f"[PRESET SAVE DEBUG] auto_assign_material: {props.auto_assign_material}") + preset_data = { 'text': props.text, 'texture_width': props.texture_width, @@ -1654,9 +2639,27 @@ class TEXT_TEXTURE_OT_save_preset(Operator): 'prepend_margin': props.prepend_margin, 'append_margin': props.append_margin, 'prepend_append_layout': props.prepend_append_layout, + 'shader_type': props.shader_type, + 'shader_connection': props.shader_connection, + 'use_alpha': props.use_alpha, + 'emission_strength': props.emission_strength, + 'auto_assign_material': props.auto_assign_material, + 'disable_shadows': props.disable_shadows, + 'disable_reflections': props.disable_reflections, + 'disable_backfacing': props.disable_backfacing, + 'generate_normal_map': props.generate_normal_map, + 'normal_map_strength': props.normal_map_strength, + 'normal_map_blur_radius': props.normal_map_blur_radius, + 'normal_map_invert': props.normal_map_invert, 'image_overlays': [] } + # DETAILED LOGGING: Verify shader properties in preset_data + print(f"[PRESET SAVE DEBUG] Shader properties in preset_data dictionary:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + # Save overlay data for overlay in props.image_overlays: overlay_data = { @@ -1672,8 +2675,35 @@ class TEXT_TEXTURE_OT_save_preset(Operator): preset_data['image_overlays'].append(overlay_data) try: - with open(preset_file, 'w') as f: - json.dump(preset_data, f, indent=2) + # Primary: Save to blend file (new system) + print(f"[PRESET SAVE DEBUG] Attempting to save to blend file...") + blend_success = save_blend_file_preset(preset_name, preset_data) + print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}") + + # ALWAYS save to local file for cross-blend-file sharing + print(f"[PRESET SAVE DEBUG] Attempting to save to local file: {preset_file}") + local_success = True + try: + with open(preset_file, 'w') as f: + json.dump(preset_data, f, indent=2) + print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to local storage for cross-file sharing") + + # VERIFY: Read back the local file to confirm shader properties were saved + print(f"[PRESET SAVE DEBUG] Verifying local file contents...") + with open(preset_file, 'r') as f: + saved_data = json.load(f) + print(f"[PRESET SAVE DEBUG] Local file shader properties verification:") + print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}") + + except Exception as local_error: + print(f"[PRESET SAVE DEBUG] Warning: Failed to save local preset file: {local_error}") + local_success = False + + if not blend_success and not local_success: + self.report({'ERROR'}, f"Failed to save preset to both blend file and local storage") + return {'CANCELLED'} # Check if preset already exists in the collection and update it existing_preset = None @@ -1685,15 +2715,27 @@ class TEXT_TEXTURE_OT_save_preset(Operator): if not existing_preset: preset = props.presets.add() preset.name = preset_name + preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_FILE' + else: + # Update source based on where it was successfully saved + existing_preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_FILE' - # Clear the input field - props.new_preset_name = "New Preset" + # Don't clear the input field - keep the name for easy re-saving + # props.new_preset_name = "New Preset" # Provide clear feedback + storage_info = "" + if blend_success and local_success: + storage_info = " (saved to blend file + local backup)" + elif blend_success: + storage_info = " (saved to blend file)" + elif local_success: + storage_info = " (saved locally only)" + if self.overwrite: - self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully") + self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}") else: - self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully") + self.report({'INFO'}, f"✅ Preset '{preset_name}' saved successfully{storage_info}") except (OSError, IOError, json.JSONEncodeError) as e: self.report({'ERROR'}, f"Failed to save preset: {str(e)}") @@ -1745,18 +2787,57 @@ class TEXT_TEXTURE_OT_load_preset(Operator): def execute(self, context): props = context.scene.text_texture_props + # DETAILED LOGGING: Current shader properties before loading + print(f"[PRESET LOAD DEBUG] ==================== LOADING PRESET: '{self.preset_name}' ====================") + print(f"[PRESET LOAD DEBUG] Current shader properties BEFORE loading:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing}") + print(f"[PRESET LOAD DEBUG] shader_type: {props.shader_type}") + print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}") + try: - preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") - if not os.path.exists(preset_file): - self.report({'ERROR'}, f"Preset file not found: {self.preset_name}") + # First try to load from blend file + blend_presets = get_blend_file_presets() + preset_data = None + source = 'BLEND_FILE' + + print(f"[PRESET LOAD DEBUG] Available blend file presets: {list(blend_presets.keys())}") + + if self.preset_name in blend_presets: + preset_data = blend_presets[self.preset_name] + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file") + print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + # Fallback to local file + preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") + print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying local file: {preset_file}") + if os.path.exists(preset_file): + with open(preset_file, 'r') as f: + preset_data = json.load(f) + source = 'LOCAL_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from local file") + print(f"[PRESET LOAD DEBUG] Local file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[PRESET LOAD DEBUG] Local file does not exist: {preset_file}") + self.report({'ERROR'}, f"Preset not found in blend file or local storage: {self.preset_name}") + return {'CANCELLED'} + + if not preset_data: + print(f"[PRESET LOAD DEBUG] No preset data found!") + self.report({'ERROR'}, f"No data found for preset: {self.preset_name}") return {'CANCELLED'} - - with open(preset_file, 'r') as f: - preset_data = json.load(f) props.is_updating = True - props.text = preset_data.get('text', props.text) + # Skip loading text to prevent overwriting current text + # props.text = preset_data.get('text', props.text) props.texture_width = preset_data.get('texture_width', props.texture_width) props.texture_height = preset_data.get('texture_height', props.texture_height) props.font_size = preset_data.get('font_size', props.font_size) @@ -1774,6 +2855,63 @@ class TEXT_TEXTURE_OT_load_preset(Operator): props.append_margin = preset_data.get('append_margin', props.append_margin) props.prepend_append_layout = preset_data.get('prepend_append_layout', props.prepend_append_layout) + # DETAILED LOGGING: Shader property loading with before/after values + print(f"[PRESET LOAD DEBUG] Loading shader properties...") + + old_shader_type = props.shader_type + props.shader_type = preset_data.get('shader_type', props.shader_type) + print(f"[PRESET LOAD DEBUG] shader_type: {old_shader_type} -> {props.shader_type}") + + old_shader_connection = props.shader_connection + props.shader_connection = preset_data.get('shader_connection', props.shader_connection) + print(f"[PRESET LOAD DEBUG] shader_connection: {old_shader_connection} -> {props.shader_connection}") + + old_use_alpha = props.use_alpha + props.use_alpha = preset_data.get('use_alpha', props.use_alpha) + print(f"[PRESET LOAD DEBUG] use_alpha: {old_use_alpha} -> {props.use_alpha}") + + old_emission_strength = props.emission_strength + props.emission_strength = preset_data.get('emission_strength', props.emission_strength) + print(f"[PRESET LOAD DEBUG] emission_strength: {old_emission_strength} -> {props.emission_strength}") + + old_auto_assign = props.auto_assign_material + props.auto_assign_material = preset_data.get('auto_assign_material', props.auto_assign_material) + print(f"[PRESET LOAD DEBUG] auto_assign_material: {old_auto_assign} -> {props.auto_assign_material}") + + # CRITICAL SHADER PROPERTIES - Most detailed logging + print(f"[PRESET LOAD DEBUG] ==================== CRITICAL SHADER PROPERTIES ====================") + + old_disable_shadows = props.disable_shadows + new_disable_shadows = preset_data.get('disable_shadows', props.disable_shadows) + props.disable_shadows = new_disable_shadows + print(f"[PRESET LOAD DEBUG] disable_shadows:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_shadows} (type: {type(old_disable_shadows)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_shadows', 'NOT FOUND')} (type: {type(preset_data.get('disable_shadows', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_shadows} (type: {type(props.disable_shadows)})") + + old_disable_reflections = props.disable_reflections + new_disable_reflections = preset_data.get('disable_reflections', props.disable_reflections) + props.disable_reflections = new_disable_reflections + print(f"[PRESET LOAD DEBUG] disable_reflections:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_reflections} (type: {type(old_disable_reflections)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_reflections', 'NOT FOUND')} (type: {type(preset_data.get('disable_reflections', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_reflections} (type: {type(props.disable_reflections)})") + + old_disable_backfacing = props.disable_backfacing + new_disable_backfacing = preset_data.get('disable_backfacing', props.disable_backfacing) + props.disable_backfacing = new_disable_backfacing + print(f"[PRESET LOAD DEBUG] disable_backfacing:") + print(f"[PRESET LOAD DEBUG] OLD VALUE: {old_disable_backfacing} (type: {type(old_disable_backfacing)})") + print(f"[PRESET LOAD DEBUG] PRESET DATA: {preset_data.get('disable_backfacing', 'NOT FOUND')} (type: {type(preset_data.get('disable_backfacing', 'N/A'))})") + print(f"[PRESET LOAD DEBUG] NEW VALUE: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") + + print(f"[PRESET LOAD DEBUG] ================================================================") + + props.generate_normal_map = preset_data.get('generate_normal_map', props.generate_normal_map) + props.normal_map_strength = preset_data.get('normal_map_strength', props.normal_map_strength) + props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius) + props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert) + # Load overlay data props.image_overlays.clear() overlay_data_list = preset_data.get('image_overlays', []) @@ -1789,10 +2927,20 @@ class TEXT_TEXTURE_OT_load_preset(Operator): overlay.enabled = overlay_data.get('enabled', True) props.is_updating = False + + # FINAL VERIFICATION: Check what the properties actually are after loading + print(f"[PRESET LOAD DEBUG] ==================== FINAL VERIFICATION ====================") + print(f"[PRESET LOAD DEBUG] Properties AFTER loading preset '{self.preset_name}':") + print(f"[PRESET LOAD DEBUG] disable_shadows: {props.disable_shadows} (type: {type(props.disable_shadows)})") + print(f"[PRESET LOAD DEBUG] disable_reflections: {props.disable_reflections} (type: {type(props.disable_reflections)})") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {props.disable_backfacing} (type: {type(props.disable_backfacing)})") + print(f"[PRESET LOAD DEBUG] ================================================================") + # Always generate preview after loading preset generate_preview(context) - self.report({'INFO'}, f"Loaded: {self.preset_name}") + source_info = "from blend file" if source == 'BLEND_FILE' else "from local storage" + self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}") except (OSError, IOError) as e: props.is_updating = False @@ -1822,18 +2970,32 @@ class TEXT_TEXTURE_OT_delete_preset(Operator): props = context.scene.text_texture_props try: + # Delete from both blend file and local storage + blend_deleted = delete_blend_file_preset(self.preset_name) + preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") + local_deleted = False if os.path.exists(preset_file): os.remove(preset_file) - else: - self.report({'WARNING'}, f"Preset file not found: {self.preset_name}") + local_deleted = True + if not blend_deleted and not local_deleted: + self.report({'WARNING'}, f"Preset '{self.preset_name}' not found in blend file or local storage") + + # Remove from UI list for i, preset in enumerate(props.presets): if preset.name == self.preset_name: props.presets.remove(i) break - self.report({'INFO'}, f"🗑️ Deleted preset: {self.preset_name}") + delete_info = [] + if blend_deleted: + delete_info.append("blend file") + if local_deleted: + delete_info.append("local storage") + + location_text = " and ".join(delete_info) if delete_info else "nowhere (not found)" + self.report({'INFO'}, f"🗑️ Deleted preset '{self.preset_name}' from {location_text}") except (OSError, IOError) as e: self.report({'ERROR'}, f"Failed to delete preset file: {str(e)}") @@ -1896,6 +3058,36 @@ class TEXT_TEXTURE_OT_refresh_fonts(Operator): self.report({'INFO'}, f"Found {len(get_font_enum_items.cached_fonts)} fonts") return {'FINISHED'} +class TEXT_TEXTURE_OT_refresh_presets(Operator): + """Refresh presets from blend file and local storage""" + bl_idname = "text_texture.refresh_presets" + bl_label = "Refresh Presets" + bl_description = "Reload presets from blend file and local storage" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + # Reload presets + initialize_presets() + + props = context.scene.text_texture_props + blend_presets = get_blend_file_presets() + total_presets = len(props.presets) + blend_count = len(blend_presets) + local_count = total_presets - blend_count + + self.report({'INFO'}, f"✅ Refreshed {total_presets} presets ({blend_count} from blend file, {local_count} local)") + + # Force UI redraw + for area in context.screen.areas: + area.tag_redraw() + + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Failed to refresh presets: {str(e)}") + return {'CANCELLED'} + class TEXT_TEXTURE_OT_add_overlay(Operator): """Add new image overlay""" bl_idname = "text_texture.add_overlay" @@ -2163,12 +3355,11 @@ def draw_presets_section(layout, props): save_row.prop(props, "new_preset_name", text="", placeholder="Preset Name") save_row.operator("text_texture.save_preset", text="Save", icon='FILE_NEW') - # Status info - if props.new_preset_name and props.new_preset_name.strip() != "New Preset": - preset_file = os.path.join(get_preset_path(), f"{props.new_preset_name.strip()}.json") - if os.path.exists(preset_file): - status_row = layout.row() - status_row.label(text="⚠️ Preset exists - will prompt to overwrite", icon='INFO') + layout.separator() + + # Add refresh button at the top of presets section + refresh_row = layout.row(align=True) + refresh_row.operator("text_texture.refresh_presets", text="Refresh Presets", icon='FILE_REFRESH') layout.separator() @@ -2176,16 +3367,146 @@ def draw_presets_section(layout, props): if props.presets: layout.label(text=f"📁 Saved Presets ({len(props.presets)}):", icon='PRESET') - for i, preset in enumerate(props.presets): - row = layout.row() - load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") - load_btn.preset_name = preset.name - - delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') - delete_btn.preset_name = preset.name + # Separate presets by source + blend_presets = [p for p in props.presets if p.source == 'BLEND_FILE'] + local_presets = [p for p in props.presets if p.source == 'LOCAL_FILE'] + + # Show blend file presets first + if blend_presets: + layout.label(text="🎯 Blend File Presets (travel with file):", icon='FILE_BLEND') + for preset in blend_presets: + row = layout.row() + load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") + load_btn.preset_name = preset.name + + # Quick overwrite button + overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH') + overwrite_btn.overwrite = True + # Set the preset name so it can be overwritten + overwrite_btn.preset_name = preset.name + + delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') + delete_btn.preset_name = preset.name + + # Show local presets second + if local_presets: + if blend_presets: + layout.separator() + layout.label(text="💻 Local Presets (this machine only):", icon='DISK_DRIVE') + for preset in local_presets: + row = layout.row() + load_btn = row.operator("text_texture.load_preset", text=f"📋 {preset.name}") + load_btn.preset_name = preset.name + + # Quick overwrite button + overwrite_btn = row.operator("text_texture.save_preset", text="", icon='FILE_REFRESH') + overwrite_btn.overwrite = True + # Set the preset name so it can be overwritten + overwrite_btn.preset_name = preset.name + + delete_btn = row.operator("text_texture.delete_preset", text="", icon='TRASH') + delete_btn.preset_name = preset.name else: layout.label(text="📂 No presets saved yet", icon='INFO') layout.label(text="💡 Save your first preset above!", icon='LIGHT_DATA') + layout.separator() + info_box = layout.box() + info_box.label(text="ℹ️ Presets are now saved in the blend file", icon='INFO') + info_box.label(text="They will travel with your .blend file!") + +def draw_shader_section(layout, props): + """Draw shader generation controls""" + # Create Shader Button (as requested) + row = layout.row() + row.scale_y = 1.5 + row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') + + layout.separator() + + # Clean minimalist controls + col = layout.column(align=True) + col.prop(props, "shader_type", text="Type") + + # Show relevant options based on shader type + if props.shader_type == 'PRINCIPLED': + col.prop(props, "shader_connection", text="Connect") + if props.shader_connection == 'EMISSION': + col.prop(props, "emission_strength", text="Strength") + elif props.shader_type == 'EMISSION': + col.prop(props, "emission_strength", text="Strength") + + layout.separator() + + # Simple toggles + col = layout.column(align=True) + col.prop(props, "use_alpha", text="Use Alpha Channel") + col.prop(props, "auto_assign_material", text="Auto-Assign to Object") + + # Clean status info + if props.auto_assign_material: + layout.separator() + info_row = layout.row() + info_row.scale_y = 0.8 + try: + context = bpy.context + if context and context.active_object and context.active_object.type in ['MESH', 'CURVE']: + info_row.label(text=f"→ {context.active_object.name}", icon='CHECKMARK') + else: + info_row.label(text="Select mesh/curve object", icon='INFO') + except: + info_row.label(text="Select mesh/curve object", icon='INFO') + +def draw_shader_options_only(layout, props): + """Draw only shader options without the Create button - for collapsible section""" + # Clean grid layout for shader options + grid = layout.grid_flow(row_major=True, columns=2, align=True) + + grid.prop(props, "shader_type", text="Type") + + if props.shader_type == 'PRINCIPLED': + grid.prop(props, "shader_connection", text="Connect") + if props.shader_connection == 'EMISSION': + grid.prop(props, "emission_strength", text="Strength") + elif props.shader_type == 'EMISSION': + grid.prop(props, "emission_strength", text="Strength") + + # Simple row for toggles + layout.separator() + row = layout.row(align=True) + row.prop(props, "use_alpha", toggle=True) + row.prop(props, "auto_assign_material", toggle=True) + + # Shadow, reflection, and backfacing toggles + row = layout.row(align=True) + row.prop(props, "disable_shadows", toggle=True) + row.prop(props, "disable_reflections", toggle=True) + row.prop(props, "disable_backfacing", toggle=True) + +def draw_normal_maps_section(layout, props): + """Draw normal map generation controls""" + # Main toggle for normal map generation + layout.prop(props, "generate_normal_map", text="Generate Normal Map", icon='TEXTURE') + + # Show controls only if normal map generation is enabled + if props.generate_normal_map: + layout.separator() + + # Normal map settings in a clean layout + col = layout.column(align=True) + col.prop(props, "normal_map_strength", text="Strength", slider=True) + col.prop(props, "normal_map_blur_radius", text="Blur Radius", slider=True) + + layout.separator() + layout.prop(props, "normal_map_invert", text="Invert (Emboss ↔ Deboss)") + + # Info box with usage tips + layout.separator() + info_box = layout.box() + info_box.scale_y = 0.8 + info_box.label(text="💡 Normal maps add depth and detail", icon='INFO') + info_box.label(text="• Higher strength = more pronounced effect") + info_box.label(text="• Blur smooths sharp edges") + info_box.label(text="• Invert changes raised/recessed effect") # ============================================================================ # MENUS @@ -2256,17 +3577,30 @@ class TEXT_TEXTURE_PT_panel(Panel): # Generate Button - layout.separator() + layout.separator(factor=0.5) draw_generate_button(layout) - # Preview Button at top level - layout.separator() - layout.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') + # CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED + layout.separator(factor=0.5) + create_shader_row = layout.row() + create_shader_row.scale_y = 1.5 + create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') - layout.separator() + # Preview Button at top level + layout.separator(factor=0.5) + preview_row = layout.row() + preview_row.scale_y = 1.5 + preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') + + layout.separator(factor=1.2) # COLLAPSIBLE SECTIONS + # Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON + shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS') + if shader_section: + draw_shader_options_only(shader_section, props) + # Image Overlays Section (collapsed by default) overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') if overlay_section: @@ -2282,6 +3616,11 @@ class TEXT_TEXTURE_PT_panel(Panel): if font_section: draw_font_settings_section(font_section, props) + # Normal Maps Section (collapsed by default) + normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') + if normal_maps_section: + draw_normal_maps_section(normal_maps_section, props) + # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: @@ -2314,17 +3653,30 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): # Generate Button - layout.separator() + layout.separator(factor=0.5) draw_generate_button(layout) - # Preview Button at top level - layout.separator() - layout.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') + # CREATE SHADER BUTTON AT TOP LEVEL - AS REQUESTED + layout.separator(factor=0.5) + create_shader_row = layout.row() + create_shader_row.scale_y = 1.5 + create_shader_row.operator("text_texture.generate_shader", text="Create Shader", icon='MATERIAL') - layout.separator() + # Preview Button at top level + layout.separator(factor=0.5) + preview_row = layout.row() + preview_row.scale_y = 1.5 + preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') + + layout.separator(factor=1.2) # COLLAPSIBLE SECTIONS + # Shader Section (collapsed by default) - ONLY OPTIONS, NOT THE BUTTON + shader_section = draw_collapsible_header(layout, "expand_shader", "Shader Options", 'SETTINGS') + if shader_section: + draw_shader_options_only(shader_section, props) + # Image Overlays Section (collapsed by default) overlay_section = draw_collapsible_header(layout, "expand_image_overlays", "Image Overlays", 'IMAGE_DATA') if overlay_section: @@ -2340,6 +3692,11 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): if font_section: draw_font_settings_section(font_section, props) + # Normal Maps Section (collapsed by default) + normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') + if normal_maps_section: + draw_normal_maps_section(normal_maps_section, props) + # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: @@ -2372,14 +3729,16 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): # Generate Button - layout.separator() + layout.separator(factor=0.5) draw_generate_button(layout) # Preview Button at top level - layout.separator() - layout.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') + layout.separator(factor=0.5) + preview_row = layout.row() + preview_row.scale_y = 1.5 + preview_row.operator("text_texture.view_preview_zoom", text="Open Preview Window", icon='WINDOW') - layout.separator() + layout.separator(factor=1.2) # COLLAPSIBLE SECTIONS @@ -2398,6 +3757,11 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): if font_section: draw_font_settings_section(font_section, props) + # Normal Maps Section (collapsed by default) + normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') + if normal_maps_section: + draw_normal_maps_section(normal_maps_section, props) + # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: @@ -2417,6 +3781,7 @@ classes = ( TEXT_TEXTURE_Preset, TEXT_TEXTURE_Properties, TEXT_TEXTURE_OT_generate, + TEXT_TEXTURE_OT_generate_shader, TEXT_TEXTURE_OT_refresh_preview, TEXT_TEXTURE_OT_view_preview_zoom, TEXT_TEXTURE_OT_add_overlay, @@ -2429,6 +3794,7 @@ classes = ( TEXT_TEXTURE_OT_load_preset, TEXT_TEXTURE_OT_delete_preset, TEXT_TEXTURE_OT_refresh_fonts, + TEXT_TEXTURE_OT_refresh_presets, TEXT_TEXTURE_PT_panel, TEXT_TEXTURE_PT_panel_3d, TEXT_TEXTURE_PT_panel_properties, @@ -2477,6 +3843,24 @@ def register(): print("[TTG DEBUG] Registration completed - scheduling preset initialization") bpy.app.timers.register(delayed_preset_init, first_interval=0.1) + + # Add handler to reload presets when blend file changes + @bpy.app.handlers.persistent + def on_file_load(dummy): + """Reload presets when a blend file is loaded""" + try: + print("[TTG] Reloading presets after file load...") + def delayed_reload(): + initialize_presets() + return None # Stop timer + bpy.app.timers.register(delayed_reload, first_interval=0.1) + except Exception as e: + print(f"[TTG] Error reloading presets: {e}") + + # Register the file load handler + if on_file_load not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(on_file_load) + print("[TTG DEBUG] ========== ADDON REGISTRATION END ==========") def unregister(): @@ -2492,6 +3876,14 @@ def unregister(): if "TextTexturePreview" in bpy.data.images: bpy.data.images.remove(bpy.data.images["TextTexturePreview"]) + # Remove file load handler + try: + for handler in bpy.app.handlers.load_post[:]: + if handler.__name__ == 'on_file_load' and 'TTG' in str(handler): + bpy.app.handlers.load_post.remove(handler) + except Exception as e: + print(f"Error removing file load handler: {e}") + # Remove from menus bpy.types.NODE_MT_add.remove(menu_func_node_editor) bpy.types.VIEW3D_MT_add.remove(menu_func_view3d) diff --git a/test_positioning_system.py b/test_positioning_system.py deleted file mode 100644 index eee2d5d..0000000 --- a/test_positioning_system.py +++ /dev/null @@ -1,558 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test suite for the Text Texture Generator's enhanced positioning system. -This script validates the positioning calculations and identifies potential issues -without requiring Blender to be installed. -""" - -import sys -import traceback -from typing import Dict, Tuple, List, Any - -# Mock Blender properties for testing -class MockProps: - """Mock properties class to simulate Blender addon properties""" - def __init__(self): - # Enhanced positioning properties - self.anchor_point = 'MIDDLE_CENTER' - self.position_mode = 'PRESET' - self.margin_x = 0.0 - self.margin_y = 0.0 - self.margins_linked = False - self.offset_x = 0 - self.offset_y = 0 - self.manual_position_x = 50.0 - self.manual_position_y = 50.0 - self.manual_position_unit = 'PERCENTAGE' - self.constrain_to_canvas = True - - # Legacy properties for backward compatibility testing - self.text_align = 'center' - self.padding = 20 - -# Copy of the enhanced positioning function from the addon -def calculate_text_position(props, canvas_width, canvas_height, text_width, text_height): - """Calculate text position based on enhanced positioning properties""" - - if props.position_mode == 'MANUAL': - # Manual positioning mode - if props.manual_position_unit == 'PERCENTAGE': - x = int((props.manual_position_x / 100.0) * canvas_width) - y = int((props.manual_position_y / 100.0) * canvas_height) - else: # PIXELS - x = int(props.manual_position_x) - y = int(props.manual_position_y) - else: - # Preset anchor-based positioning - margin_x_px = int((props.margin_x / 100.0) * canvas_width) - margin_y_px = int((props.margin_y / 100.0) * canvas_height) - - # Calculate base position based on anchor point - if 'LEFT' in props.anchor_point: - base_x = margin_x_px - elif 'RIGHT' in props.anchor_point: - base_x = canvas_width - text_width - margin_x_px - else: # CENTER - base_x = (canvas_width - text_width) // 2 - - if 'TOP' in props.anchor_point: - base_y = margin_y_px - elif 'BOTTOM' in props.anchor_point: - base_y = canvas_height - text_height - margin_y_px - else: # MIDDLE - base_y = (canvas_height - text_height) // 2 - - # Apply fine-tuning offsets - x = base_x + props.offset_x - y = base_y + props.offset_y - - # Apply canvas constraints if enabled - if props.constrain_to_canvas: - x = max(0, min(x, canvas_width - text_width)) - y = max(0, min(y, canvas_height - text_height)) - - return x, y - -def sync_margin_values(props, changed_margin): - """Synchronize margin values when linked""" - if props.margins_linked: - if changed_margin == 'x': - props.margin_y = props.margin_x - elif changed_margin == 'y': - props.margin_x = props.margin_y - -def get_anchor_point_matrix(): - """Return 3x3 matrix of anchor point identifiers for UI layout""" - return [ - ['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'], - ['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'], - ['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT'] - ] - -class PositioningTestSuite: - """Comprehensive test suite for positioning system""" - - def __init__(self): - self.test_results = [] - self.issues_found = [] - self.canvas_width = 1024 - self.canvas_height = 1024 - self.text_width = 200 - self.text_height = 50 - - def log_test(self, test_name: str, passed: bool, details: str = "", expected=None, actual=None): - """Log test result""" - result = { - 'test': test_name, - 'passed': passed, - 'details': details, - 'expected': expected, - 'actual': actual - } - self.test_results.append(result) - - if not passed: - self.issues_found.append(f"❌ {test_name}: {details}") - if expected is not None and actual is not None: - self.issues_found.append(f" Expected: {expected}, Got: {actual}") - else: - print(f"✅ {test_name}") - - def test_anchor_point_calculations(self): - """Test all 9 anchor point calculations""" - print("\n=== Testing Anchor Point Calculations ===") - - anchor_matrix = get_anchor_point_matrix() - expected_positions = { - 'TOP_LEFT': (0, 0), - 'TOP_CENTER': (412, 0), # (1024-200)/2 = 412 - 'TOP_RIGHT': (824, 0), # 1024-200 = 824 - 'MIDDLE_LEFT': (0, 487), # (1024-50)/2 = 487 - 'MIDDLE_CENTER': (412, 487), - 'MIDDLE_RIGHT': (824, 487), - 'BOTTOM_LEFT': (0, 974), # 1024-50 = 974 - 'BOTTOM_CENTER': (412, 974), - 'BOTTOM_RIGHT': (824, 974) - } - - for row in anchor_matrix: - for anchor in row: - props = MockProps() - props.anchor_point = anchor - props.position_mode = 'PRESET' - props.margin_x = 0.0 - props.margin_y = 0.0 - props.offset_x = 0 - props.offset_y = 0 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - expected = expected_positions[anchor] - - if (x, y) == expected: - self.log_test(f"Anchor {anchor}", True, f"Position: ({x}, {y})") - else: - self.log_test(f"Anchor {anchor}", False, - f"Incorrect position calculation", expected, (x, y)) - - except Exception as e: - self.log_test(f"Anchor {anchor}", False, f"Exception: {str(e)}") - - def test_margin_calculations(self): - """Test margin calculations with various percentages""" - print("\n=== Testing Margin Calculations ===") - - test_cases = [ - (10.0, 10.0), # 10% margins - (25.0, 25.0), # 25% margins - (50.0, 50.0), # 50% margins (maximum) - (0.0, 0.0), # No margins - ] - - for margin_x, margin_y in test_cases: - props = MockProps() - props.anchor_point = 'TOP_LEFT' - props.position_mode = 'PRESET' - props.margin_x = margin_x - props.margin_y = margin_y - props.offset_x = 0 - props.offset_y = 0 - - expected_x = int((margin_x / 100.0) * self.canvas_width) - expected_y = int((margin_y / 100.0) * self.canvas_height) - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test(f"Margins {margin_x}%/{margin_y}%", True, f"Position: ({x}, {y})") - else: - self.log_test(f"Margins {margin_x}%/{margin_y}%", False, - f"Incorrect margin calculation", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test(f"Margins {margin_x}%/{margin_y}%", False, f"Exception: {str(e)}") - - def test_manual_positioning(self): - """Test manual positioning mode with percentage and pixel units""" - print("\n=== Testing Manual Positioning ===") - - # Test percentage mode - props = MockProps() - props.position_mode = 'MANUAL' - props.manual_position_unit = 'PERCENTAGE' - props.manual_position_x = 25.0 # 25% - props.manual_position_y = 75.0 # 75% - - expected_x = int((25.0 / 100.0) * self.canvas_width) # 256 - expected_y = int((75.0 / 100.0) * self.canvas_height) # 768 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test("Manual Percentage Mode", True, f"Position: ({x}, {y})") - else: - self.log_test("Manual Percentage Mode", False, - f"Incorrect calculation", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test("Manual Percentage Mode", False, f"Exception: {str(e)}") - - # Test pixel mode - props.manual_position_unit = 'PIXELS' - props.manual_position_x = 100.0 - props.manual_position_y = 200.0 - - expected_x = 100 - expected_y = 200 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test("Manual Pixel Mode", True, f"Position: ({x}, {y})") - else: - self.log_test("Manual Pixel Mode", False, - f"Incorrect calculation", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test("Manual Pixel Mode", False, f"Exception: {str(e)}") - - def test_canvas_constraints(self): - """Test canvas constraint functionality""" - print("\n=== Testing Canvas Constraints ===") - - # Test position that would go out of bounds - props = MockProps() - props.position_mode = 'MANUAL' - props.manual_position_unit = 'PIXELS' - props.manual_position_x = 1000.0 # Would put text partially outside (text_width=200) - props.manual_position_y = 1000.0 # Would put text partially outside (text_height=50) - props.constrain_to_canvas = True - - expected_x = self.canvas_width - self.text_width # 824 - expected_y = self.canvas_height - self.text_height # 974 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test("Canvas Constraints (constrained)", True, f"Position: ({x}, {y})") - else: - self.log_test("Canvas Constraints (constrained)", False, - f"Constraint failed", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test("Canvas Constraints (constrained)", False, f"Exception: {str(e)}") - - # Test with constraints disabled - props.constrain_to_canvas = False - expected_x = 1000 # Should not be constrained - expected_y = 1000 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test("Canvas Constraints (unconstrained)", True, f"Position: ({x}, {y})") - else: - self.log_test("Canvas Constraints (unconstrained)", False, - f"Should not be constrained", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test("Canvas Constraints (unconstrained)", False, f"Exception: {str(e)}") - - def test_offset_application(self): - """Test pixel offset fine-tuning""" - print("\n=== Testing Pixel Offset Application ===") - - props = MockProps() - props.anchor_point = 'MIDDLE_CENTER' - props.position_mode = 'PRESET' - props.margin_x = 0.0 - props.margin_y = 0.0 - props.offset_x = 50 # 50px right - props.offset_y = -25 # 25px up - - base_x = (self.canvas_width - self.text_width) // 2 # 412 - base_y = (self.canvas_height - self.text_height) // 2 # 487 - expected_x = base_x + props.offset_x # 462 - expected_y = base_y + props.offset_y # 462 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - if x == expected_x and y == expected_y: - self.log_test("Pixel Offset Application", True, f"Position: ({x}, {y})") - else: - self.log_test("Pixel Offset Application", False, - f"Offset not applied correctly", (expected_x, expected_y), (x, y)) - - except Exception as e: - self.log_test("Pixel Offset Application", False, f"Exception: {str(e)}") - - def test_margin_synchronization(self): - """Test margin link functionality""" - print("\n=== Testing Margin Synchronization ===") - - props = MockProps() - props.margins_linked = True - props.margin_x = 15.0 - props.margin_y = 10.0 - - # Test X margin sync - sync_margin_values(props, 'x') - if props.margin_y == props.margin_x: - self.log_test("Margin Sync (X to Y)", True, f"Y margin updated to {props.margin_y}") - else: - self.log_test("Margin Sync (X to Y)", False, - f"Y margin not synced", props.margin_x, props.margin_y) - - # Reset and test Y margin sync - props.margin_x = 20.0 - props.margin_y = 25.0 - sync_margin_values(props, 'y') - if props.margin_x == props.margin_y: - self.log_test("Margin Sync (Y to X)", True, f"X margin updated to {props.margin_x}") - else: - self.log_test("Margin Sync (Y to X)", False, - f"X margin not synced", props.margin_y, props.margin_x) - - # Test unlinked margins - props.margins_linked = False - original_x = props.margin_x - original_y = props.margin_y - sync_margin_values(props, 'x') - - if props.margin_x == original_x and props.margin_y == original_y: - self.log_test("Margin Sync (Unlinked)", True, "Margins unchanged when unlinked") - else: - self.log_test("Margin Sync (Unlinked)", False, - "Margins changed when they shouldn't") - - def test_edge_cases(self): - """Test edge cases and boundary conditions""" - print("\n=== Testing Edge Cases ===") - - # Test zero canvas dimensions - props = MockProps() - try: - x, y = calculate_text_position(props, 0, 0, self.text_width, self.text_height) - self.log_test("Zero Canvas Dimensions", True, f"Handled gracefully: ({x}, {y})") - except Exception as e: - self.log_test("Zero Canvas Dimensions", False, f"Exception: {str(e)}") - - # Test text larger than canvas - props = MockProps() - props.anchor_point = 'TOP_LEFT' - props.constrain_to_canvas = True - large_text_width = 2000 - large_text_height = 2000 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - large_text_width, large_text_height) - - if x <= 0 and y <= 0: - self.log_test("Text Larger Than Canvas", True, f"Constrained to: ({x}, {y})") - else: - self.log_test("Text Larger Than Canvas", False, - f"Not properly constrained: ({x}, {y})") - except Exception as e: - self.log_test("Text Larger Than Canvas", False, f"Exception: {str(e)}") - - # Test maximum margin values (50%) - props = MockProps() - props.anchor_point = 'TOP_LEFT' - props.margin_x = 50.0 - props.margin_y = 50.0 - - try: - x, y = calculate_text_position(props, self.canvas_width, self.canvas_height, - self.text_width, self.text_height) - - expected_x = int(0.5 * self.canvas_width) # 512 - expected_y = int(0.5 * self.canvas_height) # 512 - - if x == expected_x and y == expected_y: - self.log_test("Maximum Margins (50%)", True, f"Position: ({x}, {y})") - else: - self.log_test("Maximum Margins (50%)", False, - f"Incorrect calculation", (expected_x, expected_y), (x, y)) - except Exception as e: - self.log_test("Maximum Margins (50%)", False, f"Exception: {str(e)}") - - def analyze_ui_integration(self): - """Analyze UI integration potential issues""" - print("\n=== Analyzing UI Integration ===") - - issues = [] - - # Check anchor point matrix structure - matrix = get_anchor_point_matrix() - if len(matrix) != 3 or any(len(row) != 3 for row in matrix): - issues.append("❌ Anchor point matrix is not 3x3") - else: - print("✅ Anchor point matrix structure is correct") - - # Check all anchor points are defined - all_anchors = [anchor for row in matrix for anchor in row] - expected_anchors = [ - 'TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT', - 'MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT', - 'BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT' - ] - - missing_anchors = set(expected_anchors) - set(all_anchors) - if missing_anchors: - issues.append(f"❌ Missing anchor points: {missing_anchors}") - else: - print("✅ All required anchor points are defined") - - # Check for property type consistency - props = MockProps() - - # Test property ranges - if hasattr(props, 'margin_x') and hasattr(props, 'margin_y'): - print("✅ Margin properties exist") - else: - issues.append("❌ Margin properties missing") - - if hasattr(props, 'offset_x') and hasattr(props, 'offset_y'): - print("✅ Offset properties exist") - else: - issues.append("❌ Offset properties missing") - - self.issues_found.extend(issues) - - def run_all_tests(self): - """Run all tests and generate comprehensive report""" - print("🔍 ENHANCED POSITIONING SYSTEM TEST SUITE") - print("=" * 50) - - try: - self.test_anchor_point_calculations() - self.test_margin_calculations() - self.test_manual_positioning() - self.test_canvas_constraints() - self.test_offset_application() - self.test_margin_synchronization() - self.test_edge_cases() - self.analyze_ui_integration() - - except Exception as e: - print(f"❌ Critical error during testing: {e}") - traceback.print_exc() - - self.generate_report() - - def generate_report(self): - """Generate comprehensive test report""" - print("\n" + "=" * 60) - print("📊 COMPREHENSIVE TEST REPORT") - print("=" * 60) - - total_tests = len(self.test_results) - passed_tests = sum(1 for result in self.test_results if result['passed']) - failed_tests = total_tests - passed_tests - - print(f"📈 SUMMARY:") - print(f" Total Tests: {total_tests}") - print(f" ✅ Passed: {passed_tests}") - print(f" ❌ Failed: {failed_tests}") - print(f" Success Rate: {(passed_tests/total_tests*100):.1f}%" if total_tests > 0 else "No tests run") - - if self.issues_found: - print(f"\n🐛 ISSUES IDENTIFIED ({len(self.issues_found)}):") - for issue in self.issues_found: - print(f" {issue}") - - # Categorize issues by severity - critical_issues = [] - major_issues = [] - minor_issues = [] - - for result in self.test_results: - if not result['passed']: - if 'exception' in result['details'].lower(): - critical_issues.append(result) - elif 'constraint' in result['test'].lower() or 'calculation' in result['details'].lower(): - major_issues.append(result) - else: - minor_issues.append(result) - - if critical_issues: - print(f"\n🚨 CRITICAL ISSUES ({len(critical_issues)}):") - for issue in critical_issues: - print(f" • {issue['test']}: {issue['details']}") - - if major_issues: - print(f"\n⚠️ MAJOR ISSUES ({len(major_issues)}):") - for issue in major_issues: - print(f" • {issue['test']}: {issue['details']}") - - if minor_issues: - print(f"\n ℹ️ MINOR ISSUES ({len(minor_issues)}):") - for issue in minor_issues: - print(f" • {issue['test']}: {issue['details']}") - - # Recommendations - print(f"\n🎯 RECOMMENDATIONS:") - - if critical_issues: - print(" 1. Fix critical exceptions before deployment") - print(" 2. Add proper error handling for edge cases") - - if major_issues: - print(" 3. Review positioning calculation logic") - print(" 4. Test constraint functionality thoroughly") - - print(" 5. Add comprehensive unit tests to the addon") - print(" 6. Implement validation for property ranges") - print(" 7. Add user feedback for invalid input combinations") - - return { - 'total_tests': total_tests, - 'passed': passed_tests, - 'failed': failed_tests, - 'issues': self.issues_found, - 'critical_issues': len(critical_issues), - 'major_issues': len(major_issues), - 'minor_issues': len(minor_issues) - } - -if __name__ == "__main__": - print("🚀 Starting Enhanced Positioning System Test Suite...") - test_suite = PositioningTestSuite() - results = test_suite.run_all_tests() - - print(f"\n✨ Testing completed!") - print(f"Check the detailed report above for analysis and recommendations.") \ No newline at end of file diff --git a/test_preset_unicode.py b/test_preset_unicode.py deleted file mode 100644 index acaea6f..0000000 --- a/test_preset_unicode.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify that preset names with Unicode characters like 'ö' work correctly. -This script tests the validation logic outside of Blender. -""" - -import re -import json -import os -import tempfile - -def test_preset_name_validation(): - """Test the new preset name validation regex""" - - # Test cases with expected results - test_cases = [ - # Valid names (should pass) - ("Test Preset", True), - ("Mein schönes Preset", True), # German with ö - ("Préférence française", True), # French with accents - ("Configuración español", True), # Spanish with ñ - ("Test_123", True), - ("Hello-World", True), - ("Тест", True), # Cyrillic - ("テスト", True), # Japanese - ("测试", True), # Chinese - - # Invalid names (should fail) - ("TestPreset", False), # Contains > - ("Test:Preset", False), # Contains : - ("Test\"Preset", False), # Contains " - ("Test|Preset", False), # Contains | - ("Test?Preset", False), # Contains ? - ("Test*Preset", False), # Contains * - ("Test/Preset", False), # Contains / - ("Test\\Preset", False), # Contains \ - ("", False), # Empty string - ] - - print("Testing preset name validation...") - - for preset_name, should_pass in test_cases: - # Test the Unicode regex pattern - unicode_pattern_match = bool(re.match(r'^[\w\s\-]+$', preset_name, re.UNICODE)) - - # Test filesystem-unsafe characters - invalid_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\'] - has_invalid_chars = any(char in preset_name for char in invalid_chars) - - # Combined validation (same as in the actual code) - validation_passes = unicode_pattern_match and not has_invalid_chars and preset_name.strip() - - status = "✅ PASS" if validation_passes == should_pass else "❌ FAIL" - print(f"{status}: '{preset_name}' -> Expected: {should_pass}, Got: {validation_passes}") - - if validation_passes != should_pass: - print(f" Unicode match: {unicode_pattern_match}, Has invalid chars: {has_invalid_chars}") - -def test_json_serialization(): - """Test that Unicode characters work correctly with JSON serialization/deserialization""" - - print("\nTesting JSON serialization with Unicode characters...") - - test_data = { - 'name': 'Schönes Preset', - 'text': 'Höllö Wörld', - 'texture_width': 1024, - 'texture_height': 1024, - 'font_size': 96 - } - - try: - # Test JSON encoding/decoding - json_str = json.dumps(test_data, indent=2, ensure_ascii=False) - decoded_data = json.loads(json_str) - - print("✅ JSON serialization successful") - print(f"Original name: {test_data['name']}") - print(f"Decoded name: {decoded_data['name']}") - print(f"Names match: {test_data['name'] == decoded_data['name']}") - - # Test file I/O - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: - json.dump(test_data, f, indent=2, ensure_ascii=False) - temp_file = f.name - - with open(temp_file, 'r', encoding='utf-8') as f: - file_data = json.load(f) - - os.unlink(temp_file) - - print("✅ File I/O successful") - print(f"File data name: {file_data['name']}") - print(f"File names match: {test_data['name'] == file_data['name']}") - - except Exception as e: - print(f"❌ JSON serialization failed: {e}") - -def test_filesystem_compatibility(): - """Test creating files with Unicode names""" - - print("\nTesting filesystem compatibility...") - - test_names = [ - "schönes_preset", - "café_preset", - "naïve_preset", - "test_ñ_preset" - ] - - temp_dir = tempfile.mkdtemp() - print(f"Testing in directory: {temp_dir}") - - for name in test_names: - try: - filename = f"{name}.json" - filepath = os.path.join(temp_dir, filename) - - # Create file - with open(filepath, 'w', encoding='utf-8') as f: - json.dump({'name': name}, f, ensure_ascii=False) - - # Read file back - with open(filepath, 'r', encoding='utf-8') as f: - data = json.load(f) - - # Clean up - os.unlink(filepath) - - print(f"✅ {name}: File created and read successfully") - - except Exception as e: - print(f"❌ {name}: Failed - {e}") - - # Clean up temp directory - os.rmdir(temp_dir) - -if __name__ == "__main__": - print("=" * 60) - print("Testing Text Texture Generator Preset Unicode Support") - print("=" * 60) - - test_preset_name_validation() - test_json_serialization() - test_filesystem_compatibility() - - print("\n" + "=" * 60) - print("Test completed! Check results above.") - print("=" * 60) \ No newline at end of file diff --git a/test_ui_and_compatibility.py b/test_ui_and_compatibility.py deleted file mode 100644 index 69c949d..0000000 --- a/test_ui_and_compatibility.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 - -""" -Advanced UI Integration and Backward Compatibility Test Suite -for the Text Texture Generator's enhanced positioning system. - -This focuses on potential issues that the basic positioning tests might miss. -""" - -import sys -import re - -class UICompatibilityTestSuite: - """Test suite focused on UI integration and compatibility issues""" - - def __init__(self): - self.issues_found = [] - self.warnings = [] - - def analyze_ui_layout_structure(self): - """Analyze the UI layout structure from the addon code""" - print("\n=== Analyzing UI Layout Structure ===") - - # Read the addon code to analyze UI structure - try: - with open('__init__.py', 'r') as f: - addon_code = f.read() - except FileNotFoundError: - self.issues_found.append("❌ Cannot read __init__.py for UI analysis") - return - - # Check for Positioning & Alignment section - if "Positioning & Alignment" in addon_code: - print("✅ 'Positioning & Alignment' section found in UI") - else: - self.issues_found.append("❌ 'Positioning & Alignment' section not found in UI") - - # Check for position mode toggle - if 'position_mode' in addon_code and 'PRESET' in addon_code and 'MANUAL' in addon_code: - print("✅ Position mode toggle implementation found") - else: - self.issues_found.append("❌ Position mode toggle not properly implemented") - - # Check for 3x3 anchor grid implementation - anchor_grid_patterns = [ - r'get_anchor_point_matrix\(\)', - r'for.*anchor.*in.*matrix', - r'grid_row.*=.*position_col\.row' - ] - - grid_found = all(re.search(pattern, addon_code, re.DOTALL) for pattern in anchor_grid_patterns) - if grid_found: - print("✅ 3x3 anchor grid UI implementation found") - else: - self.issues_found.append("❌ 3x3 anchor grid UI implementation incomplete") - - # Check for margin controls with link functionality - if 'margins_linked' in addon_code and 'LINKED' in addon_code and 'UNLINKED' in addon_code: - print("✅ Margin link/unlink controls found") - else: - self.issues_found.append("❌ Margin link/unlink controls not properly implemented") - - # Check for conditional UI visibility - if 'position_mode == ' in addon_code: - print("✅ Conditional UI visibility based on position mode found") - else: - self.warnings.append("⚠️ No conditional UI visibility found - controls might always be visible") - - def test_backward_compatibility_integration(self): - """Test how the new system integrates with existing text_align property""" - print("\n=== Testing Backward Compatibility Integration ===") - - try: - with open('__init__.py', 'r') as f: - addon_code = f.read() - except FileNotFoundError: - self.issues_found.append("❌ Cannot read addon code for compatibility analysis") - return - - # Find the backward compatibility section in generate_texture_image - compat_section = re.search( - r'# Apply legacy text_align.*?props\.anchor_point == \'MIDDLE_CENTER\'.*?# Apply offsets even in legacy mode', - addon_code, re.DOTALL - ) - - if compat_section: - print("✅ Backward compatibility section found in texture generation") - - # Check if all text_align values are handled - compat_code = compat_section.group(0) - align_values = ['left', 'center', 'right'] - missing_aligns = [] - - for align in align_values: - if f"text_align == '{align}'" not in compat_code: - missing_aligns.append(align) - - if missing_aligns: - self.issues_found.append(f"❌ Missing text_align compatibility for: {missing_aligns}") - else: - print("✅ All text_align values have compatibility handling") - - # Check if legacy mode only applies to MIDDLE_CENTER - if "anchor_point == 'MIDDLE_CENTER'" in compat_code: - print("✅ Legacy compatibility properly scoped to MIDDLE_CENTER anchor") - else: - self.issues_found.append("❌ Legacy compatibility scope issue - should only apply to MIDDLE_CENTER") - - else: - self.issues_found.append("❌ Backward compatibility section not found in texture generation") - - # Check if text_align property is still defined - if re.search(r'text_align.*EnumProperty', addon_code): - print("✅ Legacy text_align property still defined") - else: - self.issues_found.append("❌ Legacy text_align property not found - breaking change!") - - def analyze_property_update_chains(self): - """Analyze property update chains for consistency""" - print("\n=== Analyzing Property Update Chains ===") - - try: - with open('__init__.py', 'r') as f: - addon_code = f.read() - except FileNotFoundError: - self.issues_found.append("❌ Cannot read addon code for update chain analysis") - return - - # Find all properties with update=update_live_preview - update_properties = re.findall(r'(\w+):\s*\w+Property.*?update=update_live_preview', addon_code, re.DOTALL) - - expected_positioning_props = [ - 'anchor_point', 'position_mode', 'margin_x', 'margin_y', - 'margins_linked', 'offset_x', 'offset_y', 'manual_position_x', - 'manual_position_y', 'manual_position_unit', 'constrain_to_canvas' - ] - - missing_updates = [] - for prop in expected_positioning_props: - if prop not in update_properties: - missing_updates.append(prop) - - if missing_updates: - self.issues_found.append(f"❌ Properties missing live preview updates: {missing_updates}") - else: - print("✅ All positioning properties have live preview updates") - - # Check for visual feedback properties - visual_props = ['show_position_guides', 'show_text_bounds', 'show_canvas_grid'] - missing_visual_updates = [] - for prop in visual_props: - if prop not in update_properties: - missing_visual_updates.append(prop) - - if missing_visual_updates: - self.issues_found.append(f"❌ Visual feedback properties missing updates: {missing_visual_updates}") - else: - print("✅ All visual feedback properties have live preview updates") - - def run_compatibility_tests(self): - """Run all compatibility and UI integration tests""" - print("🔍 UI INTEGRATION & COMPATIBILITY TEST SUITE") - print("=" * 55) - - try: - self.analyze_ui_layout_structure() - self.test_backward_compatibility_integration() - self.analyze_property_update_chains() - - except Exception as e: - print(f"❌ Critical error during compatibility testing: {e}") - import traceback - traceback.print_exc() - - self.generate_compatibility_report() - - def generate_compatibility_report(self): - """Generate compatibility and UI integration report""" - print("\n" + "=" * 60) - print("📊 UI INTEGRATION & COMPATIBILITY REPORT") - print("=" * 60) - - total_issues = len(self.issues_found) - total_warnings = len(self.warnings) - - print(f"📈 SUMMARY:") - print(f" 🚨 Critical Issues: {total_issues}") - print(f" ⚠️ Warnings: {total_warnings}") - - if self.issues_found: - print(f"\n🚨 CRITICAL ISSUES FOUND ({total_issues}):") - for issue in self.issues_found: - print(f" {issue}") - - if self.warnings: - print(f"\n⚠️ WARNINGS ({total_warnings}):") - for warning in self.warnings: - print(f" {warning}") - - if not self.issues_found and not self.warnings: - print("\n🎉 NO CRITICAL ISSUES OR WARNINGS FOUND!") - print(" The UI integration and compatibility appear to be well implemented.") - - # Recommendations based on findings - print(f"\n🎯 RECOMMENDATIONS:") - - if total_issues > 0: - print(" 1. 🚨 Address all critical issues before deployment") - print(" 2. Test UI elements thoroughly in actual Blender environment") - - if total_warnings > 0: - print(" 3. ⚠️ Review and address warnings for robustness") - print(" 4. Add explicit error handling for edge cases") - - print(" 5. ✅ Add automated UI tests to prevent regressions") - print(" 6. ✅ Test backward compatibility with existing presets") - print(" 7. ✅ Validate all property ranges in actual use cases") - - return { - 'critical_issues': total_issues, - 'warnings': total_warnings, - 'issues_list': self.issues_found, - 'warnings_list': self.warnings - } - -if __name__ == "__main__": - print("🚀 Starting UI Integration & Compatibility Test Suite...") - test_suite = UICompatibilityTestSuite() - results = test_suite.run_compatibility_tests() - - print(f"\n✨ UI & Compatibility testing completed!") \ No newline at end of file