From 651afe089ed285e23068d549edb984d47726dae0 Mon Sep 17 00:00:00 2001 From: Paul Du Bois Date: Tue, 13 Sep 2016 23:16:56 -0700 Subject: [PATCH] Initial commit Change-Id: I43de759c9ff56060b079ae3fe81133e280b3e144 --- .gitattributes | 5 + .gitignore | 1 + CONTRIBUTING.md | 27 ++ LICENSE | 202 ++++++++++++++ Python/tiltbrush/__init__.py | 14 + Python/tiltbrush/export.py | 282 +++++++++++++++++++ Python/tiltbrush/tilt.py | 505 +++++++++++++++++++++++++++++++++++ Python/tiltbrush/unpack.py | 182 +++++++++++++ README.md | 23 ++ bin/dump_tilt.py | 97 +++++++ bin/geometry_json_to_fbx.py | 253 ++++++++++++++++++ bin/geometry_json_to_obj.py | 134 ++++++++++ bin/tilt_to_strokes_dae.py | 237 ++++++++++++++++ bin/unpack_tilt.py | 55 ++++ 14 files changed, 2017 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Python/tiltbrush/__init__.py create mode 100644 Python/tiltbrush/export.py create mode 100644 Python/tiltbrush/tilt.py create mode 100644 Python/tiltbrush/unpack.py create mode 100644 README.md create mode 100644 bin/dump_tilt.py create mode 100644 bin/geometry_json_to_fbx.py create mode 100644 bin/geometry_json_to_obj.py create mode 100644 bin/tilt_to_strokes_dae.py create mode 100644 bin/unpack_tilt.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1f344e45 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Unity generates these with LF, so keep them LF to prevent +# spurious diffs when Unity overwrites CRLF with LF +*.asset text eol=lf +*.meta text eol=lf +*.unity text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0d20b648 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..80980cc2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +Want to contribute? Great! First, read this page (including the small print at the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement] +(https://cla.developers.google.com/about/google-individual) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things--for instance that you'll tell us if you +know that your code infringes on other people's patents. You don't have to sign +the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews +All submissions, including submissions by project members, require review. We +use Github pull requests for this purpose. + +### The small print +Contributions made by corporations are covered by a different agreement than +the one above, the +[Software Grant and Corporate Contributor License Agreement] +(https://cla.developers.google.com/about/google-corporate). \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Python/tiltbrush/__init__.py b/Python/tiltbrush/__init__.py new file mode 100644 index 00000000..24eb9c25 --- /dev/null +++ b/Python/tiltbrush/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/Python/tiltbrush/export.py b/Python/tiltbrush/export.py new file mode 100644 index 00000000..7e5216b7 --- /dev/null +++ b/Python/tiltbrush/export.py @@ -0,0 +1,282 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Code for parsing Tilt Brush's json-based geometry export format. +Typically you should prefer the .fbx exported straight out of Tilt Brush. +See: + iter_strokes() + class TiltBrushMesh""" + +import base64 +from itertools import izip_longest +import json +import struct +from uuid import UUID + +SINGLE_SIDED_FLAT_BRUSH = set([ + UUID("cb92b597-94ca-4255-b017-0e3f42f12f9e"), # Fire + UUID("cf019139-d41c-4eb0-a1d0-5cf54b0a42f3"), # Highlighter + UUID("e8ef32b1-baa8-460a-9c2c-9cf8506794f5"), # Hypercolor + UUID("2241cd32-8ba2-48a5-9ee7-2caef7e9ed62"), # Light + UUID("c33714d1-b2f9-412e-bd50-1884c9d46336"), # Plasma + UUID("ad1ad437-76e2-450d-a23a-e17f8310b960"), # Rainbow + UUID("44bb800a-fbc3-4592-8426-94ecb05ddec3"), # Streamers + UUID("d229d335-c334-495a-a801-660ac8a87360"), # Velvet Ink +]) + +def _grouper(n, iterable, fillvalue=None): + """grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx""" + args = [iter(iterable)] * n + return izip_longest(fillvalue=fillvalue, *args) + + +def iter_meshes(filename): + """Given a Tilt Brush .json export, yields TiltBrushMesh instances.""" + obj = json.load(file(filename, 'rb')) + lookup = obj['brushes'] + for dct in lookup: + dct['guid'] = UUID(dct['guid']) + for json_stroke in obj['strokes']: + yield TiltBrushMesh._from_json(json_stroke, lookup) + + +class TiltBrushMesh(object): + """Geometry for a single stroke/mesh. + Public attributes: + .brush_name Roughly analagous to a material + .brush_guid + + .v list of positions (3-tuples) + .n list of normals (3-tuples, or None if missing) + .uv0 list of uv0 (2-, 3-, 4-tuples, or None if missing) + .uv1 see uv0 + .c list of colors, as a uint32. abgr little-endian, rgba big-endian + .t list of tangents (4-tuples, or None if missing) + + .tri list of triangles (3-tuples of ints) + """ + VERTEX_ATTRIBUTES = [ + # Attribute name, type code + ('v', 'f', None), + ('n', 'f', 3), + ('uv0','f', None), + ('uv1','f', None), + ('c', 'I', 1), + ('t', 'f', 4), + ] + + @classmethod + def _from_json(cls, obj, brush_lookup): + """Factory method: For use by iter_meshes.""" + empty = None + + stroke = TiltBrushMesh() + brush = brush_lookup[obj['brush']] + stroke.brush_name = brush['name'] + stroke.brush_guid = UUID(str(brush['guid'])) + + # Vertex attributes + # If stroke is non-empty, 'v' is always present, and always comes first + num_verts = 0 + for attr, typechar, expected_stride in cls.VERTEX_ATTRIBUTES: + if attr in obj: + data_bytes = base64.b64decode(obj[attr]) + if len(data_bytes) == 0: + data_grouped = [] + else: + fmt = "<%d%c" % (len(data_bytes) / 4, typechar) + data_words = struct.unpack(fmt, data_bytes) + if attr == 'v': + num_verts = len(data_words) / 3 + assert (len(data_words) % num_verts) == 0 + stride_words = len(data_words) / num_verts + assert (expected_stride is None) or (stride_words == expected_stride) + if stride_words > 1: + data_grouped = list(_grouper(stride_words, data_words)) + else: + data_grouped = list(data_words) + setattr(stroke, attr, data_grouped) + else: + # For convenience, fill in with an empty array + if empty is None: + empty = [None,] * num_verts + setattr(stroke, attr, empty) + + # Triangle indices. 'tri' might not exist, if empty + if 'tri' in obj: + data_bytes = base64.b64decode(obj['tri']) + data_words = struct.unpack("<%dI" % (len(data_bytes) / 4), data_bytes) + assert len(data_words) % 3 == 0 + stroke.tri = list(_grouper(3, data_words)) + else: + stroke.tri = [] + + return stroke + + @classmethod + def from_meshes(cls, strokes, name=None): + """Collapses multiple TiltBrushMesh instances into one. + Pass an iterable of at least 1 stroke. + Uses the brush from the first stroke.""" + stroke_list = list(strokes) + dest = TiltBrushMesh() + dest.name = name + dest.brush_name = stroke_list[0].brush_name + dest.brush_guid = stroke_list[0].brush_guid + dest.v = [] + dest.n = [] + dest.uv0 = [] + dest.uv1 = [] + dest.c = [] + dest.t = [] + dest.tri = [] + for stroke in stroke_list: + offset = len(dest.v) + dest.v.extend(stroke.v) + dest.n.extend(stroke.n) + dest.uv0.extend(stroke.uv0) + dest.uv1.extend(stroke.uv1) + dest.c.extend(stroke.c) + dest.t.extend(stroke.t) + dest.tri.extend([ (t[0] + offset, t[1] + offset, t[2] + offset) + for t in stroke.tri ]) + return dest + + def __init__(self): + self.name = None + self.brush_name = self.brush_guid = None + self.v = self.n = self.uv0 = self.uv1 = self.c = self.t = None + self.tri = None + + def collapse_verts(self, ignore=None): + """Collapse verts with identical data. + Put triangle indices into a canonical order, with lowest index first. + *ignore* is a list of attribute names to ignore when comparing.""" + # Convert from SOA to AOS + compare = set(('n', 'uv0', 'uv1', 'c', 't')) + if ignore is not None: + compare -= set(ignore) + compare = sorted(compare) + compare.insert(0, 'v') + + struct_of_arrays = [] + for attr_name in sorted(compare): + struct_of_arrays.append(getattr(self, attr_name)) + vert_structs = zip(*struct_of_arrays) + + vert_struct_to_new_index = {} + old_index_to_new_index = [] + new_index_to_old_index = [] + + for i_old, v in enumerate(vert_structs): + i_next = len(vert_struct_to_new_index) + i_new = vert_struct_to_new_index.setdefault(v, i_next) + if i_next == i_new: + # New vertex seen + new_index_to_old_index.append(i_old) + old_index_to_new_index.append(i_new) + + def permute(old_lst, new_to_old=new_index_to_old_index): + # Returns content of old_lst in a new order + return [old_lst[i_old] for (i_new, i_old) in enumerate(new_to_old)] + + def remap_tri((t0, t1, t2), old_to_new=old_index_to_new_index): + # Remaps triangle indices; remapped triangle indices will be + # rotated so that the lowest vert index comes first. + t0 = old_to_new[t0] + t1 = old_to_new[t1] + t2 = old_to_new[t2] + if t0 <= t1 and t0 <= t2: + return (t0, t1, t2) + elif t1 <= t2: + return (t1, t2, t0) + else: + return (t2, t0, t1) + + self.v = permute(self.v) + self.n = permute(self.n) + self.uv0 = permute(self.uv0) + self.uv1 = permute(self.uv1) + self.c = permute(self.c) + self.t = permute(self.t) + + self.tri = map(remap_tri, self.tri) + + def add_backfaces(self): + """Double the number of triangles by adding an oppositely-wound + triangle for every existing triangle.""" + num_verts = len(self.v) + + def flip_vec3(val): + if val is None: return None + return (-val[0], -val[1], -val[2]) + + # Duplicate vert data, flipping normals + # This is safe because the values are tuples (and immutable) + self.v *= 2 + self.n += map(flip_vec3, self.n) + self.uv0 *= 2 + self.uv1 *= 2 + self.c *= 2 + self.t *= 2 + + more_tris = [] + for tri in self.tri: + more_tris.append((num_verts + tri[0], + num_verts + tri[2], + num_verts + tri[1])) + self.tri += more_tris + + def remove_backfaces(self): + """Remove backfaces, defined as any triangle that follows + an oppositely-wound triangle using the same indices. + Assumes triangle indices are in canonical order.""" + # (also removes duplicates, if any exist) + seen = set() + new_tri = [] + for tri in self.tri: + # Since triangle indices are in a canonical order, the reverse + # winding will always be t[0], t[2], t[1] + if tri in seen or (tri[0], tri[2], tri[1]) in seen: + pass + else: + seen.add(tri) + new_tri.append(tri) + self.tri = new_tri + + def remove_degenerate(self): + """Removes degenerate triangles.""" + def is_degenerate((t0, t1, t2)): + return t0==t1 or t1==t2 or t2==t0 + self.tri = [t for t in self.tri if not is_degenerate(t)] + + def add_backfaces_if_necessary(self): + """Try to detect geometry that is missing backface geometry""" + + def recenter(self): + a0 = sum(v[0] for v in self.v) / len(self.v) + a1 = sum(v[1] for v in self.v) / len(self.v) + a2 = sum(v[2] for v in self.v) / len(self.v) + for i,v in enumerate(self.v): + self.v[i] = (v[0]-a0, v[1]-a1, v[2]-a2) + + def dump(self, verbose=False): + print " Brush: %s, %d verts, %d tris" % (self.brush_guid, len(self.v), len(self.tri)/3) + if verbose: + print ' v' + for v in self.v: + print ' ',v + print ' t' + for t in self.tri: + print ' ',t diff --git a/Python/tiltbrush/tilt.py b/Python/tiltbrush/tilt.py new file mode 100644 index 00000000..3085c935 --- /dev/null +++ b/Python/tiltbrush/tilt.py @@ -0,0 +1,505 @@ +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Reads and writes .tilt files. The main export is 'class Tilt'.""" + +import os +import math +import json +import uuid +import struct +import contextlib +from collections import defaultdict +from cStringIO import StringIO + +__all__ = ('Tilt', 'Sketch', 'Stroke', 'ControlPoint', + 'BadTilt', 'BadMetadata', 'MissingKey') + +# Format characters are as for struct.pack/unpack, with the addition of +# '@' which is a 4-byte-length-prefixed data blob. +STROKE_EXTENSION_BITS = { + 0x1: ('flags', 'I'), + 0x2: ('scale', 'f'), + 'unknown': lambda bit: ('stroke_ext_%d' % math.log(bit, 2), + 'I' if (bit & 0xffff) else '@') +} +STROKE_EXTENSION_BY_NAME = dict( + (info[0], (bit, info[1])) + for (bit, info) in STROKE_EXTENSION_BITS.iteritems() + if bit != 'unknown' +) + +CONTROLPOINT_EXTENSION_BITS = { + 0x1: ('pressure', 'f'), + 0x2: ('timestamp', 'I'), + 'unknown': lambda bit: ('cp_ext_%d' % math.log(bit, 2), 'I') +} + +# +# Internal utils +# + +class memoized_property(object): + """Modeled after @property, but runs the getter exactly once""" + def __init__(self, fget): + self.fget = fget + self.name = fget.__name__ + + def __get__(self, instance, owner): + if instance is None: + return None + value = self.fget(instance) + # Since this isn't a data descriptor (no __set__ method), + # instance attributes take precedence over the descriptor. + setattr(instance, self.name, value) + return value + +class binfile(object): + # Helper for parsing + def __init__(self, inf): + self.inf = inf + + def read(self, n): + return self.inf.read(n) + + def write(self, data): + return self.inf.write(data) + + def read_length_prefixed(self): + n, = self.unpack(" list + - function writer(file, values) + - dict mapping extension_name -> extension_index + """ + infos = [] + while ext_mask: + bit = ext_mask & ~(ext_mask-1) + ext_mask = ext_mask ^ bit + try: info = ext_bits[bit] + except KeyError: info = ext_bits['unknown'](bit) + infos.append(info) + + if len(infos) == 0: + return (lambda f: [], lambda f,vs: None, {}) + + fmt = '<' + ''.join(info[1] for info in infos) + names = [info[0] for info in infos] + if '@' in fmt: + # struct.unpack isn't general enough to do the job + print fmt, names, infos + fmts = ['<'+info[1] for info in infos] + def reader(f, fmts=fmts): + values = [None] * len(fmts) + for i,fmt in enumerate(fmts): + if fmt == '<@': + nbytes, = struct.unpack('value to name->value + name_to_value = dict( (name, self.extension[idx]) + for (name, idx) in self.stroke_ext_lookup.iteritems() ) + name_to_value[name] = value + + bit, exttype = STROKE_EXTENSION_BY_NAME[name] + self.stroke_mask |= bit + _, self.stroke_ext_writer, self.stroke_ext_lookup = \ + _make_stroke_ext_reader(self.stroke_mask) + + # Convert back to idx->value + self.extension = [None] * len(self.stroke_ext_lookup) + for (name, idx) in self.stroke_ext_lookup.iteritems(): + self.extension[idx] = name_to_value[name] + + def has_cp_extension(self, name): + """Returns true if control points in this stroke have the requested extension data. + All control points in a stroke are guaranteed to use the same set of extensions. + + The current control point extensions are: + timestamp In seconds + pressure From 0 to 1""" + return name in self.cp_ext_lookup + + def get_cp_extension(self, cp, name): + """Returns the requested extension data, or raises LookupError if it doesn't exist.""" + idx = self.cp_ext_lookup[name] + return cp.extension[idx] + + def _write(self, b): + b.pack(" directory conversion process""" + pass + + +def _destroy(file_or_dir): + """Ensure that *file_or_dir* does not exist in the filesystem, + deleting it if necessary.""" + import stat + if os.path.isfile(file_or_dir): + os.chmod(file_or_dir, stat.S_IWRITE) + os.unlink(file_or_dir) + elif os.path.isdir(file_or_dir): + import shutil, stat + for r,ds,fs in os.walk(file_or_dir, topdown=False): + for f in fs: + os.chmod(os.path.join(r, f), stat.S_IWRITE) + os.unlink(os.path.join(r, f)) + for d in ds: + os.rmdir(os.path.join(r, d)) + os.rmdir(file_or_dir) + if os.path.exists(file_or_dir): + raise Exception("'%s' is not empty" % file_or_dir) + + +def _read_and_check_header(inf): + """Returns header bytes, or raise ConversionError if header looks invalid.""" + base_bytes = inf.read(struct.calcsize(HEADER_FMT)) + try: + (sentinel, headerSize, headerVersion) = struct.unpack(HEADER_FMT, base_bytes) + except struct.error as e: + raise ConversionError("Unexpected header error: %s" % (e,)) + + if sentinel != 'tilT': + raise ConversionError("Sentinel looks weird: %r" % sentinel) + + more = headerSize - len(base_bytes) + if more < 0: + raise ConversionError("Strange header size %s" % headerSize) + + more_bytes = inf.read(more) + if len(more_bytes) < more: + raise ConversionError("Bad header size (claim %s, actual %s)" % (more, len(more_bytes))) + + zip_sentinel = inf.read(4) + if zip_sentinel != '' and zip_sentinel != 'PK\x03\x04': + raise ConversionError("Don't see zip sentinel after header: %r" % (zip_sentinel,)) + + if headerVersion != 1: + raise ConversionError("Bogus version %s" % headerVersion) + return base_bytes + more_bytes + + +def convert_zip_to_dir(in_name): + """Returns True if compression was used""" + with file(in_name, 'rb') as inf: + header_bytes = _read_and_check_header(inf) + + compression = False + out_name = in_name + '._part' + if os.path.exists(out_name): + raise ConversionError("Remove %s first" % out_name) + + try: + os.makedirs(out_name) + + with zipfile.ZipFile(in_name) as zf: + for member in zf.infolist(): + if member.compress_size != member.file_size: + compression = True + zf.extract(member, out_name) + with file(os.path.join(out_name, 'header.bin'), 'wb') as outf: + outf.write(header_bytes) + + tmp = in_name + '._prev' + os.rename(in_name, tmp) + os.rename(out_name, in_name) + _destroy(tmp) + + return compression + finally: + _destroy(out_name) + + +def convert_dir_to_zip(in_name, compress): + in_name = os.path.normpath(in_name) # remove trailing '/' if any + out_name = in_name + '.part' + if os.path.exists(out_name): + raise ConversionError("Remove %s first" % out_name) + + def by_standard_order(filename): + lfile = filename.lower() + try: + idx = STANDARD_FILE_ORDER[lfile] + except KeyError: + raise ConversionError("Unknown file %s; this is probably not a .tilt" % filename) + return (idx, lfile) + + # Make sure metadata.json looks like valid utf-8 (rather than latin-1 + # or something else that will cause mojibake) + try: + with file(os.path.join(in_name, 'metadata.json')) as inf: + import json + json.load(inf) + except IOError as e: + raise ConversionError("Cannot validate metadata.json: %s" % e) + except UnicodeDecodeError as e: + raise ConversionError("metadata.json is not valid utf-8: %s" % e) + except ValueError as e: + raise ConversionError("metadata.json is not valid json: %s" % e) + + compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED + try: + header_bytes = None + + zipf = StringIO() + with zipfile.ZipFile(zipf, 'a', compression, False) as zf: + for (r, ds, fs) in os.walk(in_name): + fs.sort(key=by_standard_order) + for f in fs: + fullf = os.path.join(r, f) + if f == 'header.bin': + header_bytes = file(fullf).read() + continue + arcname = fullf[len(in_name)+1:] + zf.write(fullf, arcname, compression) + + if header_bytes is None: + print "Missing header; using default" + header_bytes = struct.pack(HEADER_V1_FMT, 'tilT', struct.calcsize(HEADER_V1_FMT), 1, 0, 0) + + if not _read_and_check_header(StringIO(header_bytes)): + raise ConversionError("Invalid header.bin") + + with file(out_name, 'wb') as outf: + outf.write(header_bytes) + outf.write(zipf.getvalue()) + + tmp = in_name + '._prev' + os.rename(in_name, tmp) + os.rename(out_name, in_name) + _destroy(tmp) + + finally: + _destroy(out_name) diff --git a/README.md b/README.md new file mode 100644 index 00000000..2927a704 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Tilt Brush Toolkit + +## Overview + +The Tilt Brush Toolkit is a collection of scripts and assets that +allow you to use [Tilt Brush](http://g.co/tiltbrush) data in your +creative projects. + +This is not an official Google product. + +## Contents + + * `bin` - command-line tools + * `dump_tilt.py` - Sample code that uses the tiltbrush.tilt module to view raw Tilt Brush data. + * `geometry_json_to_fbx.py` - Historical sample code that converts Tilt Brush .json exports to .fbx. This script is superseded by Tilt Brush native .fbx exports. + * `geometry_json_to_obj.py` - Historical sample code that converts Tilt Brush .json exports to .obj. This script is superseded by Tilt Brush native .fbx exports. + * `tilt_to_strokes_dae.py` - Converts .tilt files to a Collada .dae containing spline data. + * `unpack_tilt.py` - Converts .tilt files from packed format (zip) to unpacked format (directory) and vice versa, optionally applying compression. + * `Python` - Put this in your `PYTHONPATH` + * `tiltbrush` - Python package for manipulating Tilt Brush data + * `export.py` - Parse the legacy .json export format + * `tilt.py` - Read and write .tilt files + * `unpack.py` - Convert .tilt files from packed format to unpacked format and vice versa \ No newline at end of file diff --git a/bin/dump_tilt.py b/bin/dump_tilt.py new file mode 100644 index 00000000..5f2e5997 --- /dev/null +++ b/bin/dump_tilt.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python + +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This is sample Python 2.7 code that uses the tiltbrush.tilt module +to view raw Tilt Brush data.""" + +import os +import pprint +import sys + +try: + sys.path.append(os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), 'Python')) + from tiltbrush.tilt import Tilt +except ImportError: + print >>sys.stderr, "Please put the 'Python' directory in your PYTHONPATH" + sys.exit(1) + + +def dump_sketch(sketch): + """Prints out some rough information about the strokes. + Pass a tiltbrush.tilt.Sketch instance.""" + cooky, version, unused = sketch.header[0:3] + print 'Cooky:0x%08x Version:%s Unused:%s Extra:(%d bytes)' % ( + cooky, version, unused, len(sketch.additional_header)) + if len(sketch.strokes): + stroke = sketch.strokes[0] # choose one representative one + def extension_names(lookup): + # lookup is a dict mapping name -> idx + extensions = sorted(lookup.items(), key=lambda (n,i): i) + return ', '.join(name for (name, idx) in extensions) + print "Stroke Ext: %s" % extension_names(stroke.stroke_ext_lookup) + if len(stroke.controlpoints): + print "CPoint Ext: %s" % extension_names(stroke.cp_ext_lookup) + + for (i, stroke) in enumerate(sketch.strokes): + print "%3d: " % i, + dump_stroke(stroke) + + +def dump_stroke(stroke): + """Prints out some information about the stroke.""" + if len(stroke.controlpoints) and 'timestamp' in stroke.cp_ext_lookup: + cp = stroke.controlpoints[0] + timestamp = stroke.cp_ext_lookup['timestamp'] + start_ts = ' t:%6.1f' % (cp.extension[timestamp] * .001) + else: + start_ts = '' + + try: + scale = stroke.extension[stroke.stroke_ext_lookup['scale']] + except KeyError: + scale = 1 + + print "Brush: %2d Size: %.3f Color: #%02X%02X%02X %s [%4d]" % ( + stroke.brush_idx, stroke.brush_size * scale, + int(stroke.brush_color[0] * 255), + int(stroke.brush_color[1] * 255), + int(stroke.brush_color[2] * 255), + #stroke.brush_color[3], + start_ts, + len(stroke.controlpoints)) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="View information about a .tilt") + parser.add_argument('--strokes', action='store_true', help="Dump the sketch strokes") + parser.add_argument('--metadata', action='store_true', help="Dump the metadata") + parser.add_argument('files', type=str, nargs='+', help="Files to examine") + + args = parser.parse_args() + if not (args.strokes or args.metadata): + print "You should pass at least one of --strokes or --metadata" + + for filename in args.files: + t = Tilt(filename) + if args.strokes: + dump_sketch(t.sketch) + if args.metadata: + pprint.pprint(t.metadata) + +if __name__ == '__main__': + main() diff --git a/bin/geometry_json_to_fbx.py b/bin/geometry_json_to_fbx.py new file mode 100644 index 00000000..fe3e112d --- /dev/null +++ b/bin/geometry_json_to_fbx.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python + +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Historical sample code that converts Tilt Brush '.json' exports to .fbx. +# This script is superseded by Tilt Brush native .fbx exports. +# +# There are command-line options to fine-tune the fbx creation. +# The defaults are: +# +# - Weld vertices +# - Join strokes using the same brush into a single mesh +# - Don't create backface geometry for single-sided brushes""" + +import argparse +from itertools import groupby +import os +import platform +import sys + +try: + sys.path.append(os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), 'Python')) + from tiltbrush.export import iter_meshes, TiltBrushMesh, SINGLE_SIDED_FLAT_BRUSH +except ImportError: + print >>sys.stderr, "Please put the 'Python' directory in your PYTHONPATH" + sys.exit(1) + +try: + arch = 'x64' if '64' in platform.architecture()[0] else 'x86' + sys.path.append(r'c:\Program Files\Autodesk\FBX\FBX Python SDK\2015.1\lib\Python27_'+arch) + from fbx import * +except ImportError: + print >>sys.stderr, "Please install the Python fbx sdk:\nhttp://images.autodesk.com/adsk/files/fbx20151_fbxpythonsdk_win.exe" + sys.exit(1) + + +# ---------------------------------------------------------------------- +# Utils +# ---------------------------------------------------------------------- + +def as_fvec4(tup, scale=1): + if len(tup) == 3: + return FbxVector4(tup[0]*scale, tup[1]*scale, tup[2]*scale) + else: + return FbxVector4(tup[0]*scale, tup[1]*scale, tup[2]*scale, tup[3]*scale) + +def as_fvec2(tup): + return FbxVector2(tup[0], tup[1]) + +def as_fcolor(abgr_int, memo={}): + try: + return memo[abgr_int] + except KeyError: + a = (abgr_int >> 24) & 0xff + b = (abgr_int >> 16) & 0xff + g = (abgr_int >> 8) & 0xff + r = (abgr_int ) & 0xff + scale = 1.0 / 255.0 + memo[abgr_int] = val = FbxColor(r * scale, g * scale, b * scale, a * scale) + return val + +# ---------------------------------------------------------------------- +# Export +# ---------------------------------------------------------------------- + +def write_fbx_meshes(meshes, outf_name): + """Emit a TiltBrushMesh as a .fbx file""" + import FbxCommon + (sdk, scene) = FbxCommon.InitializeSdkObjects() + + for mesh in meshes: + add_mesh_to_scene(sdk, scene, mesh) + + FbxCommon.SaveScene(sdk, scene, outf_name) + + +def create_fbx_layer(fbx_mesh, data, converter_fn, layer_class, + allow_index=False, allow_allsame=False): + """Returns an instance of layer_class populated with the passed data, + or None if the passed data is empty/nonexistent. + + fbx_mesh FbxMesh + data list of Python data + converter_fn Function converting data -> FBX data + layer_class FbxLayerElementXxx class + allow_index Allow the use of eIndexToDirect mode. Useful if the data + has many repeated values. Unity3D doesn't seem to like it + when this is used for vertex colors, though. + allow_allsame Allow the use of eAllSame mode. Useful if the data might + be entirely identical.""" + # No elements, or all missing data. + if len(data) == 0 or data[0] == None: + return None + + layer_elt = layer_class.Create(fbx_mesh, "") + direct = layer_elt.GetDirectArray() + index = layer_elt.GetIndexArray() + + if allow_allsame or allow_index: + unique_data = sorted(set(data)) + + # Something about this eIndexToDirect code isn't working for vertex colors and UVs. + # Do it the long-winded way for now, I guess. + allow_index = False + if allow_allsame and len(unique_data) == 1: + layer_elt.SetMappingMode(FbxLayerElement.eAllSame) + layer_elt.SetReferenceMode(FbxLayerElement.eDirect) + direct.Add(converter_fn(unique_data[0])) + elif allow_index and len(unique_data) <= len(data) * .7: + layer_elt.SetMappingMode(FbxLayerElement.eByControlPoint) + layer_elt.SetReferenceMode(FbxLayerElement.eIndexToDirect) + for datum in unique_data: + direct.Add(converter_fn(datum)) + for i in range(len(data)-len(unique_data)-5): + direct.Add(converter_fn(unique_data[0])) + data_to_index = dict((d, i) for (i, d) in enumerate(unique_data)) + for i,datum in enumerate(data): + #index.Add(data_to_index[datum]) + index.Add(data_to_index[datum]) + else: + layer_elt.SetMappingMode(FbxLayerElement.eByControlPoint) + layer_elt.SetReferenceMode(FbxLayerElement.eDirect) + for datum in data: + direct.Add(converter_fn(datum)) + + return layer_elt + + +def add_mesh_to_scene(sdk, scene, mesh): + """Emit a TiltBrushMesh as a .fbx file""" + name = mesh.name or 'Tilt Brush' + + # Todo: pass scene instead? + fbx_mesh = FbxMesh.Create(sdk, name) + fbx_mesh.CreateLayer() + layer0 = fbx_mesh.GetLayer(0) + + # Verts + + fbx_mesh.InitControlPoints(len(mesh.v)) + for i, v in enumerate(mesh.v): + fbx_mesh.SetControlPointAt(as_fvec4(v, scale=100), i) + + layer_elt = create_fbx_layer( + fbx_mesh, mesh.n, as_fvec4, FbxLayerElementNormal) + if layer_elt is not None: + layer0.SetNormals(layer_elt) + + layer_elt = create_fbx_layer( + fbx_mesh, mesh.c, as_fcolor, FbxLayerElementVertexColor, + allow_index = True, + allow_allsame = True) + if layer_elt is not None: + layer0.SetVertexColors(layer_elt) + + # Tilt Brush may have 3- or 4-element UV channels, and may have multiple + # UV channels. This only handles the standard case of 2-component UVs + layer_elt = create_fbx_layer( + fbx_mesh, mesh.uv0, as_fvec2, FbxLayerElementUV, + allow_index = True) + if layer_elt is not None: + layer0.SetUVs(layer_elt, FbxLayerElement.eTextureDiffuse) + pass + + # Polygons + + for triplet in mesh.tri: + fbx_mesh.BeginPolygon(-1, -1, False) + fbx_mesh.AddPolygon(triplet[0]) + fbx_mesh.AddPolygon(triplet[1]) + fbx_mesh.AddPolygon(triplet[2]) + fbx_mesh.EndPolygon() + + # Node tree + + root = scene.GetRootNode() + node = FbxNode.Create(sdk, name) + node.SetNodeAttribute(fbx_mesh) + node.SetShadingMode(FbxNode.eTextureShading) # Hmm + root.AddChild(node) + + +# ---------------------------------------------------------------------- +# main +# ---------------------------------------------------------------------- + +def main(): + import argparse + parser = argparse.ArgumentParser(description="""Converts Tilt Brush '.json' exports to .fbx.""") + parser.add_argument('filename', help="Exported .json files to convert to fbx") + grp = parser.add_argument_group(description="Merging and optimization") + grp.add_argument('--merge-stroke', action='store_true', + help="Merge all strokes into a single mesh") + + grp.add_argument('--merge-brush', action='store_true', + help="(default) Merge strokes that use the same brush into a single mesh") + grp.add_argument('--no-merge-brush', action='store_false', dest='merge_brush', + help="Turn off --merge-brush") + + grp.add_argument('--weld-verts', action='store_true', + help="(default) Weld vertices") + grp.add_argument('--no-weld-verts', action='store_false', dest='weld_verts', + help="Turn off --weld-verts") + + parser.add_argument('--add-backface', action='store_true', + help="Add backfaces to strokes that don't have them") + + parser.add_argument('-o', dest='output_filename', metavar='FILE', + help="Name of output file; defaults to .fbx") + parser.set_defaults(merge_brush=True, weld_verts=True) + args = parser.parse_args() + + if args.output_filename is None: + args.output_filename = os.path.splitext(args.filename)[0] + '.fbx' + + meshes = list(iter_meshes(args.filename)) + for mesh in meshes: + mesh.remove_degenerate() + if args.add_backface and mesh.brush_guid in SINGLE_SIDED_FLAT_BRUSH: + mesh.add_backface() + + if args.merge_stroke: + meshes = [ TiltBrushMesh.from_meshes(meshes, name='strokes') ] + elif args.merge_brush: + def by_guid(m): return (m.brush_guid, m.brush_name) + meshes = [ TiltBrushMesh.from_meshes(list(group), name='All %s' % (key[1], )) + for (key, group) in groupby(sorted(meshes, key=by_guid), key=by_guid) ] + + if args.weld_verts: + for mesh in meshes: + # We don't write out tangents, so it's safe to ignore them when welding + mesh.collapse_verts(ignore=('t',)) + mesh.remove_degenerate() + + write_fbx_meshes(meshes, args.output_filename) + print "Wrote", args.output_filename + + +if __name__ == '__main__': + main() diff --git a/bin/geometry_json_to_obj.py b/bin/geometry_json_to_obj.py new file mode 100644 index 00000000..8394975d --- /dev/null +++ b/bin/geometry_json_to_obj.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python + +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Historical sample code that converts Tilt Brush '.json' exports to .obj. +# This script is superseded by Tilt Brush native .fbx exports. +# +# There are various possible ways you might want the .obj file converted: +# +# - Should the entire sketch be converted to a single mesh? Or all +# strokes that use the same brush? Or maybe one mesh per stroke? +# - Should backfaces be kept or removed? +# - Should vertices be welded? How aggressively? +# +# This sample keeps backfaces, merges all strokes into a single mesh, +# and does no vertex welding. It can also be easily customized to do any +# of the above. + +import argparse +import os +import sys + +try: + sys.path.append(os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), 'Python')) + from tiltbrush.export import iter_meshes, TiltBrushMesh, SINGLE_SIDED_FLAT_BRUSH +except ImportError: + print >>sys.stderr, "Please put the 'Python' directory in your PYTHONPATH" + sys.exit(1) + + +def write_obj(mesh, outf_name, use_color): + """Emits a TiltBrushMesh as a .obj file. + If use_color, emit vertex color as a non-standard .obj extension.""" + from cStringIO import StringIO + tmpf = StringIO() + + if use_color: + for v, c32 in zip(mesh.v, mesh.c): + r = ( (c32 >> 0) & 0xff ) / 255.0 + g = ( (c32 >> 8) & 0xff ) / 255.0 + b = ( (c32 >>16) & 0xff ) / 255.0 + tmpf.write("v %f %f %f %f %f %f\n" % (v[0], v[1], v[2], r, g, b)) + tmpf.write("vc %f %f %f\n" % (r, g, b)) + else: + for v in mesh.v: + tmpf.write("v %f %f %f\n" % v) + + has_uv = any(uv is not None for uv in mesh.uv0) + if has_uv: + has_uv = True + for uv in mesh.uv0: + if uv is not None: + tmpf.write("vt %f %f\n" % (uv[0], uv[1])) + else: + tmpf.write("vt 0 0\n") + + has_n = any(n is not None for n in mesh.n) + if has_n: + for n in mesh.n: + if n is not None: + tmpf.write("vn %f %f %f\n" % n) + else: + tmpf.write("vn 0 0 0\n") + + if has_n and has_uv: + for (t1, t2, t3) in mesh.tri: + t1 += 1; t2 += 1; t3 += 1 + tmpf.write("f %d/%d/%d %d/%d/%d %d/%d/%d\n" % (t1,t1,t1, t2,t2,t2, t3,t3,t3)) + elif has_n: + for (t1, t2, t3) in mesh.tri: + t1 += 1; t2 += 1; t3 += 1 + tmpf.write("f %d//%d %d//%d %d//%d\n" % (t1,t1, t2,t2, t3,t3)) + elif has_uv: + for (t1, t2, t3) in mesh.tri: + t1 += 1; t2 += 1; t3 += 1 + tmpf.write("f %d/%d %d/%d %d/%d\n" % (t1,t1, t2,t2, t3,t3)) + else: + for (t1, t2, t3) in mesh.tri: + t1 += 1; t2 += 1; t3 += 1 + tmpf.write("f %d %d %d\n" % (t1, t2, t3)) + + with file(outf_name, 'wb') as outf: + outf.write(tmpf.getvalue()) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Converts Tilt Brush '.json' exports to .obj.") + parser.add_argument('filename', help="Exported .json files to convert to obj") + parser.add_argument('--cooked', action='store_true', dest='cooked', default=True, + help="(default) Strip geometry of normals, weld verts, and give single-sided triangles corresponding backfaces.") + parser.add_argument('--color', action='store_true', + help="Add vertex color to 'v' and 'vc' elements. WARNING: May produce incompatible .obj files.") + parser.add_argument('--raw', action='store_false', dest='cooked', + help="Emit geometry just as it comes from Tilt Brush. Depending on the brush, triangles may not have backfaces, adjacent triangles will mostly not share verts.") + parser.add_argument('-o', dest='output_filename', metavar='FILE', + help="Name of output file; defaults to .obj") + args = parser.parse_args() + if args.output_filename is None: + args.output_filename = os.path.splitext(args.filename)[0] + '.obj' + + meshes = list(iter_meshes(args.filename)) + for mesh in meshes: + mesh.remove_degenerate() + + if args.cooked: + for mesh in meshes: + if mesh.brush_guid in SINGLE_SIDED_FLAT_BRUSH: + mesh.add_backfaces() + mesh = TiltBrushMesh.from_meshes(meshes) + mesh.collapse_verts(ignore=('uv0', 'uv1', 'c', 't')) + mesh.remove_degenerate() + else: + mesh = TiltBrushMesh.from_meshes(meshes) + + write_obj(mesh, args.output_filename, args.color) + print "Wrote", args.output_filename + + +if __name__ == '__main__': + main() diff --git a/bin/tilt_to_strokes_dae.py b/bin/tilt_to_strokes_dae.py new file mode 100644 index 00000000..4e5c3357 --- /dev/null +++ b/bin/tilt_to_strokes_dae.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python + +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import collections +import xml.etree.ElementTree as ET + +try: + sys.path.append(os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), 'Python')) + from tiltbrush.tilt import Tilt +except ImportError: + print >>sys.stderr, "Please put the 'Python' directory in your PYTHONPATH" + sys.exit(1) + + +def Element(tag, children=None, text=None, **attribs): + """Wrapper around ET.Element that makes adding children and text easier""" + child = ET.Element(tag, **attribs) + if text is not None: + child.text = text + if children is not None: + child.extend(children) + return child + + +def _indent(elem, level=0): + """Pretty-print indent an ElementTree.Element instance""" + i = "\n" + level*"\t" + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + "\t" + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + _indent(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + +class ColladaFile(object): + def __init__(self): + self.next_ids = collections.defaultdict(int) + + self.root = ET.Element( + 'COLLADA', + xmlns="http://www.collada.org/2008/03/COLLADASchema", + version="1.5.0") + self.tree = ET.ElementTree(self.root) + self._init_asset() + self.library_effects = ET.SubElement(self.root, 'library_effects') + self.library_materials = ET.SubElement(self.root, 'library_materials') + self.library_geometries = ET.SubElement(self.root, 'library_geometries') + self.library_visual_scenes = ET.SubElement(self.root, 'library_visual_scenes') + + self.material = self._init_material() + self.visual_scene = self._init_scene() + + def _init_asset(self): + import datetime + now = datetime.datetime.now() + self.root.append( + Element('asset', children=[ + Element('contributor', children=[ + Element('authoring_tool', text='Tilt Brush COLLADA stroke converter') + ]), + Element('created', text=now.isoformat()), + Element('modified', text=now.isoformat()), + Element('unit', meter='.1', name='decimeter'), + Element('up_axis', text='Y_UP') + ]) + ) + + def _init_material(self): + effect = ET.SubElement(self.library_effects, 'effect', id=self.make_id('effect_')) + effect.append( + Element('profile_COMMON', children=[ + Element('technique', sid='COMMON', children=[ + Element('blinn', children=[ + Element('diffuse', children=[ + Element('color', text='0.8 0.8 0.8 1'), + ]), + Element('specular', children=[ + Element('color', text='0.2 0.2 0.2 1'), + ]), + Element('shininess', children=[Element('float', text='0.5')]) + ]) + ]) + ]) + ) + material = ET.SubElement( + self.library_materials, 'material', id=self.make_id('material_'), + name="Mat") + ET.SubElement(material, 'instance_effect', url='#' + effect.get('id')) + return material + + def _init_scene(self): + visual_scene = ET.SubElement(self.library_visual_scenes, 'visual_scene', + id=self.make_id('scene_')) + self.root.append( + Element('scene', children=[ + Element('instance_visual_scene', url='#' + visual_scene.get('id')) + ]) + ) + return visual_scene + + def make_id(self, prefix='ID'): + val = self.next_ids[prefix] + self.next_ids[prefix] += 1 + new_id = prefix + str(val) + return new_id + + def write(self, filename): + header = '\n' + _indent(self.root) + with file(filename, 'wb') as outf: + outf.write(header) + self.tree.write(outf) + + def add_stroke(self, stroke): + geometry = self._add_stroke_geometry(stroke) + self._add_stroke_node(geometry) + + def _add_stroke_geometry(self, stroke): + def flatten(lst): + for elt in lst: + for subelt in elt: + yield subelt + def get_rh_positions(stroke): + for cp in stroke.controlpoints: + yield (-cp.position[0], cp.position[1], cp.position[2]) + + def iter_positions(stroke): + for cp in stroke.controlpoints: + # Switch from left-handed (unity) to right-handed + yield -cp.position[0] + yield cp.position[1] + yield cp.position[2] + + raw_floats = list(flatten(get_rh_positions(stroke))) + + assert len(raw_floats) % 3 == 0 + + geom_id = self.make_id('stroke_') + source_id = geom_id + '_src' + floats_id = geom_id + '_fs' + verts_id = geom_id + '_vs' + + geometry = ET.SubElement(self.library_geometries, 'geometry', id=geom_id) + geometry.append( + Element('mesh', children=[ + Element('source', id=source_id, children=[ + Element('float_array', id=floats_id, + count=str(len(raw_floats)), + text=' '.join(map(str, raw_floats))), + Element('technique_common', children=[ + Element('accessor', + count=str(len(raw_floats)/3), stride='3', + source='#' + floats_id, + children=[ + Element('param', name='X', type='float'), + Element('param', name='Y', type='float'), + Element('param', name='Z', type='float') + ]) + ]) + ]), + Element('vertices', id=verts_id, children=[ + Element('input', semantic='POSITION', source='#' + source_id) + ]), + Element('linestrips', count='1', material='Material1', children=[ + Element('input', offset='0', semantic='VERTEX', set='0', source='#' + verts_id), + Element('p', text=' '.join(map(str, xrange(len(raw_floats) / 3)))) + ]) + ]) + ) + + return geometry + + def _add_stroke_node(self, geometry): + name = 'Spline.' + geometry.get('id') + self.visual_scene.append( + Element('node', id=self.make_id('node_'), name=name, children=[ + Element('instance_geometry', url='#' + geometry.get('id'), children=[ + Element('bind_material', children=[ + Element('technique_common', children=[ + Element('instance_material', symbol='Material1', + target='#' + self.material.get('id'), + children=[ + Element('bind_vertex_input', + semantic='UVSET0', + input_semantic='TEXCOORD', + input_set='0') + ]) + ]) + ]) + ]) + ]) + ) + + +def main(args): + import argparse + parser = argparse.ArgumentParser(description="Converts .tilt files to a Collada .dae containing spline data.") + parser.add_argument('files', type=str, nargs='*', help="Files to convert to dae") + args = parser.parse_args(args) + + for filename in args.files: + t = Tilt(filename) + outf_name = os.path.splitext(os.path.basename(filename))[0] + '.dae' + + dae = ColladaFile() + for stroke in t.sketch.strokes: + dae.add_stroke(stroke) + + dae.write(outf_name) + print 'Wrote', outf_name + + +if __name__ == '__main__': + main(sys.argv[1:]) diff --git a/bin/unpack_tilt.py b/bin/unpack_tilt.py new file mode 100644 index 00000000..0ec502ca --- /dev/null +++ b/bin/unpack_tilt.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +# Copyright 2016 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys + +try: + sys.path.append(os.path.join(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))), 'Python')) + import tiltbrush.unpack +except ImportError: + print >>sys.stderr, "Please put the 'Python' directory in your PYTHONPATH" + sys.exit(1) + + +def convert(in_name, compress): + if os.path.isdir(in_name): + tiltbrush.unpack.convert_dir_to_zip(in_name, compress) + print "Converted %s to zip format" % in_name + elif os.path.isfile(in_name): + tiltbrush.unpack.convert_zip_to_dir(in_name) + print "Converted %s to directory format" % in_name + else: + raise tiltbrush.unpack.ConversionError("%s doesn't exist" % in_name) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Converts .tilt files from packed format (zip) to unpacked format (directory), optionally applying compression.") + parser.add_argument('files', type=str, nargs='+', + help="Files to convert to the other format") + parser.add_argument('--compress', action='store_true', + help="Use compression (default: off)") + args = parser.parse_args() + for arg in args.files: + try: + convert(arg, args.compress) + except tiltbrush.unpack.ConversionError as e: + print "ERROR: %s" % e + +if __name__ == '__main__': + main()