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