├── .gitignore ├── LICENSE ├── README.md ├── build.sh ├── logger.go ├── main.go ├── obj-parser.go ├── obj-writer.go ├── objectfile └── structs.go ├── process-duplicates.go ├── process-merge.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | *.pprof 26 | 27 | obj-simplify 28 | obj-simplify.* 29 | 30 | bin 31 | tests 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jonne Nauha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obj-simplify 2 | 3 | There are a lot of authoring tools that produce OBJ files. The [spec](http://www.martinreddy.net/gfx/3d/OBJ.spec) is quite simple, but it still leaves a lot of room to export geometry and mesh/material combos that are not always optimal for 3D rendering engines. Artists and the exporters can also omit doing simple cleanup operations that would reduce file size, making loading and rendering the model faster. 4 | 5 | The biggest problem in an average OBJ export is the amount of draw calls that can be reduced trivially, but is rarely done in the authoring tool. 6 | 7 | This tool automates the following optimization and simplification steps. 8 | 9 | * Merge duplicate vertex `v`, normal `vn` and UV `vt` declarations. 10 | * Create objects from "multi-material" face groups. 11 | * Merge object `o` and group `g` face declarations that use the same material into a single mesh, reducing draw call overhead. 12 | * Rewrite geometry declarations. 13 | * Rewrite `o/g` to use absolute indexing and the deduplicated geometry. 14 | 15 | This tool can be destructive and contain bugs, it will not let you overwrite the source file. Keep your original files intact. The implementation does not support all the OBJ features out there. It is meant to be used on 3D-models 16 | that declare faces with `f`. All variants of face declarations in the spec are supported. Lines `l` and points `p` are also preserved and the same deduplication logic is applied to them. 17 | 18 | If a particular line in the input file is not supported by the parser, the tool will exit and print a link to submit an issue. If you are submitting an issue please attach a file that can reproduce the bug. 19 | 20 | ## Merging duplicate geometry 21 | 22 | Use `-epsilon` to tune vector equality checks, the default is `1e-6`. This can have a positive impact especially on large OBJ files. Basic cleanup like trimming trailing zeros and converting -0 into 0 to reduce file size is also executed. 23 | 24 | ## Object merging and multi-materials 25 | 26 | If your 3D-application needs to interact with multiple submeshes (`o/g`) in the model with the same material, you should not use this tool. For example an avatar model that has the same material in both gloves and your app wants to know e.g. which glove the user clicked on. This tool will merge both of the gloves face declarations to a single submesh to reduce draw calls. The visuals are the same, but the structure of the model from the code point of view can change. 27 | 28 | Multi-materials inside a single `o/g` declaration is another problem this tool tackles. These are OBJ files that set `material_1`, declare a few faces, set `material_2`, declare a few faces, rinse and repeat. This can produce huge files that have hundreds, thousands or tens of thousands meshes with small triangle counts, that all reference the same few materials. Most rendering engines will happily do those 10k draw calls if you don't do optimizations/merging in your application code after loading the model. This tool will merge all these triangles to a single draw call per material. 29 | 30 | ## Rewrites 31 | 32 | All found geometry from the source file is written at the top of the file, skipping any detected duplicates. Objects/groups are rewritten next so that they reference the deduplicated geometry indexes and are ordered per material. 33 | 34 | ## three.js 35 | 36 | I have contributed to the OBJ parser/loader in three.js and know it very well. I know what kind of files it has performance problems with and how to try to avoid them. I have also implemented some of the optimization done in this tool in JS on the client side, after the model has been loaded. But even if doable, its a waste of time to do them on each load for each user. Also certain optimizations can not be done on the client side. That being said there is nothing specific in the tool for three.js, it can help as much in other rendering engines. This tool can help you get: 37 | 38 | * Faster load over the network 39 | * Reduce filesize, possibly better compression e.g. with gzip (see `-gzip`). 40 | * Faster loading by the parser 41 | * Drop duplicates, reduce files size in general to parse less lines. 42 | * Arranging file output in a way that *might* benefit V8 etc. to optimize the execution better. 43 | * Faster rendering 44 | * Remove patterns that result in using `THREE.MultiMaterial`. 45 | * Reduce draw calls. 46 | 47 | ## Dev quickstart 48 | 49 | * [Install go](https://golang.org/doc/install) 50 | * Verify with `go version` that the tools are in your PATH 51 | 52 | ```bash 53 | # Install 54 | go get github.com/jonnenauha/obj-simplify 55 | 56 | # Run 57 | cd $GOPATH/src/github.com/jonnenauha/obj-simplify # or just run where you are if you have $GOPATH/bin in your PATH 58 | obj-simplify -in -out 59 | obj-simplify -h # for full help 60 | 61 | # Modify source code and 62 | go build 63 | ``` 64 | 65 | ## Command line options 66 | 67 | There are command line flags for configuration and disabling processing steps, see `-h` for help. 68 | 69 | ``` 70 | obj-simplify { 71 | "Input": "test.obj", 72 | "Output": "test.simplified.obj", 73 | "Workers": 32, 74 | "Gzip": -1, 75 | "Epsilon": 1e-06, 76 | "Strict": false, 77 | "Stdout": false, 78 | "Quiet": false, 79 | "NoProgress": false, 80 | "CpuProfile": false 81 | } 82 | 83 | processor #1: Duplicates 84 | - Using epsilon of 1e-06 85 | - vn deduplicate 1957 / 1957 [==================================] 100.00% 86 | - vt deduplicate 11 / 11 [======================================] 100.00% 87 | - v deduplicate 353 / 353 [====================================] 100.00% 88 | - v 386 duplicates found for 353 unique indexes (0.91%) in 4.87s 89 | - vn 11235 duplicates found for 1551 unique indexes (46%) in 6.48s 90 | - vt 11 duplicates found for 11 unique indexes (0.01%) in 5.71s 91 | - v 4920 refs replaced in 0.11s 92 | - vn 296829 refs replaced in 0.06s 93 | - vt 60 refs replaced in 0.06s 94 | 95 | processor #2: Merge 96 | - Found 88 unique materials 97 | 98 | Parse 0.41s 4% 99 | Duplicates 6.92s 82% 100 | Merge 0.01s 0.16% 101 | Write 1.03s 12% 102 | Total 8.37s 103 | 104 | Vertices 42 099 -386 -0.91% 105 | Normals 13 041 -11235 -47% 106 | UVs 76 891 -11 -0.01% 107 | 108 | Faces 162 982 109 | 110 | Groups 88 -532 -86% 111 | 112 | Lines input 519 767 113 | Lines output 295 384 -224 383 -43% 114 | 115 | File input 12.52 MB 116 | File output 10.01 MB -2.51 MB -20% 117 | ``` 118 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -rf bin 6 | mkdir -p bin/release 7 | 8 | VERSION="1.1" 9 | VERSION_HASH="$(git rev-parse --short HEAD)" 10 | VERSION_DATE="$(date -u '+%d.%m.%Y %H:%M:%S')" 11 | 12 | for goos in linux darwin windows ; do 13 | for goarch in amd64 386; do 14 | # path 15 | outdir="bin/$goos/$goarch" 16 | path="$outdir/obj-simplify" 17 | if [ $goos = windows ] ; then 18 | path=$path.exe 19 | fi 20 | 21 | # build 22 | echo -e "\nBuilding $goos/$goarch" 23 | GOOS=$goos GOARCH=$goarch go build -o $path -ldflags "-X 'main.Version=$VERSION' -X 'main.VersionHash=$VERSION_HASH' -X 'main.VersionDate=$VERSION_DATE'" 24 | echo " > `du -hc $path | awk 'NR==1{print $1;}'` `file $path`" 25 | 26 | # compress (for unique filenames to github release files) 27 | if [ $goos = windows ] ; then 28 | zip -rjX ./bin/release/$goos-$goarch.zip ./$outdir/ > /dev/null 2>&1 29 | else 30 | tar -C ./$outdir/ -cvzf ./bin/release/$goos-$goarch.tar.gz . > /dev/null 2>&1 31 | fi 32 | done 33 | done 34 | 35 | go env > .goenv 36 | source .goenv 37 | rm .goenv 38 | 39 | echo -e "\nRelease done: $(./bin/$GOOS/$GOARCH/obj-simplify --version)" 40 | for goos in linux darwin windows ; do 41 | for goarch in amd64 386; do 42 | path=bin/release/$goos-$goarch.tar.gz 43 | if [ $goos = windows ] ; then 44 | path=bin/release/$goos-$goarch.zip 45 | fi 46 | echo " > `du -hc $path | awk 'NR==1{print $1;}'` $path" 47 | done 48 | done 49 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | logwriter io.Writer 12 | ) 13 | 14 | func initLogging(stderr bool) { 15 | if stderr { 16 | logwriter = os.Stderr 17 | } else { 18 | logwriter = os.Stdout 19 | } 20 | } 21 | 22 | func logRaw(format string, args ...interface{}) { 23 | fmt.Fprintf(logwriter, format+"\n", args...) 24 | } 25 | 26 | func logTitle(format string, args ...interface{}) { 27 | logInfo(format, args...) 28 | 29 | title := strings.Repeat("-", len(fmt.Sprintf(format, args...))) 30 | if len(title) > 0 { 31 | logInfo(title) 32 | } 33 | } 34 | 35 | func logResultsIntPostfix(label string, value int, postfix string) { 36 | logInfo(fmt.Sprintf("%-15s %15s %s", label, formatInt(value), postfix)) 37 | } 38 | 39 | func logResults(label, value string) { 40 | logInfo(fmt.Sprintf("%-15s %15s", label, value)) 41 | } 42 | 43 | func logResultsPostfix(label, value, postfix string) { 44 | logInfo(fmt.Sprintf("%-15s %15s %s", label, value, postfix)) 45 | } 46 | 47 | func logInfo(format string, args ...interface{}) { 48 | if !StartParams.Quiet { 49 | logRaw(format, args...) 50 | } 51 | } 52 | 53 | func logWarn(format string, args ...interface{}) { 54 | format = "[WARN] " + format 55 | if !StartParams.Quiet { 56 | logRaw(format, args...) 57 | } 58 | } 59 | 60 | func logError(format string, args ...interface{}) { 61 | format = "[ERROR] " + format 62 | if !StartParams.Quiet { 63 | logRaw(format, args...) 64 | } 65 | } 66 | 67 | func logFatal(format string, args ...interface{}) { 68 | format = "\n[FATAL] " + format 69 | logRaw(format, args...) 70 | os.Exit(1) 71 | } 72 | 73 | func logFatalError(err error) { 74 | if err != nil { 75 | logFatal(err.Error()) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | "github.com/jonnenauha/obj-simplify/objectfile" 14 | "github.com/pkg/profile" 15 | ) 16 | 17 | var ( 18 | StartParams = startParams{ 19 | Gzip: -1, 20 | Epsilon: 1e-6, 21 | } 22 | 23 | ApplicationName = "obj-simplify" 24 | ApplicationURL = "https://github.com/jonnenauha/" + ApplicationName 25 | Version string 26 | VersionHash string 27 | VersionDate string 28 | 29 | Processors = []*processor{ 30 | &processor{Processor: Duplicates{}}, 31 | &processor{Processor: Merge{}}, 32 | } 33 | ) 34 | 35 | type startParams struct { 36 | Input string 37 | Output string 38 | 39 | Workers int 40 | Gzip int 41 | Epsilon float64 42 | 43 | Strict bool 44 | Stdout bool 45 | Quiet bool 46 | NoProgress bool 47 | CpuProfile bool 48 | } 49 | 50 | func (sp startParams) IsGzipEnabled() bool { 51 | return sp.Gzip >= gzip.BestSpeed && sp.Gzip <= gzip.BestCompression 52 | } 53 | 54 | func init() { 55 | version := false 56 | 57 | StartParams.Workers = runtime.NumCPU() * 4 58 | if StartParams.Workers < 4 { 59 | StartParams.Workers = 4 60 | } 61 | 62 | flag.StringVar(&StartParams.Input, 63 | "in", StartParams.Input, "Input file.") 64 | flag.StringVar(&StartParams.Output, 65 | "out", StartParams.Output, "Output file or directory.") 66 | 67 | flag.IntVar(&StartParams.Workers, 68 | "workers", StartParams.Workers, "Number of worker goroutines.") 69 | flag.IntVar(&StartParams.Gzip, 70 | "gzip", StartParams.Gzip, "Gzip compression level on the output for both -stdout and -out. <=0 disables compression, use 1 (best speed) to 9 (best compression) to enable.") 71 | flag.Float64Var(&StartParams.Epsilon, 72 | "epsilon", StartParams.Epsilon, "Epsilon for float comparisons.") 73 | 74 | flag.BoolVar(&StartParams.Strict, 75 | "strict", StartParams.Strict, "Errors out on spec violations, otherwise continues if the error is recoverable.") 76 | flag.BoolVar(&StartParams.Stdout, 77 | "stdout", StartParams.Stdout, "Write output to stdout. If enabled -out is ignored and logging directed to stderr. Use -quiet if you can't separate stdout from stderr (e.g. non-trivial in Windows).") 78 | flag.BoolVar(&StartParams.Quiet, 79 | "quiet", StartParams.Quiet, "Silence stdout printing.") 80 | flag.BoolVar(&StartParams.NoProgress, 81 | "no-progress", StartParams.NoProgress, "No shell progress bars.") 82 | flag.BoolVar(&StartParams.CpuProfile, 83 | "cpu-profile", StartParams.CpuProfile, "Record ./cpu.pprof profile.") 84 | flag.BoolVar(&version, 85 | "version", false, "Print version and exit, ignores -quiet.") 86 | 87 | // -no-xxx to disable post processors 88 | for _, processor := range Processors { 89 | flag.BoolVar(&processor.Disabled, processor.NameCmd(), processor.Disabled, processor.Desc()) 90 | } 91 | 92 | flag.Parse() 93 | 94 | initLogging(!StartParams.Stdout) 95 | 96 | // -version: ignores -stdout as we are about to exit 97 | if version { 98 | fmt.Printf("%s %s\n", ApplicationName, getVersion(true)) 99 | os.Exit(0) 100 | } 101 | 102 | if StartParams.Workers < 1 { 103 | logFatal("-workers must be a positive number, given: %d", StartParams.Workers) 104 | } 105 | 106 | // -gzip 107 | if StartParams.Gzip < -1 || StartParams.Gzip > gzip.BestCompression { 108 | logFatal("-gzip must be -1 to 9, given: %d", StartParams.Gzip) 109 | } 110 | 111 | // -in 112 | StartParams.Input = cleanPath(StartParams.Input) 113 | if len(StartParams.Input) == 0 { 114 | logFatal("-in missing") 115 | } else if !fileExists(StartParams.Input) { 116 | logFatal("-in file %q does not exist", StartParams.Input) 117 | } 118 | 119 | // -out 120 | if !StartParams.Stdout { 121 | if len(StartParams.Output) > 0 { 122 | StartParams.Output = cleanPath(StartParams.Output) 123 | } else { 124 | if iExt := strings.LastIndex(StartParams.Input, "."); iExt != -1 { 125 | StartParams.Output = StartParams.Input[0:iExt] + ".simplified" + StartParams.Input[iExt:] 126 | } else { 127 | StartParams.Output = StartParams.Input + ".simplified" 128 | } 129 | } 130 | // don't allow user to overwrite source file, this app can be destructive and should 131 | // not overwrite the source files. If user really wants to do this, he can rename the output file. 132 | if StartParams.Input == StartParams.Output { 133 | logFatal("Overwriting input file is not allowed, both input and output point to %s\n", StartParams.Input) 134 | } 135 | } 136 | } 137 | 138 | func getVersion(date bool) (version string) { 139 | if Version == "" { 140 | return "dev" 141 | } 142 | version = fmt.Sprintf("v%s (%s)", Version, VersionHash) 143 | if date { 144 | version += " " + VersionDate 145 | } 146 | return version 147 | } 148 | 149 | type processor struct { 150 | Processor 151 | Disabled bool 152 | } 153 | 154 | func (p *processor) NameCmd() string { 155 | return "no-" + strings.ToLower(p.Name()) 156 | } 157 | 158 | type Processor interface { 159 | Name() string 160 | Desc() string 161 | Execute(obj *objectfile.OBJ) error 162 | } 163 | 164 | func main() { 165 | // cpu profiling for development: github.com/pkg/profile 166 | if StartParams.CpuProfile { 167 | defer profile.Start(profile.ProfilePath(".")).Stop() 168 | } 169 | 170 | if b, err := json.MarshalIndent(StartParams, "", " "); err == nil { 171 | logInfo("\n%s %s %s", ApplicationName, getVersion(false), b) 172 | } else { 173 | logFatalError(err) 174 | } 175 | 176 | type timing struct { 177 | Step string 178 | Duration time.Duration 179 | } 180 | 181 | var ( 182 | start = time.Now() 183 | pre = time.Now() 184 | timings = []timing{} 185 | timeStep = func(step string) { 186 | timings = append(timings, timing{Step: step, Duration: time.Now().Sub(pre)}) 187 | pre = time.Now() 188 | } 189 | ) 190 | 191 | // parse 192 | obj, linesParsed, err := ParseFile(StartParams.Input) 193 | if err != nil { 194 | logFatalError(err) 195 | } 196 | timeStep("Parse") 197 | 198 | // store stats before post-processing 199 | preStats := obj.Stats() 200 | // @todo this is ugly, maybe the face objects could be marked somehow. 201 | // we want to show real stats, not faked object count stats at the end 202 | preStats.Objects = ObjectsParsed 203 | preStats.Groups = GroupsParsed 204 | 205 | // post processing 206 | for pi, processor := range Processors { 207 | logInfo(" ") 208 | if processor.Disabled { 209 | logInfo("processor #%d: %s - Disabled", pi+1, processor.Name()) 210 | continue 211 | } 212 | logInfo("processor #%d: %s", pi+1, processor.Name()) 213 | logFatalError(processor.Execute(obj)) 214 | timeStep(processor.Name()) 215 | } 216 | 217 | postStats := obj.Stats() 218 | 219 | // write file out 220 | var ( 221 | w = &Writer{obj: obj} 222 | linesWritten int 223 | errWrite error 224 | ) 225 | if StartParams.Stdout { 226 | linesWritten, errWrite = w.WriteTo(os.Stdout) 227 | } else { 228 | linesWritten, errWrite = w.WriteFile(StartParams.Output) 229 | } 230 | logFatalError(errWrite) 231 | timeStep("Write") 232 | 233 | // print stats etc 234 | logInfo(" ") 235 | durationTotal := time.Since(start) 236 | for _, timing := range timings { 237 | logResultsPostfix(timing.Step, formatDuration(timing.Duration), computeDurationPerc(timing.Duration, durationTotal)+"%%") 238 | } 239 | logResults("Total", formatDuration(durationTotal)) 240 | 241 | logGeometryStats(preStats.Geometry, postStats.Geometry) 242 | logVertexDataStats(preStats, postStats) 243 | logObjectStats(preStats, postStats) 244 | logFileStats(linesParsed, linesWritten) 245 | 246 | if StartParams.IsGzipEnabled() { 247 | logInfo(" ") 248 | logInfo("Gzip compression enabled with level %d.", StartParams.Gzip) 249 | logInfo("Remeber to set 'Content-Encoding: gzip' header if you are hosting this file over HTTP.") 250 | } 251 | 252 | logInfo(" ") 253 | } 254 | 255 | func logGeometryStats(stats, postprocessed objectfile.GeometryStats) { 256 | if !stats.IsEmpty() { 257 | logInfo(" ") 258 | } 259 | if stats.Vertices > 0 { 260 | logResultsIntPostfix("Vertices", postprocessed.Vertices, computeStatsDiff(stats.Vertices, postprocessed.Vertices)) 261 | } 262 | if stats.Normals > 0 { 263 | logResultsIntPostfix("Normals", postprocessed.Normals, computeStatsDiff(stats.Normals, postprocessed.Normals)) 264 | } 265 | if stats.UVs > 0 { 266 | logResultsIntPostfix("UVs", postprocessed.UVs, computeStatsDiff(stats.UVs, postprocessed.UVs)) 267 | } 268 | if stats.Params > 0 { 269 | logResultsIntPostfix("Params", postprocessed.Params, computeStatsDiff(stats.Params, postprocessed.Params)) 270 | } 271 | } 272 | 273 | func logObjectStats(stats, postprocessed objectfile.ObjStats) { 274 | logInfo(" ") 275 | // There is a special case where input has zero objects and we have created one or more. 276 | if stats.Groups > 0 || postprocessed.Groups > 0 { 277 | logResultsIntPostfix("Groups", postprocessed.Groups, computeStatsDiff(stats.Groups, postprocessed.Groups)) 278 | } 279 | if stats.Objects > 0 || postprocessed.Objects > 0 { 280 | logResultsIntPostfix("Objects", postprocessed.Objects, computeStatsDiff(stats.Objects, postprocessed.Objects)) 281 | } 282 | } 283 | 284 | func logVertexDataStats(stats, postprocessed objectfile.ObjStats) { 285 | if stats.Faces > 0 || stats.Lines > 0 || stats.Points > 0 { 286 | logInfo(" ") 287 | } 288 | if stats.Faces > 0 { 289 | logResultsIntPostfix("Faces", postprocessed.Faces, computeStatsDiff(stats.Faces, postprocessed.Faces)) 290 | } 291 | if stats.Lines > 0 { 292 | logResultsIntPostfix("Lines", postprocessed.Lines, computeStatsDiff(stats.Lines, postprocessed.Lines)) 293 | } 294 | if stats.Points > 0 { 295 | logResultsIntPostfix("Points", postprocessed.Points, computeStatsDiff(stats.Points, postprocessed.Points)) 296 | } 297 | } 298 | 299 | func logFileStats(linesParsed, linesWritten int) { 300 | logInfo(" ") 301 | logResults("Lines input", formatInt(linesParsed)) 302 | if linesWritten < linesParsed { 303 | logResultsPostfix("Lines output", formatInt(linesWritten), fmt.Sprintf("%-10s %s", formatInt(linesWritten-linesParsed), "-"+intToString(int(100-computePerc(float64(linesWritten), float64(linesParsed))))+"%%")) 304 | } else { 305 | logResultsPostfix("Lines output", formatInt(linesWritten), fmt.Sprintf("+%-10s %s", formatInt(linesWritten-linesParsed), "+"+intToString(int(computePerc(float64(linesWritten), float64(linesParsed))-100))+"%%")) 306 | } 307 | 308 | logInfo(" ") 309 | sizeIn, sizeOut := fileSize(StartParams.Input), fileSize(StartParams.Output) 310 | logResults("File input", formatBytes(sizeIn)) 311 | if !StartParams.Stdout { 312 | if sizeOut < sizeIn { 313 | logResultsPostfix("File output", formatBytes(sizeOut), fmt.Sprintf("%-10s %s", formatBytes(sizeOut-sizeIn), "-"+intToString(int(100-computePerc(float64(sizeOut), float64(sizeIn))))+"%%")) 314 | } else { 315 | logResultsPostfix("File output", formatBytes(sizeOut), fmt.Sprintf("+%-10s %s", formatBytes(sizeOut-sizeIn), "+"+intToString(int(computePerc(float64(sizeOut), float64(sizeIn))-100))+"%%")) 316 | } 317 | } 318 | } 319 | 320 | func computeStatsDiff(a, b int) string { 321 | if a == b { 322 | return "" 323 | } 324 | diff := b - a 325 | perc := computePerc(float64(b), float64(a)) 326 | if perc >= 99.999999 { 327 | // positive 0 decimals 328 | return fmt.Sprintf("+%-7d", diff) 329 | } else if perc <= 99.0 { 330 | // negative 0 decimals 331 | return fmt.Sprintf("%-7d -%d", diff, 100-int(perc)) + "%%" 332 | } 333 | // negative 2 decimals 334 | return fmt.Sprintf("%-7d -%.2f", diff, 100-perc) + "%%" 335 | } 336 | 337 | func computePerc(step, total float64) float64 { 338 | if step == 0 { 339 | return 0.0 340 | } else if total == 0 { 341 | return 100.0 342 | } 343 | return (step / total) * 100.0 344 | } 345 | 346 | func computeFloatPerc(step, total float64) string { 347 | perc := computePerc(step, total) 348 | if perc < 1.0 { 349 | return fmt.Sprintf("%.2f", perc) 350 | } 351 | return intToString(int(perc)) 352 | } 353 | 354 | func computeDurationPerc(step, total time.Duration) string { 355 | return computeFloatPerc(step.Seconds(), total.Seconds()) 356 | } 357 | -------------------------------------------------------------------------------- /obj-parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "runtime/debug" 10 | "strings" 11 | "time" 12 | 13 | "github.com/jonnenauha/obj-simplify/objectfile" 14 | ) 15 | 16 | var ( 17 | ObjectsParsed int 18 | GroupsParsed int 19 | ) 20 | 21 | func ParseFile(path string) (*objectfile.OBJ, int, error) { 22 | f, err := os.Open(path) 23 | if err != nil { 24 | return nil, -1, err 25 | } 26 | defer f.Close() 27 | return parse(f) 28 | } 29 | 30 | func ParseBytes(b []byte) (*objectfile.OBJ, int, error) { 31 | return parse(bytes.NewBuffer(b)) 32 | } 33 | 34 | func parse(src io.Reader) (*objectfile.OBJ, int, error) { 35 | dest := objectfile.NewOBJ() 36 | geom := dest.Geometry 37 | 38 | scanner := bufio.NewScanner(src) 39 | linenum := 0 40 | 41 | var ( 42 | currentObject *objectfile.Object 43 | currentObjectName string 44 | currentObjectChildIndex int 45 | currentMaterial string 46 | currentSmoothGroup string 47 | ) 48 | 49 | fakeObject := func(material string) *objectfile.Object { 50 | ot := objectfile.ChildObject 51 | if currentObject != nil { 52 | ot = currentObject.Type 53 | } 54 | currentObjectChildIndex++ 55 | name := fmt.Sprintf("%s_%d", currentObjectName, currentObjectChildIndex) 56 | return dest.CreateObject(ot, name, material) 57 | } 58 | 59 | for scanner.Scan() { 60 | linenum++ 61 | 62 | line := strings.TrimSpace(scanner.Text()) 63 | if len(line) == 0 { 64 | continue 65 | } 66 | t, value := parseLineType(line) 67 | 68 | // Force GC and release mem to OS for >1 million 69 | // line source files, every million lines. 70 | // 71 | // @todo We should also do data structure optimizations to handle 72 | // multiple gig source files without swapping on low mem machines. 73 | // A 4.5gb 82 million line test source file starts swapping on my 8gb 74 | // mem machine (though this app used ~5gb) at about the 40 million line mark. 75 | // 76 | // Above should be done when actualy users have a real use case for such 77 | // large files :) 78 | if linenum%1000000 == 0 { 79 | rt := time.Now() 80 | debug.FreeOSMemory() 81 | logInfo("%s lines parsed - Forced GC took %s", formatInt(linenum), formatDurationSince(rt)) 82 | } 83 | 84 | switch t { 85 | 86 | // comments 87 | case objectfile.Comment: 88 | if currentObject == nil && len(dest.MaterialLibraries) == 0 { 89 | dest.Comments = append(dest.Comments, value) 90 | } else if currentObject != nil { 91 | // skip comments that might refecence vertex, normal, uv, polygon etc. 92 | // counts as they wont be most likely true after this tool is done. 93 | if len(value) > 0 && !strContainsAny(value, []string{"vertices", "normals", "uvs", "texture coords", "polygons", "triangles"}, caseInsensitive) { 94 | currentObject.Comments = append(currentObject.Comments, value) 95 | } 96 | } 97 | 98 | // mtl file ref 99 | case objectfile.MtlLib: 100 | dest.MaterialLibraries = append(dest.MaterialLibraries, value) 101 | 102 | // geometry 103 | case objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param: 104 | if _, err := geom.ReadValue(t, value, StartParams.Strict); err != nil { 105 | return nil, linenum, wrapErrorLine(err, linenum) 106 | } 107 | 108 | // object, group 109 | case objectfile.ChildObject, objectfile.ChildGroup: 110 | currentObjectName = value 111 | currentObjectChildIndex = 0 112 | // inherit currently declared material 113 | currentObject = dest.CreateObject(t, currentObjectName, currentMaterial) 114 | if t == objectfile.ChildObject { 115 | ObjectsParsed++ 116 | } else if t == objectfile.ChildGroup { 117 | GroupsParsed++ 118 | } 119 | 120 | // object: material 121 | case objectfile.MtlUse: 122 | 123 | // obj files can define multiple materials inside a single object/group. 124 | // usually these are small face groups that kill performance on 3D engines 125 | // as they have to render hundreds or thousands of meshes with the same material, 126 | // each mesh containing a few faces. 127 | // 128 | // this app will convert all these "multi material" objects into 129 | // separate object, later merging all meshes with the same material into 130 | // a single draw call geometry. 131 | // 132 | // this might be undesirable for certain users, renderers and authoring software, 133 | // in this case don't use this simplified on your obj files. simple as that. 134 | 135 | // only fake if an object has been declared 136 | if currentObject != nil { 137 | // only fake if the current object has declared vertex data (faces etc.) 138 | // and the material name actually changed (ecountering the same usemtl 139 | // multiple times in a row would be rare, but check for completeness) 140 | if len(currentObject.VertexData) > 0 && currentObject.Material != value { 141 | currentObject = fakeObject(value) 142 | } 143 | } 144 | 145 | // store material value for inheriting 146 | currentMaterial = value 147 | 148 | // set material to current object 149 | if currentObject != nil { 150 | currentObject.Material = currentMaterial 151 | } 152 | 153 | // object: faces 154 | case objectfile.Face, objectfile.Line, objectfile.Point: 155 | // most tools support the file not defining a o/g prior to face declarations. 156 | // I'm not sure if the spec allows not declaring any o/g. 157 | // Our data structures and parsing however requires objects to put the faces into, 158 | // create a default object that is named after the input file (without suffix). 159 | if currentObject == nil { 160 | currentObject = dest.CreateObject(objectfile.ChildObject, fileBasename(StartParams.Input), currentMaterial) 161 | } 162 | vd, vdErr := currentObject.ReadVertexData(t, value, StartParams.Strict) 163 | if vdErr != nil { 164 | return nil, linenum, wrapErrorLine(vdErr, linenum) 165 | } 166 | // attach current smooth group and reset it 167 | if len(currentSmoothGroup) > 0 { 168 | vd.SetMeta(objectfile.SmoothingGroup, currentSmoothGroup) 169 | currentSmoothGroup = "" 170 | } 171 | 172 | case objectfile.SmoothingGroup: 173 | // smooth group can change mid vertex data declaration 174 | // so it is attched to the vertex data instead of current object directly 175 | currentSmoothGroup = value 176 | 177 | // unknown 178 | case objectfile.Unkown: 179 | return nil, linenum, wrapErrorLine(fmt.Errorf("Unsupported line %q\n\nPlease submit a bug report. If you can, provide this file as an attachement.\n> %s\n", line, ApplicationURL+"/issues"), linenum) 180 | default: 181 | return nil, linenum, wrapErrorLine(fmt.Errorf("Unsupported line %q\n\nPlease submit a bug report. If you can, provide this file as an attachement.\n> %s\n", line, ApplicationURL+"/issues"), linenum) 182 | } 183 | } 184 | if err := scanner.Err(); err != nil { 185 | return nil, linenum, err 186 | } 187 | return dest, linenum, nil 188 | } 189 | 190 | func wrapErrorLine(err error, linenum int) error { 191 | return fmt.Errorf("line:%d %s", linenum, err.Error()) 192 | } 193 | 194 | func parseLineType(str string) (objectfile.Type, string) { 195 | value := "" 196 | // comments, unlike other tokens, might not have a space after # 197 | if str[0] == '#' { 198 | return objectfile.Comment, strings.TrimSpace(str[1:]) 199 | } 200 | if i := strings.Index(str, " "); i != -1 { 201 | value = strings.TrimSpace(str[i+1:]) 202 | str = str[0:i] 203 | } 204 | return objectfile.TypeFromString(str), value 205 | } 206 | -------------------------------------------------------------------------------- /obj-writer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/jonnenauha/obj-simplify/objectfile" 11 | ) 12 | 13 | type Writer struct { 14 | obj *objectfile.OBJ 15 | } 16 | 17 | func (wr *Writer) WriteFile(path string) (int, error) { 18 | if fileExists(path) { 19 | if err := os.Remove(path); err != nil { 20 | return 0, err 21 | } 22 | } 23 | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm) 24 | if err != nil { 25 | return 0, err 26 | } 27 | linesWritten, errWrite := wr.WriteTo(f) 28 | if cErr := f.Close(); cErr != nil && errWrite == nil { 29 | errWrite = cErr 30 | } 31 | return linesWritten, errWrite 32 | } 33 | 34 | func (wr *Writer) WriteTo(writer io.Writer) (int, error) { 35 | linesWritten := 0 36 | 37 | w := writer 38 | if StartParams.IsGzipEnabled() { 39 | wGzip, errGzip := gzip.NewWriterLevel(writer, StartParams.Gzip) 40 | if errGzip != nil { 41 | return linesWritten, errGzip 42 | } 43 | defer wGzip.Close() 44 | w = wGzip 45 | } 46 | 47 | ln := func() { 48 | fmt.Fprint(w, "\n") 49 | linesWritten++ 50 | } 51 | writeLine := func(t objectfile.Type, value string, newline bool) { 52 | fmt.Fprintf(w, "%s %s\n", t, value) 53 | linesWritten++ 54 | if newline { 55 | ln() 56 | } 57 | } 58 | writeLines := func(t objectfile.Type, values []string, newline bool) { 59 | for _, v := range values { 60 | writeLine(t, v, false) 61 | } 62 | if newline && len(values) > 0 { 63 | ln() 64 | } 65 | } 66 | writeComments := func(t objectfile.Type, values []string, newline bool) { 67 | if len(values) == 0 { 68 | return 69 | } 70 | comments := make([]string, len(values)) 71 | copy(comments, values) 72 | // remove empty lines from start and end. 73 | // preserve empty lines in the center for long comments. 74 | for len(comments) > 0 && comments[0] == "" { 75 | comments = comments[1:] 76 | } 77 | for len(comments) > 0 && comments[len(comments)-1] == "" { 78 | comments = comments[0 : len(comments)-1] 79 | } 80 | writeLines(t, comments, newline) 81 | } 82 | 83 | obj := wr.obj 84 | 85 | // leave a comment that signifies this tool was ran on the file 86 | writeLine(objectfile.Comment, fmt.Sprintf("Processed with %s %s | %s | %s", ApplicationName, getVersion(false), time.Now().UTC().Format(time.RFC3339), ApplicationURL), true) 87 | 88 | // comments 89 | writeComments(objectfile.Comment, obj.Comments, true) 90 | 91 | // Materials (I think there is always just one, if this can change mid file, this needs to be adjusted and pos tracked during parsing) 92 | writeLines(objectfile.MtlLib, obj.MaterialLibraries, true) 93 | 94 | // geometry 95 | for ti, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 96 | if slice := obj.Geometry.Get(t); len(slice) > 0 { 97 | if ti > 0 { 98 | ln() 99 | } 100 | writeLine(objectfile.Comment, fmt.Sprintf("%s [%d]", t.Name(), len(slice)), true) 101 | for _, value := range slice { 102 | writeLine(t, value.String(t), false) 103 | } 104 | } 105 | } 106 | ln() 107 | 108 | // objects: preserves the parsing order of g/o 109 | writeLine(objectfile.Comment, fmt.Sprintf("objects [%d]", len(obj.Objects)), true) 110 | for _, child := range obj.Objects { 111 | writeComments(objectfile.Comment, child.Comments, true) 112 | writeLine(child.Type, child.Name, false) 113 | // we dont skip writing material if it has already been declared as the 114 | // last material, the file is easier to read for humans with write on each 115 | // child, and this wont take many bytes in the total file size. 116 | if len(child.Material) > 0 { 117 | writeLine(objectfile.MtlUse, child.Material, false) 118 | } 119 | ln() 120 | for _, vd := range child.VertexData { 121 | if sgroup := vd.Meta(objectfile.SmoothingGroup); len(sgroup) > 0 { 122 | writeLine(objectfile.SmoothingGroup, sgroup, false) 123 | } 124 | writeLine(vd.Type, vd.String(), false) 125 | } 126 | ln() 127 | } 128 | 129 | return linesWritten, nil 130 | } 131 | -------------------------------------------------------------------------------- /objectfile/structs.go: -------------------------------------------------------------------------------- 1 | package objectfile 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // http://www.martinreddy.net/gfx/3d/OBJ.spec 11 | 12 | // ObjectType 13 | 14 | type Type int 15 | 16 | const ( 17 | Unkown Type = iota 18 | 19 | Comment // # 20 | MtlLib // mtllib 21 | MtlUse // usemtl 22 | ChildGroup // g 23 | ChildObject // o 24 | SmoothingGroup // s 25 | Vertex // v 26 | Normal // vn 27 | UV // vt 28 | Param // vp 29 | Face // f 30 | Line // l 31 | Point // p 32 | Curve // curv 33 | Curve2 // curv2 34 | Surface // surf 35 | ) 36 | 37 | func (ot Type) String() string { 38 | switch ot { 39 | case Comment: 40 | return "#" 41 | case MtlLib: 42 | return "mtllib" 43 | case MtlUse: 44 | return "usemtl" 45 | case ChildGroup: 46 | return "g" 47 | case ChildObject: 48 | return "o" 49 | case SmoothingGroup: 50 | return "s" 51 | case Vertex: 52 | return "v" 53 | case Normal: 54 | return "vn" 55 | case UV: 56 | return "vt" 57 | case Param: 58 | return "vp" 59 | case Face: 60 | return "f" 61 | case Line: 62 | return "l" 63 | case Point: 64 | return "p" 65 | case Curve: 66 | return "curv" 67 | case Curve2: 68 | return "curv2" 69 | case Surface: 70 | return "surf" 71 | } 72 | return "" 73 | } 74 | 75 | func (ot Type) Name() string { 76 | switch ot { 77 | case Vertex: 78 | return "vertices" 79 | case Normal: 80 | return "normals" 81 | case UV: 82 | return "uvs" 83 | case Param: 84 | return "params" 85 | case ChildGroup: 86 | return "group" 87 | case ChildObject: 88 | return "object" 89 | } 90 | return "" 91 | } 92 | 93 | func TypeFromString(str string) Type { 94 | switch str { 95 | case "#": 96 | return Comment 97 | case "mtllib": 98 | return MtlLib 99 | case "usemtl": 100 | return MtlUse 101 | case "g": 102 | return ChildGroup 103 | case "o": 104 | return ChildObject 105 | case "s": 106 | return SmoothingGroup 107 | case "v": 108 | return Vertex 109 | case "vn": 110 | return Normal 111 | case "vt": 112 | return UV 113 | case "vp": 114 | return Param 115 | case "f": 116 | return Face 117 | case "l": 118 | return Line 119 | case "p": 120 | return Point 121 | case "curv": 122 | return Curve 123 | case "curv2": 124 | return Curve2 125 | case "surf": 126 | return Surface 127 | } 128 | return Unkown 129 | } 130 | 131 | // ObjStats 132 | 133 | type ObjStats struct { 134 | Objects int 135 | Groups int 136 | Faces int 137 | Lines int 138 | Points int 139 | Geometry GeometryStats 140 | } 141 | 142 | // OBJ 143 | 144 | type OBJ struct { 145 | Geometry *Geometry 146 | MaterialLibraries []string 147 | 148 | Objects []*Object 149 | Comments []string 150 | } 151 | 152 | func NewOBJ() *OBJ { 153 | return &OBJ{ 154 | Geometry: NewGeometry(), 155 | Objects: make([]*Object, 0), 156 | } 157 | } 158 | 159 | func (o *OBJ) ObjectWithType(t Type) (objects []*Object) { 160 | for _, o := range o.Objects { 161 | if o.Type == t { 162 | objects = append(objects, o) 163 | } 164 | } 165 | return objects 166 | } 167 | 168 | func (o *OBJ) CreateObject(t Type, name, material string) *Object { 169 | if t != ChildObject && t != ChildGroup { 170 | fmt.Printf("CreateObject: invalid object type %s", t) 171 | return nil 172 | } 173 | child := &Object{ 174 | Type: t, 175 | Name: name, 176 | Material: material, 177 | parent: o, 178 | } 179 | if child.Name == "" { 180 | child.Name = fmt.Sprintf("%s_%d", t.Name(), len(o.ObjectWithType(t))+1) 181 | } 182 | o.Objects = append(o.Objects, child) 183 | return child 184 | } 185 | 186 | func (o *OBJ) Stats() ObjStats { 187 | stats := ObjStats{ 188 | Objects: len(o.ObjectWithType(ChildObject)), 189 | Groups: len(o.ObjectWithType(ChildGroup)), 190 | } 191 | for _, child := range o.Objects { 192 | for _, vt := range child.VertexData { 193 | switch vt.Type { 194 | case Face: 195 | stats.Faces++ 196 | case Line: 197 | stats.Lines++ 198 | case Point: 199 | stats.Points++ 200 | } 201 | } 202 | } 203 | if o.Geometry != nil { 204 | stats.Geometry = o.Geometry.Stats() 205 | } 206 | return stats 207 | } 208 | 209 | // Object 210 | 211 | type Object struct { 212 | Type Type 213 | Name string 214 | Material string 215 | VertexData []*VertexData 216 | Comments []string 217 | 218 | parent *OBJ 219 | } 220 | 221 | // Reads a vertex data line eg. f and l into this object. 222 | // 223 | // If parent OBJ is non nil, additionally converts negative index 224 | // references into absolute indexes and check out of bounds errors. 225 | func (o *Object) ReadVertexData(t Type, value string, strict bool) (*VertexData, error) { 226 | var ( 227 | vt *VertexData 228 | err error 229 | ) 230 | switch t { 231 | case Face: 232 | vt, err = ParseFaceVertexData(value, strict) 233 | case Line, Point: 234 | vt, err = ParseListVertexData(t, value, strict) 235 | default: 236 | err = fmt.Errorf("Unsupported vertex data declaration %s %s", t, value) 237 | } 238 | 239 | if err != nil { 240 | return nil, err 241 | } else if o.parent == nil { 242 | return vt, nil 243 | } 244 | 245 | // OBJ index references start from 1 not zero. 246 | // Negative values are relative from the end of currently 247 | // declared geometry. Convert relative values to absolute. 248 | geomStats := o.parent.Geometry.Stats() 249 | 250 | for _, decl := range vt.Declarations { 251 | 252 | if decl.Vertex != 0 { 253 | if decl.Vertex < 0 { 254 | decl.Vertex = decl.Vertex + geomStats.Vertices + 1 255 | } 256 | if decl.Vertex <= 0 || decl.Vertex > geomStats.Vertices { 257 | return nil, fmt.Errorf("vertex index %d out of bounds, %d declared so far", decl.Vertex, geomStats.Vertices) 258 | } 259 | decl.RefVertex = o.parent.Geometry.Vertices[decl.Vertex-1] 260 | if decl.RefVertex.Index != decl.Vertex { 261 | return nil, fmt.Errorf("vertex index %d does not match with referenced geometry value %#v", decl.Vertex, decl.RefVertex) 262 | } 263 | } 264 | 265 | if decl.UV != 0 { 266 | if decl.UV < 0 { 267 | decl.UV = decl.UV + geomStats.UVs + 1 268 | } 269 | if decl.UV <= 0 || decl.UV > geomStats.UVs { 270 | return nil, fmt.Errorf("uv index %d out of bounds, %d declared so far", decl.UV, geomStats.UVs) 271 | } 272 | decl.RefUV = o.parent.Geometry.UVs[decl.UV-1] 273 | if decl.RefUV.Index != decl.UV { 274 | return nil, fmt.Errorf("uv index %d does not match with referenced geometry value %#v", decl.UV, decl.RefUV) 275 | } 276 | } 277 | 278 | if decl.Normal != 0 { 279 | if decl.Normal < 0 { 280 | decl.Normal = decl.Normal + geomStats.Normals + 1 281 | } 282 | if decl.Normal <= 0 || decl.Normal > geomStats.Normals { 283 | return nil, fmt.Errorf("normal index %d out of bounds, %d declared so far", decl.Normal, geomStats.Normals) 284 | } 285 | decl.RefNormal = o.parent.Geometry.Normals[decl.Normal-1] 286 | if decl.RefNormal.Index != decl.Normal { 287 | return nil, fmt.Errorf("normal index %d does not match with referenced geometry value %#v", decl.Normal, decl.RefNormal) 288 | } 289 | } 290 | } 291 | o.VertexData = append(o.VertexData, vt) 292 | return vt, nil 293 | } 294 | 295 | // Declaration 296 | 297 | // zero value means it was not declared, should not be written 298 | // @note exception: if sibling declares it, must be written eg. 1//2 299 | type Declaration struct { 300 | Vertex int 301 | UV int 302 | Normal int 303 | 304 | // Pointers to actual geometry values. 305 | // When serialized to string, the index is read from ref 306 | // if available. This enables easy geometry rewrites. 307 | RefVertex, RefUV, RefNormal *GeometryValue 308 | } 309 | 310 | func (d *Declaration) Equals(other *Declaration) bool { 311 | if d.Index(Vertex) != other.Index(Vertex) || 312 | d.Index(UV) != other.Index(UV) || 313 | d.Index(Normal) != other.Index(Normal) { 314 | return false 315 | } 316 | return true 317 | } 318 | 319 | // Use this getter when possible index rewrites has occurred. 320 | // Will first return index from geometry value pointers, if available. 321 | func (d *Declaration) Index(t Type) int { 322 | switch t { 323 | case Vertex: 324 | if d.RefVertex != nil { 325 | return d.RefVertex.Index 326 | } 327 | return d.Vertex 328 | case UV: 329 | if d.RefUV != nil { 330 | return d.RefUV.Index 331 | } 332 | return d.UV 333 | case Normal: 334 | if d.RefNormal != nil { 335 | return d.RefNormal.Index 336 | } 337 | return d.Normal 338 | default: 339 | fmt.Printf("Declaration.Index: Unsupported type %s\n", t) 340 | } 341 | return 0 342 | } 343 | 344 | // vertex data parsers 345 | 346 | func ParseFaceVertexData(str string, strict bool) (vt *VertexData, err error) { 347 | vt = &VertexData{ 348 | Type: Face, 349 | } 350 | for iMain, part := range strings.Split(str, " ") { 351 | dest := vt.Index(iMain) 352 | if dest == nil { 353 | if strict { 354 | return nil, fmt.Errorf("Invalid face index %d in %s", iMain, str) 355 | } 356 | break 357 | } 358 | for iPart, datapart := range strings.Split(part, "/") { 359 | value := 0 360 | // can be empty eg. "f 1//1 2//2 3//3 4//4" 361 | if len(datapart) > 0 { 362 | value, err = strconv.Atoi(datapart) 363 | if err != nil { 364 | return nil, err 365 | } 366 | } 367 | switch iPart { 368 | case 0: 369 | dest.Vertex = value 370 | case 1: 371 | dest.UV = value 372 | case 2: 373 | dest.Normal = value 374 | default: 375 | if strict { 376 | return nil, fmt.Errorf("Invalid face vertex data index %d.%d in %s", iMain, iPart, str) 377 | } 378 | break 379 | } 380 | } 381 | } 382 | return vt, nil 383 | } 384 | 385 | func ParseListVertexData(t Type, str string, strict bool) (*VertexData, error) { 386 | if t != Line && t != Point { 387 | return nil, fmt.Errorf("ParseListVertexData supports face and point type, given: %s", t) 388 | } 389 | vt := &VertexData{ 390 | Type: t, 391 | } 392 | for iMain, part := range strings.Split(str, " ") { 393 | decl := &Declaration{} 394 | for iPart, datapart := range strings.Split(part, "/") { 395 | if len(datapart) == 0 { 396 | continue 397 | } 398 | value, vErr := strconv.Atoi(datapart) 399 | if vErr != nil { 400 | return nil, vErr 401 | } 402 | switch iPart { 403 | case 0: 404 | decl.Vertex = value 405 | case 1: 406 | decl.UV = value 407 | default: 408 | if strict { 409 | return nil, fmt.Errorf("Invalid face vertex data index %d.%d in %s", iMain, iPart, str) 410 | } 411 | break 412 | } 413 | } 414 | vt.Declarations = append(vt.Declarations, decl) 415 | } 416 | return vt, nil 417 | } 418 | 419 | // VertexData 420 | // @todo Make face, line etc. separate objects with VertexData being an interface 421 | type VertexData struct { 422 | Type Type 423 | Declarations []*Declaration 424 | 425 | meta map[Type]string 426 | } 427 | 428 | func (f *VertexData) SetMeta(t Type, value string) { 429 | if f.meta == nil { 430 | f.meta = make(map[Type]string) 431 | } 432 | f.meta[t] = value 433 | } 434 | 435 | func (f *VertexData) Meta(t Type) string { 436 | if f.meta != nil { 437 | return f.meta[t] 438 | } 439 | return "" 440 | } 441 | 442 | func (f *VertexData) Index(index int) *Declaration { 443 | if index >= 0 && index <= 3 { 444 | for index >= len(f.Declarations) { 445 | f.Declarations = append(f.Declarations, &Declaration{}) 446 | } 447 | return f.Declarations[index] 448 | } 449 | return nil 450 | } 451 | 452 | func (vt *VertexData) String() (out string) { 453 | 454 | switch vt.Type { 455 | 456 | case Line, Point: 457 | hasUVs := false 458 | if vt.Type == Line { 459 | for _, decl := range vt.Declarations { 460 | if decl.Index(UV) != 0 { 461 | hasUVs = true 462 | break 463 | } 464 | } 465 | } 466 | var prev *Declaration 467 | for di, decl := range vt.Declarations { 468 | // remove consecutive duplicate points eg. "l 1 1 2 2 3 4 4" 469 | if prev != nil && prev.Equals(decl) { 470 | continue 471 | } 472 | if di > 0 { 473 | out += " " 474 | } 475 | out += strconv.Itoa(decl.Index(Vertex)) 476 | if hasUVs { 477 | out += "/" 478 | if index := decl.Index(UV); index != 0 { 479 | out += strconv.Itoa(index) 480 | } 481 | } 482 | prev = decl 483 | } 484 | 485 | case Face: 486 | hasUVs, hasNormals := false, false 487 | 488 | // always use ptr refs if available. 489 | // this enables simple index rewrites. 490 | for _, decl := range vt.Declarations { 491 | if !hasUVs { 492 | hasUVs = decl.Index(UV) != 0 493 | } 494 | if !hasNormals { 495 | hasNormals = decl.Index(Normal) != 0 496 | } 497 | if hasUVs && hasNormals { 498 | break 499 | } 500 | } 501 | for di, decl := range vt.Declarations { 502 | if di > 0 { 503 | out += " " 504 | } 505 | out += strconv.Itoa(decl.Index(Vertex)) 506 | if hasUVs || hasNormals { 507 | out += "/" 508 | if index := decl.Index(UV); index != 0 { 509 | out += strconv.Itoa(index) 510 | } 511 | } 512 | if hasNormals { 513 | out += "/" 514 | if index := decl.Index(Normal); index != 0 { 515 | out += strconv.Itoa(index) 516 | } 517 | } 518 | } 519 | } 520 | return out 521 | } 522 | 523 | // Geometry 524 | 525 | type Geometry struct { 526 | Vertices []*GeometryValue // v x y z [w] 527 | Normals []*GeometryValue // vn i j k 528 | UVs []*GeometryValue // vt u [v [w]] 529 | Params []*GeometryValue // vp u v [w] 530 | } 531 | 532 | func (g *Geometry) ReadValue(t Type, value string, strict bool) (*GeometryValue, error) { 533 | gv := &GeometryValue{} 534 | // default values by the spec, not serialized in String() if not touched. 535 | if t == Vertex || t == Point { 536 | gv.W = 1 537 | } 538 | for i, part := range strings.Split(value, " ") { 539 | if len(part) == 0 { 540 | continue 541 | } 542 | if part == "-0" { 543 | part = "0" 544 | } else if strings.Index(part, "-0.") == 0 { 545 | // "-0.000000" etc. 546 | if trimmed := strings.TrimRight(part, "0"); trimmed == "-0." { 547 | part = "0" 548 | } 549 | } 550 | num, err := strconv.ParseFloat(part, 64) 551 | if err != nil { 552 | return nil, fmt.Errorf("Found invalid number from %q: %s", value, err) 553 | } 554 | 555 | switch i { 556 | case 0: 557 | gv.X = num 558 | case 1: 559 | gv.Y = num 560 | case 2: 561 | gv.Z = num 562 | case 3: 563 | if strict && t != Vertex { 564 | return nil, fmt.Errorf("Found invalid fourth component: %s %s", t.String(), value) 565 | } 566 | gv.W = num 567 | default: 568 | if strict { 569 | return nil, fmt.Errorf("Found invalid fifth component: %s %s", t.String(), value) 570 | } 571 | break 572 | } 573 | } 574 | // OBJ refs start from 1 not zero 575 | gv.Index = len(g.Get(t)) + 1 576 | switch t { 577 | case Vertex: 578 | g.Vertices = append(g.Vertices, gv) 579 | case UV: 580 | g.UVs = append(g.UVs, gv) 581 | case Normal: 582 | g.Normals = append(g.Normals, gv) 583 | case Param: 584 | g.Params = append(g.Params, gv) 585 | default: 586 | return nil, fmt.Errorf("Unkown geometry value type %d %s", t, t) 587 | } 588 | return gv, nil 589 | } 590 | 591 | func (g *Geometry) Set(t Type, values []*GeometryValue) { 592 | switch t { 593 | case Vertex: 594 | g.Vertices = values 595 | case Normal: 596 | g.Normals = values 597 | case UV: 598 | g.UVs = values 599 | case Param: 600 | g.Params = values 601 | } 602 | } 603 | 604 | func (g *Geometry) Get(t Type) []*GeometryValue { 605 | switch t { 606 | case Vertex: 607 | return g.Vertices 608 | case Normal: 609 | return g.Normals 610 | case UV: 611 | return g.UVs 612 | case Param: 613 | return g.Params 614 | } 615 | return nil 616 | } 617 | 618 | func (g *Geometry) Stats() GeometryStats { 619 | return GeometryStats{ 620 | Vertices: len(g.Vertices), 621 | Normals: len(g.Normals), 622 | UVs: len(g.UVs), 623 | Params: len(g.Params), 624 | } 625 | } 626 | 627 | // GeometryStats 628 | 629 | type GeometryStats struct { 630 | Vertices, Normals, UVs, Params int 631 | } 632 | 633 | func (gs GeometryStats) IsEmpty() bool { 634 | return gs.Vertices == 0 && gs.UVs == 0 && gs.Normals == 0 && gs.Params == 0 635 | } 636 | 637 | func (gs GeometryStats) Num(t Type) int { 638 | switch t { 639 | case Vertex: 640 | return gs.Vertices 641 | case UV: 642 | return gs.UVs 643 | case Normal: 644 | return gs.Normals 645 | case Param: 646 | return gs.Params 647 | default: 648 | return 0 649 | } 650 | } 651 | 652 | // GeometryValue 653 | 654 | type GeometryValue struct { 655 | Index int 656 | Discard bool 657 | X, Y, Z, W float64 658 | } 659 | 660 | func equals(a, b, epsilon float64) bool { 661 | return (math.Abs(a-b) <= epsilon) 662 | } 663 | 664 | func (gv *GeometryValue) String(t Type) (out string) { 665 | switch t { 666 | case UV: 667 | out = strconv.FormatFloat(gv.X, 'g', -1, 64) + " " + strconv.FormatFloat(gv.Y, 'g', -1, 64) 668 | default: 669 | out = strconv.FormatFloat(gv.X, 'g', -1, 64) + " " + strconv.FormatFloat(gv.Y, 'g', -1, 64) + " " + strconv.FormatFloat(gv.Z, 'g', -1, 64) 670 | } 671 | // omit default values 672 | switch t { 673 | case Vertex, Point: 674 | if !equals(gv.W, 1, 1e-10) { 675 | out += " " + strconv.FormatFloat(gv.W, 'g', -1, 64) 676 | } 677 | } 678 | return out 679 | } 680 | 681 | func (gv *GeometryValue) Distance(to *GeometryValue) float64 { 682 | dx := gv.X - to.X 683 | dy := gv.Y - to.Y 684 | dz := gv.Z - to.Z 685 | return dx*dx + dy*dy + dz*dz 686 | } 687 | 688 | func (gv *GeometryValue) Equals(other *GeometryValue, epsilon float64) bool { 689 | if math.Abs(gv.X-other.X) <= epsilon && 690 | math.Abs(gv.Y-other.Y) <= epsilon && 691 | math.Abs(gv.Z-other.Z) <= epsilon && 692 | math.Abs(gv.W-other.W) <= epsilon { 693 | return true 694 | } 695 | return false 696 | } 697 | 698 | func NewGeometry() *Geometry { 699 | return &Geometry{ 700 | Vertices: make([]*GeometryValue, 0), 701 | Normals: make([]*GeometryValue, 0), 702 | UVs: make([]*GeometryValue, 0), 703 | Params: make([]*GeometryValue, 0), 704 | } 705 | } 706 | 707 | // Material 708 | 709 | type Material struct { 710 | Mtllib string 711 | Name string 712 | } 713 | -------------------------------------------------------------------------------- /process-duplicates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "sync" 8 | "time" 9 | 10 | "gopkg.in/cheggaaa/pb.v1" 11 | 12 | "github.com/jonnenauha/obj-simplify/objectfile" 13 | ) 14 | 15 | // replacerList 16 | 17 | type replacerList []*replacer 18 | 19 | // flat map of index to ptr that replaces that index 20 | func (rl replacerList) FlattenGeometry() map[int]*objectfile.GeometryValue { 21 | out := make(map[int]*objectfile.GeometryValue) 22 | for _, r := range rl { 23 | for index, _ := range r.replaces { 24 | if out[index] != nil { 25 | fmt.Printf("duplicate warning\n %#v\n %#v\n %t\n\n", out[index], r.ref, out[index].Equals(r.ref, 1e-6)) 26 | } 27 | out[index] = r.ref 28 | } 29 | } 30 | return out 31 | } 32 | 33 | // replacer 34 | 35 | type replacer struct { 36 | ref *objectfile.GeometryValue 37 | replaces map[int]*objectfile.GeometryValue 38 | replacesSlice []*objectfile.GeometryValue 39 | dirty bool 40 | hasItems bool 41 | } 42 | 43 | func (r *replacer) Index() int { 44 | return r.ref.Index 45 | } 46 | 47 | func (r *replacer) IsEmpty() bool { 48 | return !r.hasItems 49 | } 50 | 51 | func (r *replacer) NumReplaces() int { 52 | if r.replaces != nil { 53 | return len(r.replaces) 54 | } 55 | return 0 56 | } 57 | 58 | func (r *replacer) Replaces() []*objectfile.GeometryValue { 59 | // optimization to avoid huge map iters 60 | if r.dirty { 61 | r.dirty = false 62 | 63 | if r.replaces != nil { 64 | r.replacesSlice = make([]*objectfile.GeometryValue, len(r.replaces), len(r.replaces)) 65 | i := 0 66 | for _, ref := range r.replaces { 67 | r.replacesSlice[i] = ref 68 | i++ 69 | } 70 | } else { 71 | r.replacesSlice = nil 72 | } 73 | } 74 | return r.replacesSlice 75 | } 76 | 77 | func (r *replacer) Remove(index int) { 78 | if r.replaces == nil { 79 | return 80 | } 81 | if _, found := r.replaces[index]; found { 82 | r.dirty = true 83 | delete(r.replaces, index) 84 | r.hasItems = len(r.replaces) > 0 85 | } 86 | } 87 | 88 | func (r *replacer) Hit(ref *objectfile.GeometryValue) { 89 | // cannot hit self 90 | if ref.Index == r.ref.Index { 91 | return 92 | } 93 | if r.replaces == nil { 94 | r.replaces = make(map[int]*objectfile.GeometryValue) 95 | } 96 | r.dirty = true 97 | r.hasItems = true 98 | r.replaces[ref.Index] = ref 99 | } 100 | 101 | func (r *replacer) Hits(index int) bool { 102 | return r.hasItems && r.replaces[index] != nil 103 | } 104 | 105 | // call merge only if r.Hits(other.Index()) 106 | // returns if other was completely merged to r. 107 | func (r *replacer) Merge(other *replacer) { 108 | for _, value := range other.Replaces() { 109 | if value.Index == r.ref.Index { 110 | other.Remove(r.ref.Index) 111 | continue 112 | } 113 | if r.hasItems && r.replaces[value.Index] != nil { 114 | // straight up duplicate 115 | other.Remove(value.Index) 116 | } else if r.ref.Equals(value, StartParams.Epsilon) { 117 | // move equals hit to r from other 118 | r.Hit(value) 119 | other.Remove(value.Index) 120 | } 121 | } 122 | // if not completely merged at this point, we must 123 | // reject other.Index() from our hit list. 124 | if other.hasItems { 125 | r.Remove(other.Index()) 126 | } 127 | } 128 | 129 | // replacerByIndex 130 | 131 | type replacerByIndex []*replacer 132 | 133 | func (a replacerByIndex) Len() int { return len(a) } 134 | func (a replacerByIndex) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 135 | func (a replacerByIndex) Less(i, j int) bool { return a[i].Index() < a[j].Index() } 136 | 137 | // replacerResults 138 | 139 | type replacerResults struct { 140 | Type objectfile.Type 141 | Items []*replacer 142 | Spent time.Duration 143 | } 144 | 145 | func (rr *replacerResults) Duplicates() (duplicates int) { 146 | for _, r := range rr.Items { 147 | duplicates += r.NumReplaces() 148 | } 149 | return duplicates 150 | } 151 | 152 | // Duplicates 153 | 154 | type Duplicates struct{} 155 | 156 | func (processor Duplicates) Name() string { 157 | return "Duplicates" 158 | } 159 | 160 | func (processor Duplicates) Desc() string { 161 | return "Removes duplicate v/vn/vt declarations. Rewrites vertex data references." 162 | } 163 | 164 | func (processor Duplicates) Execute(obj *objectfile.OBJ) error { 165 | var ( 166 | results = make(map[objectfile.Type]*replacerResults) 167 | mResults = sync.RWMutex{} 168 | wg = &sync.WaitGroup{} 169 | preStats = obj.Geometry.Stats() 170 | epsilon = StartParams.Epsilon 171 | progressEnabled = !StartParams.NoProgress 172 | ) 173 | 174 | logInfo(" - Using epsilon of %s", strconv.FormatFloat(epsilon, 'g', -1, 64)) 175 | 176 | // Doing this with channels felt a bit overkill, copying a lot of replacers etc. 177 | setResults := func(result *replacerResults) { 178 | mResults.Lock() 179 | // If there is no progress bars, report results as they come in so user knows something is happening... 180 | if !progressEnabled { 181 | logInfo(" - %-2s %7d duplicates found for %d unique indexes (%s%%) in %s", 182 | result.Type, result.Duplicates(), len(result.Items), computeFloatPerc(float64(result.Duplicates()), float64(preStats.Num(result.Type))), formatDuration(result.Spent)) 183 | } 184 | results[result.Type] = result 185 | mResults.Unlock() 186 | } 187 | 188 | // find duplicated 189 | { 190 | mResults.Lock() 191 | 192 | var ( 193 | bars = make(map[objectfile.Type]*pb.ProgressBar) 194 | progressPool *pb.Pool 195 | progressErr error 196 | ) 197 | // progress bars 198 | if !StartParams.NoProgress { 199 | for _, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 200 | if slice := obj.Geometry.Get(t); len(slice) > 0 { 201 | bar := pb.New(len(slice)).Prefix(fmt.Sprintf(" - %-2s scan ", t.String())).SetMaxWidth(130) 202 | bar.ShowTimeLeft = false 203 | bars[t] = bar 204 | } 205 | } 206 | barsSlice := make([]*pb.ProgressBar, 0) 207 | for _, bar := range bars { 208 | barsSlice = append(barsSlice, bar) 209 | } 210 | if progressPool, progressErr = pb.StartPool(barsSlice...); progressErr != nil { 211 | // progress pools do not work in all shells on windows (eg. liteide) 212 | bars = make(map[objectfile.Type]*pb.ProgressBar) 213 | progressPool = nil 214 | progressEnabled = false 215 | } 216 | } 217 | // start goroutines 218 | for _, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 219 | if slice := obj.Geometry.Get(t); len(slice) > 0 { 220 | wg.Add(1) 221 | go findDuplicates(t, slice, epsilon, wg, bars[t], setResults) 222 | } 223 | } 224 | 225 | mResults.Unlock() 226 | 227 | wg.Wait() 228 | 229 | if progressPool != nil { 230 | progressPool.Stop() 231 | } 232 | // report totals now in the same order as it would print without progress bars 233 | if progressEnabled { 234 | mResults.Lock() 235 | for _, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 236 | if result := results[t]; result != nil { 237 | logInfo(" - %-2s %7d duplicates found for %d unique indexes (%s%%) in %s", 238 | result.Type, result.Duplicates(), len(result.Items), computeFloatPerc(float64(result.Duplicates()), float64(preStats.Num(result.Type))), formatDuration(result.Spent)) 239 | } 240 | } 241 | mResults.Unlock() 242 | } 243 | } 244 | 245 | // Rewrite ptr refs to vertex data that is using an about to be removed duplicate. 246 | // Exec in main thread, accessing the vertex data arrays in objects would be 247 | // too much contention with a mutex. This operation is fairly fast, no need for parallel exec. 248 | // Sweeps and marks .Discard to replaced values 249 | for _, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 250 | if result := results[t]; result != nil { 251 | replaceDuplicates(result.Type, obj, result.Items) 252 | } 253 | } 254 | 255 | // Rewrite geometry 256 | for _, t := range []objectfile.Type{objectfile.Vertex, objectfile.Normal, objectfile.UV, objectfile.Param} { 257 | src := obj.Geometry.Get(t) 258 | if len(src) == 0 { 259 | continue 260 | } 261 | dest := make([]*objectfile.GeometryValue, 0) 262 | for _, gv := range src { 263 | if !gv.Discard { 264 | gv.Index = len(dest) + 1 265 | dest = append(dest, gv) 266 | } 267 | } 268 | if len(dest) != len(src) { 269 | obj.Geometry.Set(t, dest) 270 | } 271 | } 272 | return nil 273 | } 274 | 275 | func findDuplicates(t objectfile.Type, slice []*objectfile.GeometryValue, epsilon float64, wgMain *sync.WaitGroup, progress *pb.ProgressBar, callback func(*replacerResults)) { 276 | defer wgMain.Done() 277 | 278 | var ( 279 | started = time.Now() 280 | results = make(replacerList, 0) 281 | mResults sync.RWMutex 282 | ) 283 | 284 | appendResults := func(rs []*replacer) { 285 | mResults.Lock() 286 | for _, result := range rs { 287 | if !result.IsEmpty() { 288 | results = append(results, result) 289 | } 290 | } 291 | mResults.Unlock() 292 | } 293 | 294 | processSlice := func(substart, subend int, fullslice []*objectfile.GeometryValue, subwg *sync.WaitGroup) { 295 | innerResults := make(replacerList, 0) 296 | var value *objectfile.GeometryValue 297 | for first := substart; first < subend; first++ { 298 | if progress != nil { 299 | progress.Increment() 300 | } 301 | result := &replacer{ 302 | ref: fullslice[first], 303 | } 304 | for second, lenFull := first+1, len(fullslice); second < lenFull; second++ { 305 | value = fullslice[second] 306 | if value.Equals(result.ref, epsilon) { 307 | result.Hit(value) 308 | } 309 | } 310 | if !result.IsEmpty() { 311 | innerResults = append(innerResults, result) 312 | } 313 | } 314 | appendResults(innerResults) 315 | 316 | subwg.Done() 317 | } 318 | 319 | wgInternal := &sync.WaitGroup{} 320 | numPerRoutine := len(slice) / StartParams.Workers 321 | for iter := 0; iter < StartParams.Workers; iter++ { 322 | start := iter * numPerRoutine 323 | end := start + numPerRoutine 324 | if end >= len(slice) || iter == StartParams.Workers-1 { 325 | end = len(slice) 326 | iter = StartParams.Workers 327 | } 328 | wgInternal.Add(1) 329 | go processSlice(start, end, slice, wgInternal) 330 | } 331 | wgInternal.Wait() 332 | 333 | mResults.Lock() 334 | defer mResults.Unlock() 335 | 336 | if len(results) == 0 { 337 | return 338 | } 339 | 340 | sort.Sort(replacerByIndex(results)) 341 | 342 | if progress != nil { 343 | progress.Prefix(fmt.Sprintf(" - %-2s merge ", t)) 344 | progress.Total = int64(len(results)) 345 | progress.Set(0) 346 | } 347 | 348 | var r1, r2 *replacer 349 | 350 | // 1st run: merge 351 | for i1, lenResults := 0, len(results); i1 < lenResults; i1++ { 352 | if progress != nil { 353 | progress.Increment() 354 | } 355 | r1 = results[i1] 356 | if !r1.hasItems { 357 | continue 358 | } 359 | for i2 := i1 + 1; i2 < lenResults; i2++ { 360 | r2 = results[i2] 361 | /*if !r2.hasItems { 362 | continue 363 | }*/ 364 | /*if r1.ref.Index == r2.ref.Index { 365 | // same primary index, this is a bug 366 | logFatal("r1.Index() and r2.Index() are the same, something wrong with sub slice processing code\n%#v\n%#v\n\n", r1, r2) 367 | }*/ 368 | if r2.hasItems && r1.hasItems && r1.replaces[r2.ref.Index] != nil { 369 | // r1 geom value equals r2. 370 | // only merge r2 hits where value equals r1, otherwise 371 | // we would do transitive merges which is not what we want: 372 | // eg. r1 closer than epsilon to r2, but r1 further than epsilon to r2.hitN 373 | r1.Merge(r2) 374 | // r1 might now be empty if r2 was its only hit, 375 | // and it was not completely merged. 376 | if !r1.hasItems { 377 | break 378 | } 379 | } 380 | } 381 | } 382 | 383 | nonemptyMerged := make(replacerList, 0) 384 | for _, r := range results { 385 | if r.hasItems { 386 | nonemptyMerged = append(nonemptyMerged, r) 387 | } 388 | } 389 | if progress != nil { 390 | progress.Prefix(fmt.Sprintf(" - %-2s deduplicate ", t)) 391 | progress.Total = int64(len(nonemptyMerged)) 392 | progress.Set(0) 393 | } 394 | 395 | // 2nd run: deduplicate, must be done after full merge to work correctly. 396 | // 397 | // Deduplicate hits that are in both r1 and r2. This can happen if a value 398 | // is between r1 and r2. Both equal with the in between value but 399 | // not with each other (see above merge). 400 | // In this case the hit is kept in the result that is closest to it. 401 | // if r and other both have a hit index, which is not shared by being 402 | // closer than epsilon tp both, keep it in the parent that it is closest to. 403 | for i1, lenResults := 0, len(nonemptyMerged); i1 < lenResults; i1++ { 404 | if progress != nil { 405 | progress.Increment() 406 | } 407 | r1 = nonemptyMerged[i1] 408 | if !r1.hasItems { 409 | continue 410 | } 411 | for i2 := i1 + 1; i2 < lenResults; i2++ { 412 | r2 = nonemptyMerged[i2] 413 | if r2.hasItems { 414 | deduplicate(r1, r2) 415 | if !r1.hasItems { 416 | break 417 | } 418 | } 419 | } 420 | } 421 | 422 | // Gather non empty results 423 | nonemptyFinal := make([]*replacer, 0) 424 | for _, r := range nonemptyMerged { 425 | if r.hasItems { 426 | nonemptyFinal = append(nonemptyFinal, r) 427 | } 428 | } 429 | 430 | // send results back 431 | callback(&replacerResults{ 432 | Type: t, 433 | Items: nonemptyFinal, 434 | Spent: time.Since(started), 435 | }) 436 | } 437 | 438 | func deduplicate(r1, r2 *replacer) { 439 | for _, value := range r1.Replaces() { 440 | if !r2.hasItems { 441 | // merged to empty during this iteration 442 | return 443 | } else if r2.replaces[value.Index] == nil { 444 | // no hit for index, avoid function call 445 | continue 446 | } 447 | 448 | // keep whichever is closest to value 449 | dist1, dist2 := r1.ref.Distance(value), r2.ref.Distance(value) 450 | if dist1 < dist2 { 451 | r2.Remove(value.Index) 452 | } else { 453 | r1.Remove(value.Index) 454 | } 455 | } 456 | } 457 | 458 | func replaceDuplicates(t objectfile.Type, obj *objectfile.OBJ, replacements replacerList) { 459 | rStart := time.Now() 460 | 461 | indexToRef := replacements.FlattenGeometry() 462 | 463 | replaced := 0 464 | for _, child := range obj.Objects { 465 | for _, vt := range child.VertexData { 466 | // catch newly added types that are not implemented yet here 467 | if vt.Type != objectfile.Face && vt.Type != objectfile.Line && vt.Type != objectfile.Point { 468 | logFatal("Unsupported vertex data type %q for replacing duplicates\n\nPlease submit a bug report. If you can, provide this file as an attachement.\n> %s\n", vt.Type, ApplicationURL+"/issues") 469 | } 470 | for _, decl := range vt.Declarations { 471 | switch t { 472 | case objectfile.Vertex: 473 | if ref := indexToRef[decl.Vertex]; ref != nil { 474 | replaced++ 475 | decl.RefVertex.Discard = true 476 | decl.RefVertex = ref 477 | } 478 | case objectfile.UV: 479 | if ref := indexToRef[decl.UV]; ref != nil { 480 | replaced++ 481 | decl.RefUV.Discard = true 482 | decl.RefUV = ref 483 | } 484 | case objectfile.Normal: 485 | if ref := indexToRef[decl.Normal]; ref != nil { 486 | replaced++ 487 | decl.RefNormal.Discard = true 488 | decl.RefNormal = ref 489 | } 490 | } 491 | } 492 | } 493 | } 494 | logInfo(" - %-2s %7d refs replaced in %s", t, replaced, formatDurationSince(rStart)) 495 | } 496 | -------------------------------------------------------------------------------- /process-merge.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/jonnenauha/obj-simplify/objectfile" 8 | ) 9 | 10 | type Merge struct{} 11 | 12 | type merger struct { 13 | Material string 14 | Objects []*objectfile.Object 15 | } 16 | 17 | func (processor Merge) Name() string { 18 | return "Merge" 19 | } 20 | 21 | func (processor Merge) Desc() string { 22 | return "Merges objects and groups with the same material into a single mesh." 23 | } 24 | 25 | func (processor Merge) Execute(obj *objectfile.OBJ) error { 26 | // use an array to preserve original order and 27 | // to produce always the same output with same input. 28 | // Map will 'randomize' keys in golang on each run. 29 | materials := make([]*merger, 0) 30 | 31 | for _, child := range obj.Objects { 32 | // skip children that do not declare faces etc. 33 | if len(child.VertexData) == 0 { 34 | continue 35 | } 36 | found := false 37 | for _, m := range materials { 38 | if m.Material == child.Material { 39 | m.Objects = append(m.Objects, child) 40 | found = true 41 | break 42 | } 43 | } 44 | if !found { 45 | materials = append(materials, &merger{ 46 | Material: child.Material, 47 | Objects: []*objectfile.Object{child}, 48 | }) 49 | } 50 | } 51 | logInfo(" - Found %d unique materials", len(materials)) 52 | 53 | mergeName := func(objects []*objectfile.Object) string { 54 | parts := []string{} 55 | for _, child := range objects { 56 | if len(child.Name) > 0 { 57 | parts = append(parts, child.Name) 58 | } 59 | } 60 | if len(parts) == 0 { 61 | parts = append(parts, "Unnamed") 62 | } 63 | name := strings.Join(parts, " ") 64 | // we might be merging hundreds or thousands of objects, at which point 65 | // the name becomes huge. Clamp with arbitrary 256 chars. 66 | if len(name) > 256 { 67 | name = "" 68 | for i, child := range objects { 69 | if len(child.Name) == 0 { 70 | continue 71 | } 72 | if len(name)+len(child.Name) < 256 { 73 | name += child.Name + " " 74 | } else { 75 | name += fmt.Sprintf("(and %d others)", len(objects)-i) 76 | break 77 | } 78 | } 79 | } 80 | return name 81 | } 82 | 83 | mergeComments := func(objects []*objectfile.Object) (comments []string) { 84 | for _, child := range objects { 85 | if len(child.Comments) > 0 { 86 | comments = append(comments, child.Comments...) 87 | } 88 | } 89 | return comments 90 | } 91 | 92 | // reset objects, we are about to rewrite them 93 | obj.Objects = make([]*objectfile.Object, 0) 94 | 95 | for _, merger := range materials { 96 | src := merger.Objects[0] 97 | child := obj.CreateObject(src.Type, mergeName(merger.Objects), merger.Material) 98 | child.Comments = mergeComments(merger.Objects) 99 | for _, original := range merger.Objects { 100 | child.VertexData = append(child.VertexData, original.VertexData...) 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | // strings 14 | 15 | type caseSensitivity int 16 | 17 | const ( 18 | caseSensitive caseSensitivity = 0 19 | caseInsensitive caseSensitivity = 1 20 | ) 21 | 22 | func strIndexOf(str1, str2 string, from int, cs caseSensitivity) int { 23 | if from >= len(str1) { 24 | return -1 25 | } 26 | src := str1 27 | if from < 0 { 28 | from = 0 29 | } else if from > 0 { 30 | src = src[from:] 31 | } 32 | i := -1 33 | if cs == caseSensitive { 34 | i = strings.Index(src, str2) 35 | } else { 36 | i = strings.Index(strings.ToLower(src), strings.ToLower(str2)) 37 | } 38 | if i >= 0 { 39 | i += from 40 | } 41 | return i 42 | } 43 | 44 | func strStartsWith(str, prefix string, cs caseSensitivity) bool { 45 | if cs == caseSensitive { 46 | return strings.HasPrefix(str, prefix) 47 | } 48 | return strings.HasPrefix(strings.ToLower(str), strings.ToLower(prefix)) 49 | } 50 | 51 | func strEndsWith(str, postfix string, cs caseSensitivity) bool { 52 | if cs == caseSensitive { 53 | return strings.HasSuffix(str, postfix) 54 | } 55 | return strings.HasSuffix(strings.ToLower(str), strings.ToLower(postfix)) 56 | } 57 | 58 | func strContains(str, part string, cs caseSensitivity) bool { 59 | if cs == caseSensitive { 60 | return strings.Contains(str, part) 61 | } 62 | return strings.Contains(strings.ToLower(str), strings.ToLower(part)) 63 | } 64 | 65 | func strContainsAny(str string, parts []string, cs caseSensitivity) bool { 66 | for _, part := range parts { 67 | if strContains(str, part, cs) { 68 | return true 69 | } 70 | } 71 | return false 72 | } 73 | 74 | func substring(str string, i int, iEnd int) string { 75 | strLen := len(str) 76 | if i < 0 { 77 | i = 0 78 | } 79 | if i >= strLen { 80 | return str 81 | } else if iEnd < 0 || iEnd >= strLen { 82 | return str[i:] 83 | } 84 | return str[i:iEnd] 85 | } 86 | 87 | func substringBefore(str, sep string, includeSeparator bool, cs caseSensitivity) string { 88 | i := strIndexOf(str, sep, -1, cs) 89 | if i < 0 { 90 | return str 91 | } 92 | if includeSeparator { 93 | i += len(sep) 94 | } 95 | return substring(str, 0, i) 96 | } 97 | 98 | // files 99 | 100 | func cleanPath(path string) string { 101 | if len(path) == 0 { 102 | return path 103 | } 104 | path, err := filepath.Abs(path) 105 | logFatalError(err) 106 | return filepath.ToSlash(filepath.Clean(path)) 107 | } 108 | 109 | func fileExists(path string) bool { 110 | if len(path) == 0 { 111 | return false 112 | } 113 | if _, err := os.Stat(path); os.IsNotExist(err) { 114 | return false 115 | } 116 | return true 117 | } 118 | 119 | func fileBasename(path string) string { 120 | p := cleanPath(path) 121 | if p[len(p)-1] == '/' { 122 | p = p[0 : len(p)-1] 123 | } 124 | if i := strings.LastIndex(p, "/"); i != -1 { 125 | p = p[i+1:] 126 | } 127 | if i := strings.LastIndex(p, "."); i != -1 { 128 | p = p[0:i] 129 | } 130 | return p 131 | } 132 | 133 | // The returned ext is always lower-cased and contains a prefix "." dot (e.g. ".png") 134 | func fileExtension(path string) string { 135 | // Strip query from URLs http(s)://domain.com/path/to/my.jpg?id=123312 136 | if strStartsWith(path, "http", caseInsensitive) && strContains(path, "?", caseSensitive) { 137 | path = substringBefore(path, "?", false, caseSensitive) 138 | } 139 | return strings.ToLower(filepath.Ext(path)) 140 | } 141 | 142 | func fileSize(path string) int64 { 143 | if len(path) == 0 { 144 | return 0 145 | } 146 | if fi, err := os.Stat(path); os.IsNotExist(err) { 147 | return 0 148 | } else { 149 | return fi.Size() 150 | } 151 | } 152 | 153 | // formatting 154 | 155 | func formatInt(num int) string { 156 | str := intToString(num) 157 | for i := len(str) - 1; i > 2; i -= 3 { 158 | if str[0:i-2] == "-" { 159 | break 160 | } 161 | str = str[0:i-2] + " " + str[i-2:] 162 | } 163 | return str 164 | } 165 | 166 | func formatUInt(num uint) string { 167 | str := uintToString(num) 168 | for i := len(str) - 1; i > 2; i -= 3 { 169 | str = str[0:i-2] + " " + str[i-2:] 170 | } 171 | return str 172 | } 173 | 174 | func intToString(num int) string { 175 | return strconv.Itoa(num) 176 | } 177 | 178 | func uintToString(num uint) string { 179 | return strconv.FormatUint(uint64(num), 10) 180 | } 181 | 182 | func formatFloat32(f float32, decimals int) string { 183 | return strconv.FormatFloat(float64(f), 'f', decimals, 32) 184 | } 185 | 186 | func formatFloat64(f float64, decimals int) string { 187 | return strconv.FormatFloat(f, 'f', decimals, 64) 188 | } 189 | 190 | func formatBytes(numBytes int64) string { 191 | prefix := "" 192 | numAbs := numBytes 193 | if numBytes < 0 { 194 | prefix = "-" 195 | numAbs = -numBytes 196 | } 197 | if numAbs >= 1024 { 198 | if numAbs >= 1024*1024 { 199 | if numAbs >= 1024*1024*1024 { 200 | return fmt.Sprintf("%s%.*f GB", prefix, 2, (float32(numAbs)/1024.0)/1024.0/1024.0) 201 | } 202 | return fmt.Sprintf("%s%.*f MB", prefix, 2, (float32(numAbs)/1024.0)/1024.0) 203 | } 204 | return fmt.Sprintf("%s%.*f kB", prefix, 2, float32(numAbs)/1024.0) 205 | } 206 | return fmt.Sprintf("%s%d B", prefix, numAbs) 207 | } 208 | 209 | func formatDurationSince(t time.Time) string { 210 | return formatDuration(time.Since(t)) 211 | } 212 | 213 | func formatDuration(d time.Duration) (duration string) { 214 | if d.Minutes() < 1.0 { 215 | // sec 216 | duration = fmt.Sprintf("%ss", strconv.FormatFloat(d.Seconds(), 'f', 2, 64)) 217 | } else if d.Minutes() < 60.0 { 218 | // min sec 219 | s := math.Mod(d.Seconds(), 60.0) 220 | duration = fmt.Sprintf("%dm %ss", int(math.Floor(d.Minutes())), 221 | strconv.FormatFloat(s, 'f', 2, 64)) 222 | } else { 223 | s := math.Mod(d.Seconds(), 60.0) 224 | m := math.Mod(d.Minutes(), 60.0) 225 | if d.Hours() < 24.0 { 226 | // hour min sec 227 | duration = fmt.Sprintf("%dh %dm %ss", int(math.Floor(d.Hours())), 228 | int(math.Floor(m)), strconv.FormatFloat(s, 'f', 2, 64)) 229 | } else { 230 | h := math.Mod(d.Hours(), 24.0) 231 | days := d.Hours() / 24.0 232 | if days < 7.0 { 233 | // day hour min sec 234 | duration = fmt.Sprintf("%dd %dh %dm %ss", int(math.Floor(days)), int(math.Floor(h)), 235 | int(math.Floor(m)), strconv.FormatFloat(s, 'f', 2, 64)) 236 | } else { 237 | // week day hour min sec 238 | w := math.Floor(days / 7.0) 239 | days := math.Mod(days, 7.0) 240 | duration = fmt.Sprintf("%dw %dd %dh %dm %ss", int(w), int(math.Floor(days)), 241 | int(math.Floor(h)), int(math.Floor(m)), strconv.FormatFloat(s, 'f', 2, 64)) 242 | } 243 | } 244 | } 245 | return 246 | } 247 | --------------------------------------------------------------------------------