KiCad PCB EDA Suite
Loading...
Searching...
No Matches
dialog_migrate_3d_models.cpp
Go to the documentation of this file.
1/*
2 * This program source code file is part of KiCad, a free EDA CAD application.
3 *
4 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
5 *
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 2
9 * of the License, or (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 */
19
21
22#include <algorithm>
23#include <unordered_set>
24
25#include <wx/utils.h>
26#include <wx/dir.h>
27#include <wx/dirdlg.h>
28#include <wx/filedlg.h>
29#include <wx/filename.h>
30
31#include <board.h>
32#include <board_commit.h>
35#include <footprint.h>
36#include <lset.h>
37#include <pcb_edit_frame.h>
38#include <pgm_base.h>
39#include <project.h>
40#include <project_pcb.h>
43
44#include <3d_cache/3d_cache.h>
49#include <filename_resolver.h>
50#include <gestfich.h>
51
52
53namespace
54{
59const std::vector<wxString>& stepExtensions()
60{
61 static const std::vector<wxString> exts = {
62 wxS( "step" ), wxS( "stp" ), wxS( "stpz" ),
63 wxS( "step.gz" ), wxS( "stp.gz" ),
64 wxS( "iges" ), wxS( "igs" )
65 };
66 return exts;
67}
68
69
75wxString normalizeStem( const wxString& aName )
76{
77 wxString stem = aName;
78
79 // Strip known extensions (case-insensitive), including doubled-up .gz
80 // forms that don't round-trip through wxFileName::GetName.
81 static const wxString stripList[] = {
82 wxS( ".step.gz" ), wxS( ".stp.gz" ),
83 wxS( ".wrl" ), wxS( ".wrz" ),
84 wxS( ".step" ), wxS( ".stp" ), wxS( ".stpz" ),
85 wxS( ".iges" ), wxS( ".igs" )
86 };
87
88 for( const wxString& ext : stripList )
89 {
90 if( stem.length() > ext.length()
91 && stem.Right( ext.length() ).Lower() == ext )
92 {
93 stem = stem.Left( stem.length() - ext.length() );
94 break;
95 }
96 }
97
98 stem.MakeLower();
99
100 // Treat separator characters as interchangeable so that e.g.
101 // "R_0603_1608Metric" and "R-0603-1608Metric" collide.
102 stem.Replace( wxS( "-" ), wxS( "_" ) );
103 stem.Replace( wxS( " " ), wxS( "_" ) );
104
105 return stem;
106}
107
108
110int levenshtein( const wxString& a, const wxString& b )
111{
112 const size_t m = a.length();
113 const size_t n = b.length();
114
115 if( m == 0 )
116 return static_cast<int>( n );
117
118 if( n == 0 )
119 return static_cast<int>( m );
120
121 std::vector<int> prev( n + 1 );
122 std::vector<int> curr( n + 1 );
123
124 for( size_t j = 0; j <= n; ++j )
125 prev[j] = static_cast<int>( j );
126
127 for( size_t i = 1; i <= m; ++i )
128 {
129 curr[0] = static_cast<int>( i );
130
131 for( size_t j = 1; j <= n; ++j )
132 {
133 int cost = ( a[i - 1] == b[j - 1] ) ? 0 : 1;
134 curr[j] = std::min( { curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost } );
135 }
136
137 std::swap( prev, curr );
138 }
139
140 return prev[n];
141}
142
143
146wxString parentDirName( const wxString& aPath )
147{
148 wxFileName fn( aPath );
149 const wxArrayString& dirs = fn.GetDirs();
150 return dirs.empty() ? wxString() : dirs.Last();
151}
152
153
157wxString parentDirFromFilename( const wxString& aFilename )
158{
159 return parentDirName( aFilename );
160}
161
162} // anonymous namespace
163
164
167 m_frame( aFrame ),
168 m_dummyBoard( nullptr ),
169 m_dummyFootprint( nullptr ),
172 m_previewPane( nullptr )
173{
174 // Single column spanning the full list-control width; wxLC_REPORT needs
175 // at least one column to show anything, wxLC_NO_HEADER hides the header.
176 m_missingList->AppendColumn( wxEmptyString, wxLIST_FORMAT_LEFT, 800 );
177 m_candidatesList->AppendColumn( wxEmptyString, wxLIST_FORMAT_LEFT, 800 );
178
179 // Keep the single column filling the visible width as the splitter sash
180 // moves. Bare wxListCtrl in report mode clips overflowing text cleanly.
181 auto fitColumn = []( wxListCtrl* aList )
182 {
183 const int width = std::max( aList->GetClientSize().GetWidth() - 2, 20 );
184 aList->SetColumnWidth( 0, width );
185 };
186
187 m_missingList->Bind( wxEVT_SIZE,
188 [this, fitColumn]( wxSizeEvent& aEvt )
189 {
190 fitColumn( m_missingList );
191 aEvt.Skip();
192 } );
193
194 m_candidatesList->Bind( wxEVT_SIZE,
195 [this, fitColumn]( wxSizeEvent& aEvt )
196 {
197 fitColumn( m_candidatesList );
198 aEvt.Skip();
199 } );
200
202 buildCatalog();
204
205 // Build the throwaway board + footprint and the 3D canvas. Must happen
206 // after collectMissingModels() so we can size the preview using whichever
207 // representative footprint is first shown.
209
210 // Auto-select the top real (non-"keep existing") candidate for every
211 // missing row so the user sees a sensible default without having to
212 // click through each one. Rows for which no real candidate exists get
213 // bolded by populateMissingList() as a visual attention marker.
214 for( size_t i = 0; i < m_missing.size(); ++i )
215 {
216 const std::vector<MATCH_CANDIDATE>& cands = m_candidatesPerMissing[i];
217
218 if( !cands.empty() && !cands.front().m_absPath.IsEmpty() )
220 }
221
223
224 if( !m_missing.empty() )
225 {
226 m_missingList->SetItemState( 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
227 wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED );
229 }
230
233
234 // Position the splitter sashes so the three columns start out at roughly
235 // one third of the dialog width each. Must happen after the dialog has
236 // been laid out at its final initial size.
237 const int clientWidth = m_mainSplitter->GetClientSize().GetWidth();
238
239 if( clientWidth > 360 )
240 {
241 const int outerSash = clientWidth / 3;
242 m_mainSplitter->SetSashPosition( outerSash );
243
244 const int innerWidth = std::max( clientWidth - outerSash, 240 );
245 m_innerSplitter->SetSashPosition( innerWidth / 2 );
246 }
247}
248
249
251{
252 const wxSize screenSize = wxGetDisplaySize();
253 const int hardCap = ( screenSize.GetWidth() * 9 ) / 10;
254
255 // Soft cap (0.9 * parent width) only applies on the first time the
256 // dialog is opened against a given user settings file. We detect that
257 // by comparing the current width to the hardcoded base-class default:
258 // if finishDialogSettings() restored a saved value the width won't
259 // match the default.
260 constexpr int kBaseDefaultWidth = 900;
261 int cap = hardCap;
262
263 if( m_frame && GetSize().GetWidth() == kBaseDefaultWidth )
264 {
265 const int parentCap = ( m_frame->GetSize().GetWidth() * 9 ) / 10;
266 cap = std::min( cap, parentCap );
267 }
268
269 if( GetSize().GetWidth() > cap )
270 {
271 wxSize sized = GetSize();
272 sized.SetWidth( std::max( cap, GetMinSize().GetWidth() ) );
273 SetSize( sized );
274 Centre( wxBOTH );
275 }
276}
277
278
280{
281 delete m_previewPane;
282 delete m_dummyBoard; // owns m_dummyFootprint
283}
284
285
287{
288 if( !aFrame )
289 return false;
290
291 BOARD* board = aFrame->GetBoard();
293
294 if( !board || !resolver )
295 return false;
296
297 const wxString projectPath = aFrame->Prj().GetProjectPath();
298
299 for( FOOTPRINT* fp : board->Footprints() )
300 {
301 for( const FP_3DMODEL& model : fp->Models() )
302 {
303 const wxString& fn = model.m_Filename;
304
305 if( fn.IsEmpty() || !MODEL_SUBSTITUTION::IsWrlExtension( fn ) )
306 continue;
307
308 if( resolver->ResolvePath( fn, projectPath, {} ).IsEmpty() )
309 return true;
310 }
311 }
312
313 return false;
314}
315
316
318{
319 if( !aFrame )
320 return 0;
321
322 BOARD* board = aFrame->GetBoard();
324
325 if( !board || !resolver )
326 return 0;
327
328 const wxString projectPath = aFrame->Prj().GetProjectPath();
329 std::unordered_set<wxString> unique;
330
331 for( FOOTPRINT* fp : board->Footprints() )
332 {
333 for( const FP_3DMODEL& model : fp->Models() )
334 {
335 const wxString& fn = model.m_Filename;
336
337 if( fn.IsEmpty() || !MODEL_SUBSTITUTION::IsWrlExtension( fn ) )
338 continue;
339
340 if( unique.count( fn ) )
341 continue;
342
343 if( resolver->ResolvePath( fn, projectPath, {} ).IsEmpty() )
344 unique.insert( fn );
345 }
346 }
347
348 return static_cast<int>( unique.size() );
349}
350
351
353{
354 if( !aFrame )
355 return 0;
356
357 BOARD* board = aFrame->GetBoard();
359
360 if( !board || !resolver )
361 return 0;
362
363 const wxString projectPath = aFrame->Prj().GetProjectPath();
364
366 bool catalogBuilt = false;
367
368 std::map<wxString, wxString> replacements;
369 std::unordered_set<wxString> seen;
370
371 for( FOOTPRINT* fp : board->Footprints() )
372 {
373 for( const FP_3DMODEL& model : fp->Models() )
374 {
375 const wxString& fn = model.m_Filename;
376
377 if( fn.IsEmpty() || !MODEL_SUBSTITUTION::IsWrlExtension( fn ) )
378 continue;
379
380 if( !seen.insert( fn ).second )
381 continue;
382
383 if( !resolver->ResolvePath( fn, projectPath, {} ).IsEmpty() )
384 continue;
385
386 if( !catalogBuilt )
387 {
388 catalog.Build( projectPath, resolver );
389 catalogBuilt = true;
390 }
391
392 const wxString match = catalog.FindMatchFor( fn );
393
394 if( match.IsEmpty() )
395 continue;
396
397 const wxString shortened = resolver->ShortenPath( match );
398
399 // ShortenPath returning empty would blank the footprint reference;
400 // fall back to the absolute path in that edge case.
401 replacements[fn] = shortened.IsEmpty() ? match : shortened;
402 }
403 }
404
405 if( replacements.empty() )
406 return 0;
407
408 BOARD_COMMIT commit( aFrame );
409 int rewritten = 0;
410
411 for( FOOTPRINT* fp : board->Footprints() )
412 {
413 bool changedThisFp = false;
414
415 for( FP_3DMODEL& model : fp->Models() )
416 {
417 auto it = replacements.find( model.m_Filename );
418
419 if( it == replacements.end() )
420 continue;
421
422 if( !changedThisFp )
423 {
424 commit.Modify( fp );
425 changedThisFp = true;
426 }
427
428 model.m_Filename = it->second;
429 ++rewritten;
430 }
431 }
432
433 if( rewritten > 0 )
434 commit.Push( _( "Auto-migrate 3D model references" ) );
435
436 return rewritten;
437}
438
439
441{
442 BOARD* board = m_frame->GetBoard();
444
445 if( !board || !resolver )
446 return;
447
448 const wxString projectPath = m_frame->Prj().GetProjectPath();
449
450 // Accumulate into a map keyed by WRL filename so we can sort afterwards
451 // while still tracking the representative footprint and transform captured
452 // from the first FP_3DMODEL that referenced it.
453 struct ENTRY
454 {
455 const FOOTPRINT* m_fp = nullptr;
456 MISSING_XFORM m_xform;
457 };
458 std::map<wxString, ENTRY> byName;
459
460 for( FOOTPRINT* fp : board->Footprints() )
461 {
462 for( const FP_3DMODEL& model : fp->Models() )
463 {
464 const wxString& fn = model.m_Filename;
465
466 if( fn.IsEmpty() || !MODEL_SUBSTITUTION::IsWrlExtension( fn ) )
467 continue;
468
469 if( byName.count( fn ) )
470 continue;
471
472 // Mirror the resolver invocation used by the 3D viewer: empty
473 // return means "not found on disk via any search path".
474 if( !resolver->ResolvePath( fn, projectPath, {} ).IsEmpty() )
475 continue;
476
477 ENTRY& e = byName[fn];
478 e.m_fp = fp;
479 e.m_xform.m_scale = model.m_Scale;
480 e.m_xform.m_rotation = model.m_Rotation;
481 e.m_xform.m_offset = model.m_Offset;
482 e.m_xform.m_opacity = model.m_Opacity;
483 }
484 }
485
486 m_missing.reserve( byName.size() );
487 m_missingRepFp.reserve( byName.size() );
488 m_missingXform.reserve( byName.size() );
489
490 for( const auto& [fn, entry] : byName )
491 {
492 m_missing.push_back( fn );
493 m_missingRepFp.push_back( entry.m_fp );
494 m_missingXform.push_back( entry.m_xform );
495 }
496
497 m_selectedPerMissing.assign( m_missing.size(), -1 );
498 m_candidatesPerMissing.resize( m_missing.size() );
499}
500
501
503{
504 if( m_missing.empty() )
505 return;
506
507 wxBusyCursor busy;
508
510
511 // Standard KiCad 3D search paths (resolved to absolute directories). GetKicadPaths
512 // returns variable names, not filesystem paths, so walk the resolver's search-path list
513 // directly and consume m_Pathexp (the expanded absolute directory).
514 if( resolver )
515 {
516 if( const std::list<SEARCH_PATH>* searchPaths = resolver->GetPaths() )
517 {
518 for( const SEARCH_PATH& sp : *searchPaths )
519 {
520 if( !sp.m_Pathexp.IsEmpty() )
521 scanDirectory( sp.m_Pathexp );
522 }
523 }
524 }
525
526 // Project-local 3D model directory, when present.
527 wxFileName prj3D( m_frame->Prj().GetProjectPath(), wxEmptyString );
528 prj3D.AppendDir( wxS( "3dshapes" ) );
529
530 if( prj3D.DirExists() )
531 scanDirectory( prj3D.GetPath() );
532
533 // User-added extra directories, persisted across sessions.
535
536 if( common )
537 {
538 for( const wxString& dir : common->m_Extra3DSearchDirs )
539 scanDirectory( dir );
540 }
541}
542
543
544void DIALOG_MIGRATE_3D_MODELS::scanDirectory( const wxString& aDir )
545{
546 if( aDir.IsEmpty() )
547 return;
548
549 wxFileName normFn( aDir, wxEmptyString );
550 normFn.Normalize( wxPATH_NORM_ABSOLUTE | wxPATH_NORM_DOTS );
551 const wxString key = normFn.GetPath().Lower();
552
553 if( !m_scannedDirs.insert( key ).second )
554 return; // already scanned
555
556 if( !wxDir::Exists( normFn.GetPath() ) )
557 return;
558
559 wxArrayString files;
560 CollectFilesLoopSafe( normFn.GetPath(), files, wxEmptyString, wxDIR_FILES | wxDIR_DIRS );
561
562 // Collect duplicate-detection set by absolute-path key so re-scanning
563 // the same tree (e.g. through a different env var alias) doesn't add
564 // duplicate catalog entries.
565 std::unordered_set<wxString> existing;
566
567 for( const CATALOG_ENTRY& e : m_catalog )
568 existing.insert( e.m_absPath.Lower() );
569
570 for( const wxString& f : files )
571 {
572 const wxString lower = f.Lower();
573 bool acceptable = false;
574
575 for( const wxString& ext : stepExtensions() )
576 {
577 if( lower.length() > ext.length() + 1
578 && lower.Right( ext.length() + 1 ) == wxS( "." ) + ext )
579 {
580 acceptable = true;
581 break;
582 }
583 }
584
585 if( !acceptable )
586 continue;
587
588 if( existing.count( lower ) )
589 continue;
590
591 existing.insert( lower );
592
593 CATALOG_ENTRY entry;
594 entry.m_absPath = f;
595 entry.m_stem = normalizeStem( wxFileName( f ).GetFullName() );
596 entry.m_parent = parentDirName( f );
597 m_catalog.push_back( entry );
598 }
599}
600
601
602std::vector<DIALOG_MIGRATE_3D_MODELS::MATCH_CANDIDATE>
603DIALOG_MIGRATE_3D_MODELS::rankCandidatesFor( const wxString& aWrlFilename ) const
604{
605 const wxString wrlStem = normalizeStem( wxFileName( aWrlFilename ).GetFullName() );
606 const wxString wrlParent = parentDirFromFilename( aWrlFilename );
607
608 std::vector<MATCH_CANDIDATE> ranked;
609 ranked.reserve( std::min<size_t>( m_catalog.size(), 64 ) );
610
611 for( const CATALOG_ENTRY& entry : m_catalog )
612 {
613 int score = 0;
614
615 if( entry.m_stem == wrlStem )
616 {
617 score = ( !wrlParent.IsEmpty() && entry.m_parent.CmpNoCase( wrlParent ) == 0 )
618 ? 1000
619 : 800;
620 }
621 else
622 {
623 const int dist = levenshtein( entry.m_stem, wrlStem );
624 const int maxDist = static_cast<int>( std::max<size_t>( 2, wrlStem.length() / 4 ) );
625
626 if( dist <= maxDist )
627 score = 500 - dist * 10;
628 }
629
630 if( score > 0 )
631 {
632 MATCH_CANDIDATE cand;
633 cand.m_absPath = entry.m_absPath;
634 cand.m_display = wxFileName( entry.m_absPath ).GetFullName()
635 + wxS( " \u2014 " )
636 + entry.m_parent;
637 cand.m_score = score;
638 ranked.push_back( cand );
639 }
640 }
641
642 std::sort( ranked.begin(), ranked.end(),
643 []( const MATCH_CANDIDATE& a, const MATCH_CANDIDATE& b )
644 {
645 if( a.m_score != b.m_score )
646 return a.m_score > b.m_score;
647
648 return a.m_display.CmpNoCase( b.m_display ) < 0;
649 } );
650
651 constexpr size_t kMaxCandidates = 15;
652
653 if( ranked.size() > kMaxCandidates )
654 ranked.resize( kMaxCandidates );
655
656 // Always append "keep existing" at the bottom so the user has a way
657 // to explicitly decline a replacement for this one row.
658 MATCH_CANDIDATE keep;
659 keep.m_absPath = wxEmptyString;
660 keep.m_display = _( "(keep existing reference)" );
661 keep.m_score = 0;
662 ranked.push_back( keep );
663
664 return ranked;
665}
666
667
669{
670 for( size_t i = 0; i < m_missing.size(); ++i )
672}
673
674
676{
677 m_missingList->DeleteAllItems();
678
679 for( size_t i = 0; i < m_missing.size(); ++i )
680 {
681 const long row = m_missingList->InsertItem( static_cast<long>( i ), m_missing[i] );
682 (void) row;
683 updateMissingItemStyle( static_cast<int>( i ) );
684 }
685}
686
687
689{
690 if( aMissingIndex < 0 || aMissingIndex >= static_cast<int>( m_missing.size() ) )
691 return;
692
693 wxFont font = m_missingList->GetFont();
694 font.SetWeight( m_selectedPerMissing[aMissingIndex] < 0 ? wxFONTWEIGHT_BOLD
695 : wxFONTWEIGHT_NORMAL );
696 m_missingList->SetItemFont( aMissingIndex, font );
697}
698
699
701{
702 m_candidatesList->DeleteAllItems();
703
704 if( aMissingIndex < 0 || aMissingIndex >= static_cast<int>( m_missing.size() ) )
705 return;
706
707 const std::vector<MATCH_CANDIDATE>& cands = m_candidatesPerMissing[aMissingIndex];
708
709 for( size_t i = 0; i < cands.size(); ++i )
710 m_candidatesList->InsertItem( static_cast<long>( i ), cands[i].m_display );
711
712 const int sel = m_selectedPerMissing[aMissingIndex];
713
714 if( sel >= 0 && sel < static_cast<int>( cands.size() ) )
715 {
716 m_candidatesList->SetItemState( sel, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
717 wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED );
718 showPreview( aMissingIndex, cands[sel].m_absPath );
719 }
720 else if( !cands.empty() )
721 {
722 // No committed choice yet (only the "keep existing" row is available).
723 // Highlight the top row for visual continuity; the preview still shows
724 // the footprint on the dummy board so the user sees the landing pad
725 // even when no replacement has been chosen.
726 m_candidatesList->SetItemState( 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
727 wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED );
728 showPreview( aMissingIndex, cands.front().m_absPath );
729 }
730}
731
732
734{
735 m_dummyBoard = new BOARD();
736 m_dummyBoard->SetProject( &m_frame->Prj(), true );
737 m_dummyBoard->SetEmbeddedFilesDelegate( m_frame->GetBoard() );
738 m_dummyBoard->SetBoardUse( BOARD_USE::FPHOLDER );
739
740 BOARD_DESIGN_SETTINGS& dummyBds = m_dummyBoard->GetDesignSettings();
741 const BOARD_DESIGN_SETTINGS& parentBds = m_frame->GetDesignSettings();
742 dummyBds.SetBoardThickness( parentBds.GetBoardThickness() );
744
745 BOARD_STACKUP& stackup = dummyBds.GetStackupDescriptor();
746 stackup.RemoveAll();
747 stackup.BuildDefaultStackupList( &dummyBds, 2 );
748
749 m_boardAdapter.SetBoard( m_dummyBoard );
750 m_boardAdapter.m_IsBoardView = false;
751 m_boardAdapter.m_IsPreviewer = true;
752
754 {
755 m_boardAdapter.m_Cfg = cfg;
756
757 // Same rationale as PANEL_PREVIEW_3D_MODEL: placeholder models are
758 // noisy in a candidate picker.
759 cfg->m_Render.show_missing_models = false;
760 }
761
765
766 m_previewPanel->GetSizer()->Add( m_previewPane, 1, wxEXPAND, 0 );
767 m_previewPanel->Layout();
768}
769
770
771void DIALOG_MIGRATE_3D_MODELS::showPreview( int aMissingIndex, const wxString& aCandAbsPath )
772{
773 if( !m_previewPane || !m_dummyBoard )
774 return;
775
776 // Swap out whatever footprint is currently on the dummy board. Cloning
777 // the representative footprint each time is cheap and avoids lingering
778 // state (pads, silk, ...) from previously-selected rows.
779 if( m_dummyFootprint )
780 {
782 delete m_dummyFootprint;
783 m_dummyFootprint = nullptr;
784 }
785
786 const FOOTPRINT* repFp = nullptr;
787
788 if( aMissingIndex >= 0 && aMissingIndex < static_cast<int>( m_missingRepFp.size() ) )
789 repFp = m_missingRepFp[aMissingIndex];
790
791 if( repFp )
792 {
793 m_dummyFootprint = new FOOTPRINT( *repFp );
794 m_dummyFootprint->SetParentGroup( nullptr );
795
796 // Normalize orientation the same way the footprint-properties preview
797 // does so the model lands face-up regardless of how the real footprint
798 // is placed on the user's board.
799 if( m_dummyFootprint->IsFlipped() )
801
802 m_dummyFootprint->SetOrientation( ANGLE_0 );
803
804 // Replace the 3D model list with just the candidate (if any), carrying
805 // over the transform captured from the original WRL entry so the user
806 // sees the candidate sitting in the same spot the WRL would have.
807 m_dummyFootprint->Models().clear();
808
809 if( !aCandAbsPath.IsEmpty() )
810 {
811 FP_3DMODEL replacement;
812 replacement.m_Filename = aCandAbsPath;
813 replacement.m_Scale = m_missingXform[aMissingIndex].m_scale;
814 replacement.m_Rotation = m_missingXform[aMissingIndex].m_rotation;
815 replacement.m_Offset = m_missingXform[aMissingIndex].m_offset;
816 replacement.m_Opacity = m_missingXform[aMissingIndex].m_opacity;
817 replacement.m_Show = true;
818 m_dummyFootprint->Models().push_back( replacement );
819 }
820
822 }
823
824 m_previewPane->ReloadRequest();
825 m_previewPane->Request_refresh();
826}
827
828
829static int getSelectedRow( wxListCtrl* aList )
830{
831 return static_cast<int>( aList->GetNextItem( -1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED ) );
832}
833
834
836{
837 populateCandidatesList( static_cast<int>( aEvent.GetIndex() ) );
838}
839
840
842{
843 const int missingIdx = static_cast<int>( getSelectedRow( m_missingList ) );
844 const int candIdx = static_cast<int>( aEvent.GetIndex() );
845
846 if( missingIdx < 0 || missingIdx >= static_cast<int>( m_missing.size() ) )
847 return;
848
849 const std::vector<MATCH_CANDIDATE>& cands = m_candidatesPerMissing[missingIdx];
850
851 if( candIdx < 0 || candIdx >= static_cast<int>( cands.size() ) )
852 return;
853
854 const MATCH_CANDIDATE& c = cands[candIdx];
855
856 // Record this row's choice. The "keep existing" row has an empty path
857 // and we store -1 to mean "no replacement".
858 m_selectedPerMissing[missingIdx] = c.m_absPath.IsEmpty() ? -1 : candIdx;
859
860 // Bold the missing row again when the user explicitly chose "keep
861 // existing", so the visual cue tracks the actual selection state.
862 updateMissingItemStyle( missingIdx );
863
864 showPreview( missingIdx, c.m_absPath );
865}
866
867
869{
870 wxDirDialog dlg( this, _( "Add 3D Model Search Directory" ) );
871
872 if( dlg.ShowModal() != wxID_OK )
873 return;
874
875 const wxString chosen = dlg.GetPath();
876
877 if( chosen.IsEmpty() )
878 return;
879
880 // Persist the directory so future sessions include it automatically.
882
883 if( common )
884 {
885 auto& dirs = common->m_Extra3DSearchDirs;
886
887 if( std::find( dirs.begin(), dirs.end(), chosen ) == dirs.end() )
888 dirs.push_back( chosen );
889 }
890
891 // Scan just the new directory; catalog dedup ensures no duplicates.
892 {
893 wxBusyCursor busy;
894 scanDirectory( chosen );
895 }
896
897 // Re-rank every row against the expanded catalog and refresh the view,
898 // preserving the user's existing per-row selections by filename.
899 std::vector<wxString> priorSelectionPaths( m_missing.size() );
900
901 for( size_t i = 0; i < m_missing.size(); ++i )
902 {
903 const int sel = m_selectedPerMissing[i];
904
905 if( sel >= 0 && sel < static_cast<int>( m_candidatesPerMissing[i].size() ) )
906 priorSelectionPaths[i] = m_candidatesPerMissing[i][sel].m_absPath;
907 }
908
910
911 for( size_t i = 0; i < m_missing.size(); ++i )
912 {
913 m_selectedPerMissing[i] = -1;
914 const std::vector<MATCH_CANDIDATE>& cands = m_candidatesPerMissing[i];
915
916 // Preserve the user's prior explicit pick when the same file still
917 // appears in the catalog.
918 if( !priorSelectionPaths[i].IsEmpty() )
919 {
920 for( size_t j = 0; j < cands.size(); ++j )
921 {
922 if( cands[j].m_absPath == priorSelectionPaths[i] )
923 {
924 m_selectedPerMissing[i] = static_cast<int>( j );
925 break;
926 }
927 }
928 }
929
930 // For rows with no prior pick (or whose pick disappeared), auto-select
931 // the newly-best candidate so adding a directory immediately un-bolds
932 // rows that just acquired a plausible match.
933 if( m_selectedPerMissing[i] < 0 && !cands.empty()
934 && !cands.front().m_absPath.IsEmpty() )
935 {
937 }
938
939 updateMissingItemStyle( static_cast<int>( i ) );
940 }
941
942 const int activeRow = getSelectedRow( m_missingList );
943 populateCandidatesList( activeRow );
944}
945
946
948{
949 const int missingIdx = getSelectedRow( m_missingList );
950
951 if( missingIdx < 0 || missingIdx >= static_cast<int>( m_missing.size() ) )
952 return;
953
954 wxString wildcard;
955
956 for( const wxString& ext : stepExtensions() )
957 {
958 if( !wildcard.IsEmpty() )
959 wildcard += wxS( ";" );
960
961 wildcard += wxS( "*." ) + ext;
962 }
963
964 wxFileDialog dlg( this, _( "Select 3D Model File" ), wxEmptyString, wxEmptyString,
965 _( "3D Models" ) + wxS( " (" ) + wildcard + wxS( ")|" ) + wildcard,
966 wxFD_OPEN | wxFD_FILE_MUST_EXIST );
967
968 if( dlg.ShowModal() != wxID_OK )
969 return;
970
971 const wxString chosenPath = dlg.GetPath();
972
973 if( chosenPath.IsEmpty() )
974 return;
975
976 // Inject the chosen file as a top-ranked candidate for this row so the
977 // user can see it previewed and it will be applied when they hit Replace.
978 MATCH_CANDIDATE injected;
979 injected.m_absPath = chosenPath;
980 injected.m_display = wxFileName( chosenPath ).GetFullName() + wxS( " \u2014 " )
981 + _( "user-selected" );
982 injected.m_score = 2000;
983
984 std::vector<MATCH_CANDIDATE>& cands = m_candidatesPerMissing[missingIdx];
985
986 // Avoid duplicating an already-present entry.
987 auto existing = std::find_if( cands.begin(), cands.end(),
988 [&]( const MATCH_CANDIDATE& c )
989 { return c.m_absPath == chosenPath; } );
990
991 if( existing == cands.end() )
992 {
993 cands.insert( cands.begin(), injected );
994 m_selectedPerMissing[missingIdx] = 0;
995 }
996 else
997 {
998 m_selectedPerMissing[missingIdx] = static_cast<int>( existing - cands.begin() );
999 }
1000
1001 updateMissingItemStyle( missingIdx );
1002 populateCandidatesList( missingIdx );
1003}
1004
1005
1007{
1008 // Build the old->new filename map from the per-row selections. We store
1009 // the new filename through FILENAME_RESOLVER::ShortenPath() so that
1010 // env-var aliases like ${KICAD10_3DMODEL_DIR} survive round-trip.
1012 std::map<wxString, wxString> replacements;
1013
1014 for( size_t i = 0; i < m_missing.size(); ++i )
1015 {
1016 const int sel = m_selectedPerMissing[i];
1017
1018 if( sel < 0 || sel >= static_cast<int>( m_candidatesPerMissing[i].size() ) )
1019 continue;
1020
1021 const MATCH_CANDIDATE& c = m_candidatesPerMissing[i][sel];
1022
1023 if( c.m_absPath.IsEmpty() )
1024 continue; // "(keep existing)"
1025
1026 wxString stored = c.m_absPath;
1027
1028 if( resolver )
1029 stored = resolver->ShortenPath( c.m_absPath );
1030
1031 replacements[m_missing[i]] = stored;
1032 }
1033
1034 if( replacements.empty() )
1035 {
1036 // User clicked Replace without choosing anything; treat as Keep.
1037 EndModal( wxID_CANCEL );
1038 return;
1039 }
1040
1041 BOARD* board = m_frame->GetBoard();
1042 BOARD_COMMIT commit( m_frame );
1043
1044 for( FOOTPRINT* fp : board->Footprints() )
1045 {
1046 bool changedThisFp = false;
1047
1048 for( FP_3DMODEL& model : fp->Models() )
1049 {
1050 auto it = replacements.find( model.m_Filename );
1051
1052 if( it == replacements.end() )
1053 continue;
1054
1055 if( !changedThisFp )
1056 {
1057 commit.Modify( fp );
1058 changedThisFp = true;
1059 }
1060
1061 model.m_Filename = it->second;
1062 }
1063 }
1064
1065 commit.Push( _( "Migrate 3D model references" ) );
1066
1067 EndModal( wxID_OK );
1068}
1069
1070
1072{
1073 EndModal( wxID_CANCEL );
1074}
@ FPHOLDER
Definition board.h:364
#define RANGE_SCALE_3D
This defines the range that all coord will have to be rendered.
virtual void Push(const wxString &aMessage=wxEmptyString, int aCommitFlags=0) override
Execute the changes.
Container for design settings for a BOARD object.
void SetEnabledLayers(const LSET &aMask)
Change the bit-mask of enabled layers to aMask.
int GetBoardThickness() const
The full thickness of the board including copper and masks.
BOARD_STACKUP & GetStackupDescriptor()
void SetBoardThickness(int aThickness)
Manage layers needed to make a physical board.
void RemoveAll()
Delete all items in list and clear the list.
void BuildDefaultStackupList(const BOARD_DESIGN_SETTINGS *aSettings, int aActiveCopperLayersCount=0)
Create a default stackup, according to the current BOARD_DESIGN_SETTINGS settings.
Information pertinent to a Pcbnew printed circuit board.
Definition board.h:372
const FOOTPRINTS & Footprints() const
Definition board.h:420
COMMIT & Modify(EDA_ITEM *aItem, BASE_SCREEN *aScreen=nullptr, RECURSE_MODE aRecurse=RECURSE_MODE::NO_RECURSE)
Modify a given item in the model.
Definition commit.h:102
std::vector< wxString > m_Extra3DSearchDirs
Extra directories to search for 3D models, added by the user through the 3D model migration dialog.
DIALOG_MIGRATE_3D_MODELS_BASE(wxWindow *parent, wxWindowID id=wxID_ANY, const wxString &title=_("Migrate 3D Models"), const wxPoint &pos=wxDefaultPosition, const wxSize &size=wxSize(900, 560), long style=wxDEFAULT_DIALOG_STYLE|wxRESIZE_BORDER)
static int AutoMigrateByFilename(PCB_EDIT_FRAME *aFrame)
Silently rewrite unresolvable .wrl/.wrz references to STEP files whose filename stem matches in the s...
void OnKeepClick(wxCommandEvent &event) override
std::vector< int > m_selectedPerMissing
Which candidate index the user has selected for each missing entry.
std::vector< CATALOG_ENTRY > m_catalog
Catalog of STEP-compatible model paths, deduped by absolute path.
DIALOG_MIGRATE_3D_MODELS(PCB_EDIT_FRAME *aFrame)
std::vector< wxString > m_missing
Unique WRL filenames (as stored in FP_3DMODEL::m_Filename, i.e.
static bool BoardHasUnresolvedWrlReferences(PCB_EDIT_FRAME *aFrame)
Cheap precheck that avoids constructing the dialog (and its embedded 3D canvas) when the board has no...
void populateCandidatesList(int aMissingIndex)
void applyInitialSizeCaps()
Size the dialog on first construction: cap at 0.9 * parent width when opening for the first time,...
void updateMissingItemStyle(int aMissingIndex)
Apply bold styling to the missing-list row iff no replacement is currently selected (m_selectedPerMis...
void OnOpenExternalFileClick(wxCommandEvent &event) override
void OnAddSearchDirectoryClick(wxCommandEvent &event) override
std::vector< std::vector< MATCH_CANDIDATE > > m_candidatesPerMissing
Ranked candidates per missing filename, keyed by index into m_missing.
std::vector< MATCH_CANDIDATE > rankCandidatesFor(const wxString &aWrlFilename) const
void initPreviewBoard()
Set up the throwaway board/footprint that the preview canvas renders.
void showPreview(int aMissingIndex, const wxString &aCandAbsPath)
Replace the preview's footprint and model list to reflect the current missing-list selection + candid...
std::vector< MISSING_XFORM > m_missingXform
Source FP_3DMODEL transform per missing filename, used when building the candidate model for preview.
BOARD * m_dummyBoard
Preview pipeline.
void OnCandidateSelected(wxListEvent &event) override
void scanDirectory(const wxString &aDir)
static int CountUnresolvedWrlReferences(PCB_EDIT_FRAME *aFrame)
Count of unique unresolvable .wrl/.wrz references on the board (deduplicated by filename).
FOOTPRINT * m_dummyFootprint
Owned by m_dummyBoard.
std::vector< const FOOTPRINT * > m_missingRepFp
Representative footprint per missing filename.
void OnMissingSelected(wxListEvent &event) override
void OnReplaceClick(wxCommandEvent &event) override
std::set< wxString > m_scannedDirs
Set of already-scanned directories (absolute path, case-normalised).
void finishDialogSettings()
In all dialogs, we must call the same functions to fix minimal dlg size, the default position and per...
Implement a canvas based on a wxGLCanvas.
Provide an extensible class to resolve 3D model paths.
VECTOR3D m_Offset
3D model offset (mm)
Definition footprint.h:171
double m_Opacity
Definition footprint.h:172
VECTOR3D m_Rotation
3D model rotation (degrees)
Definition footprint.h:170
VECTOR3D m_Scale
3D model scaling factor (dimensionless)
Definition footprint.h:169
wxString m_Filename
The 3D shape filename in 3D library.
Definition footprint.h:173
bool m_Show
Include model in rendering.
Definition footprint.h:174
PROJECT & Prj() const
Return a reference to the PROJECT associated with this KIWAY.
static const LSET & FrontMask()
Return a mask holding all technical layers and the external CU layer on front side.
Definition lset.cpp:718
static const LSET & BackMask()
Return a mask holding all technical layers and the external CU layer on back side.
Definition lset.cpp:725
An index of STEP-family model files keyed by normalised filename stem.
wxString FindMatchFor(const wxString &aMissingWrl) const
Look up the best STEP-family replacement for a missing WRL reference.
void Build(const wxString &aProjectPath, const FILENAME_RESOLVER *aResolver)
Walk the resolver's search paths, the project's 3dshapes/ subdirectory, and the user's COMMON_SETTING...
static const wxGLAttributes GetAttributesList(ANTIALIASING_MODE aAntiAliasingMode, bool aAlpha=false)
Get a list of attributes to pass to wxGLCanvas.
BOARD * GetBoard() const
The main frame for Pcbnew.
virtual COMMON_SETTINGS * GetCommonSettings() const
Definition pgm_base.cpp:528
static S3D_CACHE * Get3DCacheManager(PROJECT *aProject, bool updateProjDir=false)
Return a pointer to an instance of the 3D cache manager.
static FILENAME_RESOLVER * Get3DFilenameResolver(PROJECT *aProject)
Accessor for 3D path resolver.
virtual const wxString GetProjectPath() const
Return the full path of the project.
Definition project.cpp:183
static int getSelectedRow(wxListCtrl *aList)
#define _(s)
static constexpr EDA_ANGLE ANGLE_0
Definition eda_angle.h:411
static FILENAME_RESOLVER * resolver
void CollectFilesLoopSafe(const wxString &aRoot, wxArrayString &aFiles, const wxString &aFileSpec, int aFlags)
Recursively collect every file under aRoot, deduplicating subdirectories by their resolved path.
Definition gestfich.cpp:817
@ TOP_BOTTOM
Flip top to bottom (around the X axis)
Definition mirror.h:25
bool IsWrlExtension(const wxString &aFilename)
True iff aFilename ends in .wrl or .wrz (case-insensitive).
Declaration of the cogl_att_list class.
PGM_BASE & Pgm()
The global program "get" accessor.
see class PGM_BASE
T * GetAppSettings(const char *aFilename)
An entry in the STEP catalog. m_absPath is the deduplication key.
wxString m_parent
Parent directory basename (e.g. "Resistor_SMD.3dshapes")
wxString m_absPath
Absolute on-disk path.
wxString m_stem
Filename without directory or extension.
A ranked candidate shown in column 2. A score of 0 means "keep existing".
wxString m_absPath
Absolute path, or empty for the "keep existing" row.
Transform data captured from the first FP_3DMODEL entry that referenced each missing WRL filename,...
KIBIS_MODEL * model