diff --git a/Obj2Tiles/Options.cs b/Obj2Tiles/Options.cs index d7bbe0c..42a2ed8 100644 --- a/Obj2Tiles/Options.cs +++ b/Obj2Tiles/Options.cs @@ -42,7 +42,7 @@ public sealed class Options [Option("scale", Required = false, HelpText = "Scale for data if using units other than meters ( 1200.0/3937.0 for survey ft)", Default = 1.0)] public double Scale { get; set; } - [Option('e',"error", Required = false, HelpText = "Base error for root node", Default = 100.0)] + [Option('e',"error", Required = false, HelpText = "Base geometric error for the root node, in the model's coordinate units. When 0 (default) it is derived automatically from the model's bounding box diagonal.", Default = 0.0)] public double BaseError { get; set; } [Option("use-system-temp", Required = false, HelpText = "Uses the system temp folder", Default = false)] diff --git a/Obj2Tiles/Program.cs b/Obj2Tiles/Program.cs index 639c1fe..3a9837a 100644 --- a/Obj2Tiles/Program.cs +++ b/Obj2Tiles/Program.cs @@ -90,17 +90,47 @@ private static async Task Run(Options opts) Console.WriteLine(); Console.WriteLine($" => Tiling stage {(gpsCoords != null ? $"with GPS coords {gpsCoords}" : "")}"); - var baseError = opts.BaseError; + // Geometric error must be expressed in the model's own coordinate units because it + // drives the screen-space-error refinement of every 3D Tiles renderer. A fixed value + // is meaningless for models that are not that size, so when the caller does not force + // one (--error 0, the default) derive it from the model's bounding box diagonal. + var b = decimateRes.Bounds; + var modelDiagonal = Math.Sqrt(b.Width * b.Width + b.Height * b.Height + b.Depth * b.Depth); + var baseError = opts.BaseError > 0 ? opts.BaseError : modelDiagonal; + if (!(baseError > 0)) baseError = 1.0; + + // Coarsest decimated whole-model mesh, used to give the tileset root renderable content + // (an empty root tile leaves the model invisible in renderers that do not descend into + // the children of a content-less root). Run it through the split stage with 0 divisions, + // which keeps it as a single mesh but compresses its textures, so the bootstrap root tile + // stays small instead of embedding the full-resolution source textures. + string? rootSourceObj = null; + if (decimateRes.DestFiles.Length > 0) + { + rootSourceObj = decimateRes.DestFiles[^1]; + try + { + var rootTempDir = createTempFolder($"{pipelineId}-obj2tiles-root"); + await StagesFacade.Split(rootSourceObj, rootTempDir, 0); + var compressedRoot = Directory.GetFiles(rootTempDir, "*.obj").FirstOrDefault(); + if (compressedRoot != null) + rootSourceObj = compressedRoot; + } + catch (Exception ex) + { + Console.WriteLine($" !> Could not compress root mesh ({ex.Message}); using full-resolution coarsest mesh."); + } + } Console.WriteLine(); - Console.WriteLine($" => Tiling stage with baseError {baseError}"); + Console.WriteLine($" => Tiling stage with baseError {baseError:0.000}"); sw.Restart(); if (opts.LocalMode && (opts.Latitude != null || opts.Longitude != null)) Console.WriteLine(" !> Warning: --local overrides --lat/--lon. ECEF transform will not be applied."); - StagesFacade.Tile(destFolderSplit, opts.Output, opts.LODs, opts.BaseError, boundsMapper, gpsCoords, opts.LocalMode, opts.Octree); + StagesFacade.Tile(destFolderSplit, opts.Output, opts.LODs, baseError, boundsMapper, gpsCoords, opts.LocalMode, opts.Octree, rootSourceObj); Console.WriteLine(" ?> Tiling stage done in {0}", sw.Elapsed); } diff --git a/Obj2Tiles/Stages/TilingStage.cs b/Obj2Tiles/Stages/TilingStage.cs index f357963..0590ae9 100644 --- a/Obj2Tiles/Stages/TilingStage.cs +++ b/Obj2Tiles/Stages/TilingStage.cs @@ -12,13 +12,36 @@ namespace Obj2Tiles.Stages; public static partial class StagesFacade { public static void Tile(string sourcePath, string destPath, int lods, double baseError, Dictionary[] boundsMapper, - GpsCoords? coords = null, bool localMode = false, bool isOctree = false) + GpsCoords? coords = null, bool localMode = false, bool isOctree = false, string? rootSourceObj = null) { Console.WriteLine(" ?> Working on objs conversion"); ConvertAllB3dm(sourcePath, destPath, lods); + // Give the tileset root renderable content. The root tile spans the whole model but, in an + // octree/multi-tile layout, its geometry lives only in the child tiles, leaving the root + // empty (content: null). An empty root is legal per the 3D Tiles spec, but several renderers + // (e.g. giro3d's 3d-tiles-renderer, which hardcodes LOAD_ROOT_SIBLINGS) will not descend into + // the children of a content-less root, so the whole model never appears. Converting the + // coarsest decimated whole-model mesh into "root.b3dm" gives the root a lightweight, complete + // representation that is then refined (REPLACE) by the finer child tiles. + string? rootContentUri = null; + if (rootSourceObj != null && File.Exists(rootSourceObj)) + { + try + { + var rootB3dm = Path.Combine(destPath, "root.b3dm"); + Utils.ConvertB3dm(rootSourceObj, rootB3dm); + rootContentUri = "root.b3dm"; + Console.WriteLine($" ?> Generated root content from '{Path.GetFileName(rootSourceObj)}'"); + } + catch (Exception ex) + { + Console.WriteLine($" !> Could not generate root content ({ex.Message}); the root tile will be empty."); + } + } + Console.WriteLine(" -> Generating tileset.json"); double[] rootTransform; @@ -52,8 +75,11 @@ public static void Tile(string sourcePath, string destPath, int lods, double bas Root = new TileElement { GeometricError = baseError, - Refine = "ADD", + // REPLACE (not ADD): the root's coarse whole-model content is superseded by the finer + // child tiles as they load, so the two never render on top of each other. + Refine = "REPLACE", Transform = rootTransform, + Content = rootContentUri != null ? new Content { Uri = rootContentUri } : null, } };