diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 754cd93fd..600395c25 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -27,6 +27,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status, | CAD (ACadSharp + built-in renderer) | built in (library) | **DWG ↔ DXF**; DWG/DXF → **PDF** (vector), **SVG**, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF | | **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) | | 3D models (SharpGLTF for glTF) | built in (library) | **GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY ↔ each other**; FBX → each of them; → PNG, JPG, … (an isometric preview) | +| Pictures to 3D relief | built in | **PNG, JPG, WEBP, … (every picture ImageMagick reads) → GLB, glTF, STL, OBJ, 3MF, PLY**: a relief, brighter areas higher | | **Geospatial vector data** | built in | **GeoJSON, KML, KMZ, GPX, Shapefile ↔ each other and CSV** (longitude / latitude or WKT); Garmin FIT (activities, read only) → each of them and CSV; projected Shapefiles (UTM, Web Mercator, Lambert, Albers) are converted to WGS 84; through CSV to XLSX, JSON, … (see below) | | rhwp | bundled with the installer (`engines/rhwp`, with the Microsoft C++ runtime, see below) | HWP/HWPX → PDF, **HWP → HWPX, HWPX → HWP** | | Archives (7-Zip) | bundled with the installer (`engines/7zip`, ~2.5 MB) + built-in readers | ZIP (including ANSYS Workbench WBPZ), 7Z, RAR, TAR (+ GZ/BZ2/XZ/Z/7Z/LZ), CAB, ISO, DMG, split (.001), … **→ folder, ZIP, 7Z, TAR, TAR.GZ/BZ2/XZ**; ALZ, EGG, lzip read in-process; "Compress into one archive" for any files | @@ -1102,13 +1103,26 @@ with one light and flat shading per triangle, vertex colours or the texture inte at 2048 px and scaled down to 1024 px for smooth edges, on a transparent background. The preset's image options (resize, background for JPG, quality) apply as for other pictures. +**Pictures → 3D relief** (`ImageTo3DConverter`, engine `image3d`): every picture ImageMagick reads (PNG, JPG, WebP, +HEIC, camera RAW, …; not EMF/WMF) becomes a relief in any of the six formats, only as a direct conversion (a picture +never reaches a model through another format). The picture is turned upright and scaled down to at most 200 × 200 +grid points, one per pixel; its longer side is **100 mm** (from the first pixel's centre to the last one's), the other in +proportion. Each point stands on a **2 mm base** plus **8 mm × its brightness** (Rec. 601 luma) × its opacity, so white +is 10 mm high and black or transparent pixels 2 mm. The mesh is one closed solid (the top grid, the same grid flat +underneath and four walls; every edge is shared by two triangles), so slicers take it as it is, and the picture reads +upright seen from above. GLB, glTF and OBJ get the picture as the material's texture (a PNG at the grid's size), 3MF and +PLY vertex colours, STL none; transparent pixels are light grey (#D9D9D9) and the walls take their edge pixels' colour. +A 200 × 200 grid is about 160,000 triangles, some 8 MB as STL. + Tests (`tests/Filee.Engines.Tests/ModelTests.cs`) build a cube, a sphere and a fan of coloured triangles in code and convert each through every format pair, checking triangle counts, bounds, signed volume (winding), normals, UVs, colours and materials where both formats keep them; hand-written STL, OBJ, PLY, 3MF and glTF files cover the reading details, and a generated 1,000,000-triangle STL the speed. `FbxTests.cs` writes FBX files in code (`FbxBuilder`: binary 7.4 and 7.5, plain and compressed arrays, ASCII): a cube under a moved parent, turned and scaled, with a templated material and an embedded or neighbouring texture, Z-up files in metres, rotation orders, concave polygons and -materials by polygon, and every cut or flipped byte of a file, which must read or fail with a clear message. +materials by polygon, and every cut or flipped byte of a file, which must read or fail with a clear message. The relief +tests draw pictures in code and check that the mesh is closed, the sizes and heights above, transparency, the 200-point +grid and every output format. ## Geospatial data (GeoJSON, KML / KMZ, GPX, Shapefile, CSV, FIT) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 405cde662..131afaae9 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -200,6 +200,8 @@ "engine.cad.description": "رسومات AutoCAD: التحويل بين DWG وDXF، وتحويل كليهما إلى PDF وSVG وPNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "التحويل بين النماذج ثلاثية الأبعاد: GLB وglTF وSTL وOBJ و3MF وPLY، وتحويل FBX إلى أي منها، وإنشاء معاينة PNG.", + "engine.image3d.name": "صور إلى نقش بارز ثلاثي الأبعاد (مدمج)", + "engine.image3d.description": "تحويل الصور إلى نماذج نقش بارز ثلاثية الأبعاد للطباعة أو العرض: المناطق الأفتح أعلى، فوق قاعدة مسطحة. GLB وglTF وSTL وOBJ و3MF وPLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "التحويل بين البيانات الجغرافية المتجهية: GeoJSON وKML وKMZ وGPX وShapefile وCSV (خطوط طول وعرض أو WKT).\nتُحفَظ النتائج بخطوط طول وعرض وفق WGS 84؛ وتُحوَّل ملفات Shapefile ذات الإحداثيات المسقطة (UTM وWeb Mercator وLambert وAlbers).", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 785c03dd9..711da48b5 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -200,6 +200,8 @@ "engine.cad.description": "AutoCAD-Zeichnungen: DWG ↔ DXF und beide in PDF, SVG und PNG.", "engine.model3d.name": "3D-Modelle (integriert)", "engine.model3d.description": "3D-Modelle: Konvertiert GLB, glTF, STL, OBJ, 3MF und PLY untereinander, FBX in jedes dieser Formate und erstellt eine PNG-Vorschau.", + "engine.image3d.name": "Bilder zu 3D-Relief (integriert)", + "engine.image3d.description": "Bilder als 3D-Reliefmodelle zum Drucken oder Ansehen: Hellere Bereiche stehen höher, auf einer flachen Grundplatte. GLB, glTF, STL, OBJ, 3MF und PLY.", "engine.geo.name": "Vektor-Geodaten (integriert)", "engine.geo.description": "Vektor-Geodaten: Konvertiert GeoJSON, KML, KMZ, GPX, Shapefile und CSV (Längen-/Breitengrad oder WKT) untereinander.\nErgebnisse verwenden WGS-84-Längen- und Breitengrade. Projizierte Shapefiles (UTM, Web Mercator, Lambert, Albers) werden umgerechnet.", "engine.archive.name": "Archive (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index bf35bdd9d..f8189700f 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -207,6 +207,8 @@ "engine.cad.description": "AutoCAD drawings: DWG ↔ DXF, and both to PDF, SVG and PNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "3D models: GLB, glTF, STL, OBJ, 3MF and PLY into each other, FBX into any of them, and a PNG preview.", + "engine.image3d.name": "Pictures to 3D relief (built-in)", + "engine.image3d.description": "Pictures to 3D relief models for printing or viewing: brighter areas stand higher, on a flat base. GLB, glTF, STL, OBJ, 3MF and PLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "Geospatial vector data: GeoJSON, KML, KMZ, GPX, Shapefile and CSV (longitude/latitude or WKT) into each other.\nResults are WGS 84 longitude/latitude; projected Shapefiles (UTM, Web Mercator, Lambert, Albers) are converted.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8c5a6b7d8..879f69fab 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -200,6 +200,8 @@ "engine.cad.description": "Planos de AutoCAD: DWG ↔ DXF, y ambos a PDF, SVG y PNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "Modelos 3D: GLB, glTF, STL, OBJ, 3MF y PLY entre sí, FBX a cualquiera de ellos, además de una vista previa en PNG.", + "engine.image3d.name": "Imágenes a relieve 3D (integrado)", + "engine.image3d.description": "Imágenes a modelos 3D en relieve para imprimir o ver: las zonas más claras quedan más altas, sobre una base plana. GLB, glTF, STL, OBJ, 3MF y PLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "Datos vectoriales geoespaciales: GeoJSON, KML, KMZ, GPX, Shapefile y CSV (longitud/latitud o WKT) entre sí.\nEl resultado es longitud/latitud WGS 84; los sistemas proyectados de Shapefile (UTM, Web Mercator, Lambert, Albers) se convierten.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index a1e32fffd..5770c2ee0 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -207,6 +207,8 @@ "engine.cad.description": "Dessins AutoCAD : DWG ↔ DXF, et les deux en PDF, SVG et PNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "Modèles 3D : GLB, glTF, STL, OBJ, 3MF et PLY entre eux, FBX vers chacun d'eux, et un aperçu PNG.", + "engine.image3d.name": "Images vers relief 3D (intégré)", + "engine.image3d.description": "Images en modèles 3D en relief, à imprimer ou à afficher : les zones plus claires sont plus hautes, sur un socle plat. GLB, glTF, STL, OBJ, 3MF et PLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "Données géospatiales vectorielles : GeoJSON, KML, KMZ, GPX, Shapefile et CSV (longitude/latitude ou WKT) entre eux.\nLes résultats sont en longitude/latitude WGS 84 ; les Shapefiles projetés (UTM, Web Mercator, Lambert, Albers) sont convertis.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 72684bad8..1eb26ced2 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -200,6 +200,8 @@ "engine.cad.description": "AutoCAD ड्रॉइंग: DWG ↔ DXF, और दोनों से PDF, SVG और PNG।", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "3D मॉडल: GLB, glTF, STL, OBJ, 3MF और PLY एक-दूसरे में, FBX इनमें से किसी में भी, और PNG प्रीव्यू।", + "engine.image3d.name": "चित्र से 3D रिलीफ़ (बिल्ट-इन)", + "engine.image3d.description": "चित्रों को प्रिंट करने या देखने के लिए 3D रिलीफ़ मॉडल में: हल्के हिस्से ऊँचे उठते हैं, एक समतल आधार पर। GLB, glTF, STL, OBJ, 3MF और PLY।", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "भू-स्थानिक वेक्टर डेटा: GeoJSON, KML, KMZ, GPX, Shapefile और CSV (देशांतर/अक्षांश या WKT) एक-दूसरे में।\nनतीजे WGS 84 देशांतर/अक्षांश में होते हैं। प्रोजेक्टेड Shapefile (UTM, Web Mercator, Lambert, Albers) कन्वर्ट किए जाते हैं।", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 08060560b..1ada91e39 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -200,6 +200,8 @@ "engine.cad.description": "Gambar AutoCAD: DWG ↔ DXF, dan keduanya ke PDF, SVG, serta PNG.", "engine.model3d.name": "Model 3D (bawaan)", "engine.model3d.description": "Model 3D: GLB, glTF, STL, OBJ, 3MF, dan PLY saling konversi, FBX ke format mana pun, serta pratinjau gambar PNG.", + "engine.image3d.name": "Gambar ke relief 3D (bawaan)", + "engine.image3d.description": "Gambar menjadi model relief 3D untuk dicetak atau dilihat: bagian yang lebih terang lebih tinggi, di atas alas datar. GLB, glTF, STL, OBJ, 3MF, dan PLY.", "engine.geo.name": "Data vektor geospasial (bawaan)", "engine.geo.description": "Data vektor geospasial: GeoJSON, KML, KMZ, GPX, Shapefile, dan CSV (garis bujur/lintang atau WKT) saling konversi.\nHasilnya adalah garis bujur/lintang WGS 84; Shapefile terproyeksi (UTM, Web Mercator, Lambert, Albers) otomatis dikonversi.", "engine.archive.name": "Arsip (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index b14f3f9c8..df6a74990 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -200,6 +200,8 @@ "engine.cad.description": "Disegni AutoCAD: DWG ↔ DXF, ed entrambi in PDF, SVG e PNG.", "engine.model3d.name": "Modelli 3D (integrato)", "engine.model3d.description": "Modelli 3D: GLB, glTF, STL, OBJ, 3MF e PLY convertibili tra loro, FBX in uno qualsiasi di essi, e un'anteprima PNG.", +"engine.image3d.name": "Immagini in rilievo 3D (integrato)", +"engine.image3d.description": "Immagini in modelli 3D a rilievo da stampare o visualizzare: le zone più chiare sono più alte, su una base piatta. GLB, glTF, STL, OBJ, 3MF e PLY.", "engine.geo.name": "Dati vettoriali geospaziali (integrato)", "engine.geo.description": "Dati vettoriali geospaziali: GeoJSON, KML, KMZ, GPX, Shapefile e CSV (longitudine/latitudine o WKT) convertibili tra loro.\nI risultati sono in longitudine/latitudine WGS 84; gli Shapefile proiettati (UTM, Web Mercator, Lambert, Albers) vengono convertiti.", "engine.archive.name": "Archivi (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 4697eea35..ccc16c848 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -200,6 +200,8 @@ "engine.cad.description": "AutoCAD図面に対応します。DWGとDXFの相互変換、および両形式からPDF、SVG、PNGへの変換ができます。", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "3Dモデル(GLB、glTF、STL、OBJ、3MF、PLY)を相互変換します。FBXから各形式への変換と、PNGプレビューの作成にも対応します。", + "engine.image3d.name": "画像から3Dレリーフ(内蔵)", + "engine.image3d.description": "画像を印刷や表示用の3Dレリーフモデルに変換します。明るい部分ほど高くなり、平らな土台に乗ります。GLB、glTF、STL、OBJ、3MF、PLYに対応します。", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "地理空間ベクターデータ(GeoJSON、KML、KMZ、GPX、Shapefile、経度・緯度またはWKTのCSV)を相互変換します。\n結果はWGS 84の経度・緯度です。投影座標系のShapefile(UTM、Webメルカトル、ランベルト、アルベルス)も変換します。", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 3517a071c..6cfe7e31e 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -207,6 +207,8 @@ "engine.cad.description": "오토캐드 도면: DWG ↔ DXF, 그리고 둘 다 PDF, SVG, PNG로.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "3D 모델: GLB, glTF, STL, OBJ, 3MF, PLY를 서로 변환하고, FBX도 이 형식들로 바꾸고, PNG 미리보기도 만들어요.", + "engine.image3d.name": "이미지를 3D 부조로 (내장)", + "engine.image3d.description": "이미지를 출력하거나 볼 수 있는 3D 부조 모델로 바꿔요. 밝은 부분일수록 높이 솟고, 평평한 받침 위에 놓여요. GLB, glTF, STL, OBJ, 3MF, PLY로 만들어요.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "지리 공간 벡터 데이터: GeoJSON, KML, KMZ, GPX, Shapefile, CSV(경도·위도 또는 WKT)를 서로 변환해요.\n결과는 WGS 84 경위도예요. Shapefile의 투영 좌표계(UTM, 웹 메르카토르, 람베르트, 알버스)는 변환해요.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 6670e9f28..f63e0d93b 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -200,6 +200,8 @@ "engine.cad.description": "AutoCAD रेखाचित्रहरू: DWG ↔ DXF, र दुवैबाट PDF, SVG र PNG।", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "थ्रीडी मोडलहरू: GLB, glTF, STL, OBJ, 3MF र PLY एकअर्कामा, FBX यीमध्ये कुनैमा पनि, र PNG पूर्वावलोकन।", + "engine.image3d.name": "चित्रबाट थ्रीडी रिलिफ (बिल्ट-इन)", + "engine.image3d.description": "चित्रहरूलाई छाप्न वा हेर्नका लागि थ्रीडी रिलिफ मोडलमा: उज्यालो भागहरू अग्लो हुन्छन्, समतल आधारमाथि। GLB, glTF, STL, OBJ, 3MF र PLY।", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "भौगोलिक भेक्टर डाटा: GeoJSON, KML, KMZ, GPX, Shapefile र CSV (देशान्तर/अक्षांश वा WKT) एकअर्कामा।\nपरिणामहरू WGS 84 देशान्तर/अक्षांशमा हुन्छन्; प्रोजेक्टेड Shapefile (UTM, Web Mercator, Lambert, Albers) रूपान्तरण गरिन्छ।", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 080ed62f9..7b812e87f 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -207,6 +207,8 @@ "engine.cad.description": "Rysunki AutoCAD: DWG ↔ DXF, oba także do PDF, SVG i PNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "Modele 3D: GLB, glTF, STL, OBJ, 3MF i PLY między sobą, FBX do każdego z nich oraz podgląd PNG.", + "engine.image3d.name": "Obrazy na płaskorzeźbę 3D (wbudowany)", + "engine.image3d.description": "Obrazy jako modele 3D w formie płaskorzeźby do druku lub oglądania: jaśniejsze miejsca są wyżej, na płaskiej podstawie. GLB, glTF, STL, OBJ, 3MF i PLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "Wektorowe dane geoprzestrzenne: GeoJSON, KML, KMZ, GPX, Shapefile i CSV (długość/szerokość lub WKT) wzajemnie.\nWynik to długość i szerokość WGS 84; układy rzutowane w Shapefile (UTM, Web Mercator, Lambert, Albers) są przeliczane.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index f16ca02ef..de0897a7c 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -207,6 +207,8 @@ "engine.cad.description": "Desenhos do AutoCAD: DWG ↔ DXF, e os dois para PDF, SVG e PNG.", "engine.model3d.name": "Modelos 3D (mecanismo integrado)", "engine.model3d.description": "Modelos 3D: GLB, glTF, STL, OBJ, 3MF e PLY entre si, FBX para qualquer um deles, e uma prévia em PNG.", + "engine.image3d.name": "Imagens para relevo 3D (mecanismo integrado)", + "engine.image3d.description": "Imagens em modelos 3D em relevo para imprimir ou visualizar: as áreas mais claras ficam mais altas, sobre uma base plana. GLB, glTF, STL, OBJ, 3MF e PLY.", "engine.geo.name": "Dados geoespaciais vetoriais (mecanismo integrado)", "engine.geo.description": "Dados vetoriais geoespaciais: GeoJSON, KML, KMZ, GPX, Shapefile e CSV (longitude/latitude ou WKT) entre si.\nOs resultados são longitude/latitude em WGS 84; Shapefiles projetados (UTM, Web Mercator, Lambert, Albers) são convertidos.", "engine.archive.name": "Arquivos compactados (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 347589b0b..4bd6311c7 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -207,6 +207,8 @@ "engine.cad.description": "Desenhos AutoCAD: DWG ↔ DXF e ambos para PDF, SVG e PNG.", "engine.model3d.name": "Modelos 3D (motor integrado)", "engine.model3d.description": "Converte modelos 3D entre GLB, glTF, STL, OBJ, 3MF e PLY, de FBX para qualquer um destes formatos e para uma pré-visualização PNG.", + "engine.image3d.name": "Imagens para relevo 3D (motor integrado)", + "engine.image3d.description": "Converte imagens em modelos 3D em relevo para imprimir ou visualizar: as zonas mais claras ficam mais altas, sobre uma base plana. GLB, glTF, STL, OBJ, 3MF e PLY.", "engine.geo.name": "Dados geoespaciais vetoriais (motor integrado)", "engine.geo.description": "Converte dados geoespaciais vetoriais entre GeoJSON, KML, KMZ, GPX, Shapefile e CSV (longitude/latitude ou WKT).\nOs resultados usam longitude/latitude WGS 84; os ficheiros Shapefile com coordenadas projetadas (UTM, Web Mercator, Lambert, Albers) são convertidos.", "engine.archive.name": "Ficheiros comprimidos (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index f95f60ac5..0686144c0 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -207,6 +207,8 @@ "engine.cad.description": "Чертежи AutoCAD: DWG ↔ DXF, и оба — в PDF, SVG и PNG.", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "3D-модели: GLB, glTF, STL, OBJ, 3MF и PLY друг в друга, FBX — в любой из них, плюс превью в PNG.", + "engine.image3d.name": "Изображения в 3D-рельеф (встроенный)", + "engine.image3d.description": "Изображения в 3D-модели рельефа для печати или просмотра: светлые участки выше, на плоском основании. GLB, glTF, STL, OBJ, 3MF и PLY.", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "Векторные геоданные: GeoJSON, KML, KMZ, GPX, Shapefile и CSV (долгота/широта или WKT) друг в друга.\nРезультат — долгота и широта в WGS 84; Shapefile в проекциях (UTM, Web Mercator, Lambert, Albers) пересчитываются.", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 5858e0427..79ce2ec2f 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -200,6 +200,8 @@ "engine.cad.description": "แบบแปลน AutoCAD: DWG ↔ DXF และแปลงทั้งสองเป็น PDF, SVG และ PNG", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "โมเดล 3D: GLB, glTF, STL, OBJ, 3MF และ PLY สลับกันได้ FBX เป็นฟอร์แมตใดก็ได้ และแสดงตัวอย่างเป็น PNG", + "engine.image3d.name": "รูปภาพเป็นภาพนูน 3D (ในตัว)", + "engine.image3d.description": "แปลงรูปภาพเป็นโมเดลภาพนูน 3D สำหรับพิมพ์หรือดู: ส่วนที่สว่างกว่าจะสูงกว่า บนฐานเรียบ GLB, glTF, STL, OBJ, 3MF และ PLY", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "ข้อมูลเวกเตอร์เชิงพื้นที่: GeoJSON, KML, KMZ, GPX, Shapefile และ CSV (ลองจิจูด/ละติจูด หรือ WKT) สลับกันได้\nผลลัพธ์เป็นลองจิจูด/ละติจูด WGS 84 ส่วน Shapefile ที่มีพิกัดโปรเจกชัน (UTM, Web Mercator, Lambert, Albers) จะได้รับการแปลงค่า", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 00a08c22f..d90546519 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -200,6 +200,8 @@ "engine.cad.description": "AutoCAD çizimleri: DWG ↔ DXF ve her ikisinden PDF, SVG ve PNG.", "engine.model3d.name": "3B modeller (yerleşik)", "engine.model3d.description": "3B modeller: GLB, glTF, STL, OBJ, 3MF ve PLY arasında dönüştürme; FBX'ten bunların tümüne ve PNG önizlemesine dönüştürme.", + "engine.image3d.name": "Resimden 3B kabartma (yerleşik)", + "engine.image3d.description": "Resimleri baskı veya görüntüleme için 3B kabartma modellerine dönüştürür: açık renkli alanlar daha yüksekte, düz bir taban üzerinde. GLB, glTF, STL, OBJ, 3MF ve PLY.", "engine.geo.name": "Coğrafi vektör verisi (yerleşik)", "engine.geo.description": "Coğrafi vektör verisi: GeoJSON, KML, KMZ, GPX, Shapefile ve CSV (enlem/boylam veya WKT) arasında dönüştürme.\nSonuçlar WGS 84 enlem/boylamıdır; projeksiyonlu Shapefile'lar (UTM, Web Mercator, Lambert, Albers) dönüştürülür.", "engine.archive.name": "Arşivler (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 917002dae..371190ac5 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -207,6 +207,8 @@ "engine.cad.description": "AutoCAD 图纸:DWG ↔ DXF,并可转为 PDF、SVG 和 PNG。", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "GLB、glTF、STL、OBJ、3MF 和 PLY 3D 模型互相转换,FBX 可转换为其中任意格式,并可生成 PNG 预览。", + "engine.image3d.name": "图片转 3D 浮雕(内置)", + "engine.image3d.description": "将图片转换为可打印或查看的 3D 浮雕模型:越亮的区域越高,下方是平整的底座。支持 GLB、glTF、STL、OBJ、3MF 和 PLY。", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "地理空间矢量数据 GeoJSON、KML、KMZ、GPX、Shapefile 和 CSV(经纬度或 WKT)互相转换。\n结果为 WGS 84 经纬度,Shapefile 的投影坐标系(UTM、Web 墨卡托、兰伯特、阿尔伯斯)会被转换。", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index d3e200078..80e034504 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -207,6 +207,8 @@ "engine.cad.description": "AutoCAD 圖紙:DWG ↔ DXF,並可轉為 PDF、SVG 和 PNG。", "engine.model3d.name": "3D models (built-in)", "engine.model3d.description": "GLB、glTF、STL、OBJ、3MF 和 PLY 3D 模型互相轉換,FBX 可轉換為其中任意格式,並可產生 PNG 預覽。", + "engine.image3d.name": "圖片轉 3D 浮雕(內建)", + "engine.image3d.description": "將圖片轉換為可列印或檢視的 3D 浮雕模型:越亮的區域越高,下方是平整的底座。支援 GLB、glTF、STL、OBJ、3MF 和 PLY。", "engine.geo.name": "Geospatial vector data (built-in)", "engine.geo.description": "地理空間向量資料 GeoJSON、KML、KMZ、GPX、Shapefile 和 CSV(經緯度或 WKT)互相轉換。\n結果為 WGS 84 經緯度,Shapefile 的投影座標系統(UTM、Web 麥卡托、蘭伯特、阿爾伯斯)會被轉換。", "engine.archive.name": "Archives (7-Zip)", diff --git a/src/Filee.Engines/EngineRegistry.cs b/src/Filee.Engines/EngineRegistry.cs index 374de2fb9..ed6e3f9b8 100644 --- a/src/Filee.Engines/EngineRegistry.cs +++ b/src/Filee.Engines/EngineRegistry.cs @@ -66,6 +66,7 @@ public static IReadOnlyList CreateAll(EngineEnvironment env, ILogger new FfmpegConverter(loggers?.CreateLogger()), new CadConverter(), new ModelConverter(), + new ImageTo3DConverter(), new GeoConverter(), new EbookConverter(), new PandocConverter(), diff --git a/src/Filee.Engines/Models3D/ImageTo3DConverter.cs b/src/Filee.Engines/Models3D/ImageTo3DConverter.cs new file mode 100644 index 000000000..9f2e6069a --- /dev/null +++ b/src/Filee.Engines/Models3D/ImageTo3DConverter.cs @@ -0,0 +1,230 @@ +// Pictures → 3D relief models (GLB, glTF, STL, OBJ, 3MF, PLY): a closed solid whose top rises with the picture's +// brightness, on a flat base. + +using System.Numerics; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Engines.Infrastructure; +using Filee.Engines.Magick; +using ImageMagick; + +namespace Filee.Engines.Models3D; + +/// +/// Turns a picture into a relief: one closed mesh with the picture's brightness as the height of its top (brighter is +/// higher, transparent is as low as black), four side walls and a flat base, written through +/// . +/// +public sealed class ImageTo3DConverter : IConverter +{ + /// Length of the model's longest side (the picture's longer side), in millimetres. + internal const float LongestSideMm = 100f; + + /// Thickness of the flat base under the relief, in millimetres. + internal const float BaseMm = 2f; + + /// How much higher a white pixel stands than a black (or transparent) one, in millimetres. + internal const float ReliefMm = 8f; + + /// Most grid points along a side: larger pictures are scaled down to this, one point per pixel. + internal const int MaxGrid = 200; + + /// Light grey that transparent pixels are blended into (sRGB, 0–1). + internal static readonly Vector3 Background = new(0.85f, 0.85f, 0.85f); + + /// Targets that keep a texture: the picture goes there as one; the others get vertex colours. + private static readonly HashSet Textured = ["glb", "gltf", "obj"]; + + /// Every picture format the image engine reads (its sources in the image category; not EMF/WMF). + public static readonly string[] InputFormats = + [.. ImageEncoder.Sources.Where(f => FormatRegistry.FindById(f)?.Category == FormatCategory.Image)]; + + public string Id => "image3d"; + public string DisplayName => "Pictures to 3D relief (built-in)"; + + /// A model of a 200 × 200 grid is small, but the picture may be large while it is read. + public int MaxParallelism => Math.Clamp(Environment.ProcessorCount / 2, 1, 4); + + /// + /// Only as the whole route (a picture straight to a model), so a picture never reaches a model through another + /// format, and nothing else passes through here. + /// + public IReadOnlyList Edges { get; } = + [ + .. from source in InputFormats + from target in ModelConverter.Formats + select new ConversionEdge(source, target, Cost: 25, DirectOnly: true) + ]; + + public EngineStatus GetStatus() => + EngineStatus.Available("Pictures → 3D relief (GLB, glTF, STL, OBJ, 3MF, PLY)", EngineVersions.BuiltIn); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + Task.Run>(() => + { + progress?.Report(0.1); + Model3D model; + using (var images = ImageReader.Read(step.InputPath, step.From, step.Preset)) + { + var picture = ImageEncoder.SelectFrames(images, step.From, "png").FirstOrDefault() + ?? throw new InvalidDataException("The file holds no picture."); + progress?.Report(0.3); + model = Build(picture, Textured.Contains(step.To), cancellationToken); + } + progress?.Report(0.7); + + var outputs = new List(); + try + { + ModelConverter.Write(model, step.To, step.Output, step.Preset.Image, outputs, cancellationToken); + } + catch + { + foreach (var output in outputs) + { + try { File.Delete(output); } catch { /* best effort */ } + } + throw; + } + + progress?.Report(1.0); + return (IReadOnlyList)outputs; + }, cancellationToken); + + /// + /// The relief of (turned upright and scaled down to points a side). + /// With the picture is the material's texture; otherwise each vertex has its pixel's + /// colour. Not both: glTF multiplies them, which would darken the picture. + /// + internal static Model3D Build(IMagickImage picture, bool texture, CancellationToken cancellationToken) + { + using var image = picture.Clone(); + image.AutoOrient(); + if (image.ColorSpace is not (ColorSpace.sRGB or ColorSpace.RGB)) + image.ColorSpace = ColorSpace.sRGB; + if (image.Width > MaxGrid || image.Height > MaxGrid) + image.Resize(new MagickGeometry(MaxGrid, MaxGrid)); // keeps the aspect ratio + var w = (int)image.Width; + var h = (int)image.Height; + if (w < 2 || h < 2) + throw new InvalidDataException("The picture is too small for a relief: it needs at least 2 × 2 pixels."); + + var rgba = image.GetPixels().ToByteArray(PixelMapping.RGBA) + ?? throw new InvalidDataException("The picture's pixels could not be read."); + return Build(rgba, w, h, texture, cancellationToken); + } + + /// The relief of × RGBA pixels (8 bits each, rows top down). + internal static Model3D Build(byte[] rgba, int w, int h, bool texture, CancellationToken cancellationToken) + { + // The longer side is LongestSideMm from the first pixel's centre to the last one's, the other in proportion. + var step = LongestSideMm / (Math.Max(w, h) - 1); + var width = step * (w - 1); + var depth = step * (h - 1); + + // Per pixel: the height of the top, and the colour with transparency blended into the background. + var heights = new float[w * h]; + var colors = new Vector4[w * h]; + for (var p = 0; p < w * h; p++) + { + var rgb = new Vector3(rgba[p * 4], rgba[p * 4 + 1], rgba[p * 4 + 2]) / 255f; + var alpha = rgba[p * 4 + 3] / 255f; + heights[p] = BaseMm + ReliefMm * Vector3.Dot(rgb, new Vector3(0.299f, 0.587f, 0.114f)) * alpha; + colors[p] = new Vector4(rgb * alpha + Background * (1 - alpha), 1); + } + + var part = new MeshPart { Name = "relief", Normals = [], TexCoords = texture ? [] : null, Colors = texture ? null : [] }; + var model = new Model3D(); + model.Parts.Add(part); + + // Picture rows run along +Z (away from the top edge) and columns along +X, so the picture reads upright when + // seen from above; the base is at Y = 0 and the model centred on X and Z. + Vector3 At(int pixel, float y) => new((pixel % w) * step - width / 2, y, (pixel / w) * step - depth / 2); + Vector2 Uv(int pixel) => new((pixel % w) / (float)(w - 1), 1 - (pixel / w) / (float)(h - 1)); + int Vertex(int pixel, float y) => + part.AddVertex(At(pixel, y), Vector3.Zero, Uv(pixel), colors[pixel]); + + // Top and base: a grid of vertices each, two triangles per square (the base's facing down). + foreach (var top in new[] { true, false }) + { + cancellationToken.ThrowIfCancellationRequested(); + var first = part.VertexCount; + for (var p = 0; p < w * h; p++) + Vertex(p, top ? heights[p] : 0); + for (var j = 0; j < h - 1; j++) + { + for (var i = 0; i < w - 1; i++) + { + var a = first + j * w + i; + var b = a + 1; + var c = a + w; + var d = c + 1; + if (top) + { + part.AddTriangle(a, c, b); + part.AddTriangle(b, c, d); + } + else + { + part.AddTriangle(a, b, c); + part.AddTriangle(b, d, c); + } + } + } + } + + // Walls: around the edge counter-clockwise seen from above (back left to right, right to the front, front to + // the left, left to the back), each side with its own vertices so the corners stay sharp. A wall has its edge + // pixels' colour from top to bottom. + int[][] sides = + [ + [.. Enumerable.Range(0, w).Select(i => (h - 1) * w + i)], + [.. Enumerable.Range(0, h).Select(j => (h - 1 - j) * w + w - 1)], + [.. Enumerable.Range(0, w).Select(i => w - 1 - i)], + [.. Enumerable.Range(0, h).Select(j => j * w)], + ]; + foreach (var side in sides) + { + cancellationToken.ThrowIfCancellationRequested(); + var first = part.VertexCount; + foreach (var pixel in side) + { + Vertex(pixel, heights[pixel]); + Vertex(pixel, 0); + } + for (var k = 0; k < side.Length - 1; k++) + { + var top0 = first + k * 2; + var bottom0 = top0 + 1; + var top1 = top0 + 2; + var bottom1 = top0 + 3; + part.AddTriangle(bottom0, bottom1, top1); + part.AddTriangle(bottom0, top1, top0); + } + } + + part.ComputeNormals(); + + if (texture) + { + var rgb = new byte[w * h * 3]; + for (var p = 0; p < w * h; p++) + { + rgb[p * 3] = MeshMath.ToByte(colors[p].X); + rgb[p * 3 + 1] = MeshMath.ToByte(colors[p].Y); + rgb[p * 3 + 2] = MeshMath.ToByte(colors[p].Z); + } + using var picture = new MagickImage(); + picture.ReadPixels(rgb, new PixelReadSettings((uint)w, (uint)h, StorageType.Char, PixelMapping.RGB)); + model.Materials.Add(new Material3D + { + Name = "relief", + BaseColorTexture = new Texture3D(picture.ToByteArray(MagickFormat.Png), "png", "relief"), + }); + part.Material = 0; + } + + model.Validate(); + return model; + } +} diff --git a/tests/Filee.Engines.Tests/ModelTests.cs b/tests/Filee.Engines.Tests/ModelTests.cs index 9b2499e45..e6cfe1632 100644 --- a/tests/Filee.Engines.Tests/ModelTests.cs +++ b/tests/Filee.Engines.Tests/ModelTests.cs @@ -868,4 +868,191 @@ private static (List<(Vector3 Normal, Vector3 A, Vector3 B, Vector3 C)> Facets, } return (facets, min, max); } + + // ───────────────────────── Pictures → relief (image3d) ───────────────────────── + + [Fact] + public void A_picture_reaches_every_3d_format_in_one_step_of_the_relief_engine_and_nothing_else_moves() + { + Assert.Contains("jpg", ImageTo3DConverter.InputFormats); + Assert.DoesNotContain("jpeg", ImageTo3DConverter.InputFormats); + Assert.All(ImageTo3DConverter.InputFormats, + f => Assert.Equal(Filee.Core.Formats.FormatCategory.Image, Filee.Core.Formats.FormatRegistry.Get(f).Category)); + + var planner = fx.Catalog.CreatePlanner(); + foreach (var from in ImageTo3DConverter.InputFormats) + foreach (var to in Formats) + Assert.Equal("image3d", Assert.Single(planner.Plan(from, to)!.Steps).Converter.Id); + + // Pictures still go to pictures with ImageMagick, models to models and pictures with the 3D engine, and nothing + // else reaches a model through a picture. + Assert.Equal("magick", Assert.Single(planner.Plan("png", "jpg")!.Steps).Converter.Id); + Assert.Equal("magick", Assert.Single(planner.Plan("jpg", "png")!.Steps).Converter.Id); + Assert.Equal("model3d", Assert.Single(planner.Plan("stl", "png")!.Steps).Converter.Id); + Assert.Equal("model3d", Assert.Single(planner.Plan("glb", "stl")!.Steps).Converter.Id); + foreach (var from in new[] { "svg", "pdf", "docx", "mp4" }) + Assert.DoesNotContain(planner.Plan(from, "stl")?.Steps ?? [], s => s.Converter.Id == "image3d"); + } + + [Fact] + public void A_relief_is_a_closed_solid_100_mm_long_on_a_2_mm_base() + { + // Wide and tall pictures: the longer side is 100 mm (pixel centre to pixel centre), the other in proportion. + foreach (var (w, h) in new[] { (64, 32), (20, 50) }) + { + using var picture = Picture(w, h, (x, y) => Gray((byte)((x * 37 + y * 91) % 256))); + foreach (var texture in new[] { true, false }) + { + var model = ImageTo3DConverter.Build(picture, texture, CancellationToken.None); + AssertClosed(model); + var (min, max) = model.Bounds(); + var longest = Math.Max(w, h) - 1; + Assert.Equal(100f * (w - 1) / longest, max.X - min.X, 0.01f); + Assert.Equal(100f * (h - 1) / longest, max.Z - min.Z, 0.01f); + Assert.Equal(0f, min.Y); + } + } + + // An all-black picture is just the base: a flat 2 mm slab. + using var black = Picture(10, 10, (_, _) => Gray(0)); + var slab = ImageTo3DConverter.Build(black, texture: false, CancellationToken.None); + AssertClosed(slab); + Assert.Equal(2f, slab.Bounds().Max.Y, 0.001f); + } + + [Fact] + public void A_white_pixel_stands_8_mm_higher_than_a_black_one() + { + // Left half black, right half white. + using var picture = Picture(8, 4, (x, _) => Gray(x < 4 ? (byte)0 : (byte)255)); + var model = ImageTo3DConverter.Build(picture, texture: false, CancellationToken.None); + var part = Assert.Single(model.Parts); + + // The top grid is the first w × h vertices, row by row. + var heights = Enumerable.Range(0, 8 * 4).Select(v => part.Position(v).Y).ToList(); + for (var v = 0; v < heights.Count; v++) + Assert.Equal(v % 8 < 4 ? 2f : 10f, heights[v], 0.001f); + Assert.Equal(8f, heights.Max() - heights.Min(), 0.001f); + + // The picture reads upright from above: its left column is at −X, its top row at −Z (away from the viewer). + Assert.True(part.Position(0).X < part.Position(7).X); + Assert.True(part.Position(0).Z < part.Position(3 * 8).Z); + } + + [Fact] + public void Transparent_pixels_lie_at_the_base_in_light_grey() + { + // White, with a transparent (and black-filled, as many writers leave it) square in the middle. + using var picture = Picture(10, 10, (x, y) => x is >= 3 and < 7 && y is >= 3 and < 7 ? ((byte)0, (byte)0, (byte)0, (byte)0) : Gray(255)); + var model = ImageTo3DConverter.Build(picture, texture: false, CancellationToken.None); + AssertClosed(model); + var part = Assert.Single(model.Parts); + var hole = 5 * 10 + 5; + Assert.Equal(2f, part.Position(hole).Y, 0.001f); + Assert.Equal(10f, part.Position(0).Y, 0.001f); + AssertClose(new Vector4(ImageTo3DConverter.Background, 1), part.Color(hole), 0.01f); + AssertClose(Vector4.One, part.Color(0), 0.01f); + } + + [Fact] + public void A_large_picture_is_scaled_to_a_200_point_grid() + { + using var picture = Picture(1000, 500, (x, y) => Gray((byte)((x + y) % 256))); + var model = ImageTo3DConverter.Build(picture, texture: true, CancellationToken.None); + AssertClosed(model); + const int w = 200, h = 100; + Assert.Equal(2 * 2 * (w - 1) * (h - 1) + 2 * 2 * ((w - 1) + (h - 1)), model.TriangleCount); + var texture = Assert.Single(model.Materials).BaseColorTexture!; + using var image = new MagickImage(texture.Data); + Assert.Equal((uint)w, image.Width); + Assert.Equal((uint)h, image.Height); + } + + [Theory] + [InlineData("png")] + [InlineData("jpg")] + [InlineData("webp")] + public async Task A_picture_converts_to_every_3d_format(string from) + { + var folder = fx.NewFolder(); + // A gradient with a transparent corner (PNG and WebP keep it; JPG flattens it). + var source = EngineFixture.MakeImage(folder, "picture." + from, from switch + { + "jpg" => MagickFormat.Jpeg, + "webp" => MagickFormat.WebP, + _ => MagickFormat.Png, + }); + + foreach (var to in Formats) + { + var job = await RunAsync(source, to); + var output = job.Outputs.First(o => o.EndsWith("." + to, StringComparison.OrdinalIgnoreCase)); + var model = ModelBuilders.Load(output, to); + var (min, max) = model.Bounds(); + Assert.Equal(100f, max.X - min.X, 0.05f); // 320 × 200 pixels: wider than tall + Assert.InRange(max.Y - min.Y, 2f, 10.001f); + Assert.True(ModelBuilders.SignedVolume(model) > 0); + if (to is "stl" or "3mf") + AssertClosed(model); // vertices welded by position on reading + + // The picture is a texture where the format keeps one, vertex colours elsewhere; never both. + var part = Assert.Single(model.Parts); + if (WithTextures.Contains(to)) + { + Assert.NotNull(model.MaterialOf(part)?.BaseColorTexture); + Assert.Null(part.Colors); + } + else if (WithVertexColors.Contains(to)) + { + Assert.NotNull(part.Colors); + } + } + } + + /// A picture built from a pixel function (R, G, B, A). + private static MagickImage Picture(int width, int height, Func pixel) + { + var rgba = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + var (r, g, b, a) = pixel(x, y); + var o = (y * width + x) * 4; + (rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3]) = (r, g, b, a); + } + } + var image = new MagickImage(); + image.ReadPixels(rgba, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.RGBA)); + return image; + } + + private static (byte, byte, byte, byte) Gray(byte value) => (value, value, value, 255); + + /// + /// Closed and consistently wound: with vertices welded by position, every edge belongs to exactly two triangles, + /// once in each direction. + /// + private static void AssertClosed(Model3D model) + { + var directed = new Dictionary<((int, int, int), (int, int, int)), int>(); + foreach (var part in model.Parts) + { + for (var t = 0; t < part.TriangleCount; t++) + { + var corners = Enumerable.Range(0, 3).Select(k => ModelBuilders.Key(part.Position(part.Indices[t * 3 + k]))).ToArray(); + for (var k = 0; k < 3; k++) + { + var edge = (corners[k], corners[(k + 1) % 3]); + directed[edge] = directed.GetValueOrDefault(edge) + 1; + } + } + } + Assert.NotEmpty(directed); + foreach (var ((a, b), count) in directed) + { + Assert.True(count == 1, $"edge {a} → {b} is used {count} times"); + Assert.True(directed.ContainsKey((b, a)), $"edge {a} → {b} has no triangle on its other side"); + } + } }