diff --git a/.workonrc.products b/.workonrc.products new file mode 100644 index 00000000..68440a06 --- /dev/null +++ b/.workonrc.products @@ -0,0 +1 @@ +partio diff --git a/.workonrc.py b/.workonrc.py new file mode 100644 index 00000000..9cfbc246 --- /dev/null +++ b/.workonrc.py @@ -0,0 +1,14 @@ +#!/usr/bin/python + +import os, dshell + +from pathfinder import build +_opts = build.Options(absolute=True) + +shell = dshell.create() +shell.export('PARTIO', os.getcwd()) + +# Add to paths +test_dir = build.test('partio', opts=_opts) +shell.prepend('PATH', test_dir) +shell.prepend('PYTHONPATH', test_dir) diff --git a/CMakeLists.txt b/CMakeLists.txt index ad8e283f..70de8447 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -39,6 +39,7 @@ PROJECT( partio LANGUAGES CXX VERSION 0.1.1 ) # PARTIO_SE_ENABLED enables SeExpr support option(PARTIO_SE_ENABLED "PARTIO_SE_ENABLED" false) +option(GTEST_ENABLED "Enable GTest for tests" false) # Enable C++11 SET(CMAKE_CXX_STANDARD 11) @@ -67,6 +68,9 @@ IF(NOT CMAKE_BUILD_TYPE) FORCE) ENDIF(NOT CMAKE_BUILD_TYPE) +include(CTest) +enable_testing() + ## Set install location IF(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) EXECUTE_PROCESS(COMMAND sh -c "echo `uname`-`uname -r | cut -d'-' -f1`-`uname -m`" OUTPUT_VARIABLE VARIANT_DIRECTORY OUTPUT_STRIP_TRAILING_WHITESPACE) @@ -103,6 +107,12 @@ if (${PARTIO_SE_ENABLED}) find_library(SEEXPR_LIB SeExpr HINTS ${SEEXPR_LINK_PATH}) endif () +if (${GTEST_ENABLED}) + set($GEST_LOCATION "/usr" CACHE STRING "gtest installation prefix") + set(GTEST_INCLUDE_PATH ${GTEST_BASE}/include) + set(GTEST_LIBDIR ${CMAKE_INSTALL_LIBDIR}) + set(GTEST_LINK_PATH ${GTEST_LOCATION}/${GTEST_LIBDIR} CACHE STRING "gtest library directory") +endif() ## Traverse subdirectories ADD_SUBDIRECTORY (src/lib) diff --git a/Makefile b/Makefile index 1b9205a3..784ea006 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ SH ?= sh uname_S := $(shell $(SH) -c 'uname -s || echo kernel') uname_R := $(shell $(SH) -c 'uname -r | cut -d- -f1 || echo release') uname_M := $(shell $(SH) -c 'uname -m || echo machine') +lib ?= lib64 FLAVOR ?= optimize platformdir ?= $(uname_S)-$(uname_R)-$(uname_M)-$(FLAVOR) builddir ?= $(CURDIR)/build/$(platformdir) @@ -28,17 +29,31 @@ ifdef RP_SeExpr CMAKE_FLAGS += -DPARTIO_SE_ENABLED=1 endif +# gtest location: RP_gtest= +ifdef RP_gtest + CMAKE_FLAGS += -DGTEST_LOCATION=$(RP_gtest) + CMAKE_FLAGS += -DGTEST_ENABLED=1 +endif + # Extra cmake flags: CMAKE_EXTRA_FLAGS= ifdef CMAKE_EXTRA_FLAGS CMAKE_FLAGS += $(CMAKE_EXTRA_FLAGS) endif +ifdef RP_zlib + CMAKE_FLAGS += -DZLIB_INCLUDE_DIR=$(RP_zlib)/include + CMAKE_FLAGS += -DZLIB_LIBRARY_RELEASE=$(RP_zlib)/$(lib)/libz.so +endif + # The default target in this Makefile is... all:: install: all $(MAKE) -C $(builddir) DESTDIR=$(DESTDIR) install +test: all + $(MAKE) -C $(builddir) DESTDIR=$(DESTDIR) test + $(builddir)/stamp: mkdir -p $(builddir) cd $(builddir) && cmake $(CMAKE_FLAGS) ../.. diff --git a/UPDATE b/UPDATE new file mode 100644 index 00000000..97d57931 --- /dev/null +++ b/UPDATE @@ -0,0 +1,6 @@ +1. fetch github-wdas/master +2. reset master from github-wdas master +3. push master to origin/master (origin/master should be identical to github-wdas/master at this point) +4. merge master into disney +5. push disney to origin/disney (do this without creating code review branch: + git push origin disney:disney) diff --git a/src/data/base.bgeo b/src/data/base.bgeo new file mode 100644 index 00000000..ac72cf49 Binary files /dev/null and b/src/data/base.bgeo differ diff --git a/src/data/delta.bgeo b/src/data/delta.bgeo new file mode 100644 index 00000000..ab6b443a Binary files /dev/null and b/src/data/delta.bgeo differ diff --git a/src/data/json.bgeo b/src/data/json.bgeo new file mode 100644 index 00000000..3346b369 Binary files /dev/null and b/src/data/json.bgeo differ diff --git a/src/lib/CMakeLists.txt b/src/lib/CMakeLists.txt index af47971f..54d01027 100644 --- a/src/lib/CMakeLists.txt +++ b/src/lib/CMakeLists.txt @@ -59,7 +59,9 @@ if (SEEXPR_BASE) target_include_directories(partioSe PUBLIC $ - $) + $ + PRIVATE + ${SEEXPR_INCLUDE_PATH}) target_link_libraries(partioSe PUBLIC partio ${SEEXPR_LIB}) install(TARGETS partioSe DESTINATION ${CMAKE_INSTALL_LIBDIR}) endif () diff --git a/src/lib/Partio.h b/src/lib/Partio.h index 6c2dc336..1dd6596b 100644 --- a/src/lib/Partio.h +++ b/src/lib/Partio.h @@ -318,5 +318,18 @@ void endCachedAccess(ParticlesData* particles); void print(const ParticlesData* particles); ParticlesDataMutable* computeClustering(ParticlesDataMutable* particles, const int numNeighbors,const double radiusSearch,const double radiusInside,const int connections,const double density); + +//! Merges one particle set into another +/*! + Given a ParticleSetMutable, merges it with a second ParticleSet, + copying particles and attributes that align with the base particle + set. If an identifier is provided, that will be used as a key + to replace the particle in the base set with the particle in the second + set with the same identifier attribute value. If the identifier is not + provided or the particle's attribute value is not found in the base set, + a new particle is added. If used, the identifier must be a single INT. +*/ +void merge(ParticlesDataMutable& base, const ParticlesData& delta, const std::string& identifier=std::string()); + } #endif diff --git a/src/lib/core/Particle.cpp b/src/lib/core/Particle.cpp index 51ece055..226c515b 100644 --- a/src/lib/core/Particle.cpp +++ b/src/lib/core/Particle.cpp @@ -43,6 +43,10 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. #include #include #include +#include +#include +#include + namespace Partio{ std::string @@ -161,18 +165,27 @@ printAttr(const ParticlesData* p,const ParticleAttribute& attr,const int particl for(int k=0;k +getAttrs(const ParticlesData& particles) +{ + std::vector attrs(particles.numAttributes()); + for(int i=0;inumParticles()<numAttributes()< attrs; - for(int i=0;inumAttributes();i++){ - ParticleAttribute attr; - particles->attributeInfo(i,attr); - attrs.push_back(attr); - std::cout<<"attribute "< attrs = getAttrs(*particles); + for (const ParticleAttribute& attr : attrs) { + std::cout << "attribute " << attr.name + << " type=" << TypeName(attr.type) + << " count=" << attr.count << std::endl; } int numToPrint=std::min(10,particles->numParticles()); @@ -183,22 +196,35 @@ print(const ParticlesData* particles) for(size_t k=0;k(it)[c]; + for(int c=0;c(it)[c]; + } break; case INT: - for(int c=0;c(it)[c]; + for(int c=0;c(it)[c]<<","; + } break; case INDEXEDSTR: - for(int c=0;c(it)[c]; + for(int c=0;c(it)[c]<<","; + } break; } + if (attrs[k].count > 1) std::cout<<")"; + std::cout<<"\t"; } std::cout< +struct AttributePair { + T base; + T delta; +}; + +void merge(ParticlesDataMutable& base, const ParticlesData& delta, const std::string& identifier) +{ + // Build a map from the identifier value to the particle index + // and locate the identifier attribute in the base. + // This assumes unique identifiers per particle. + std::unordered_map idToParticleIndex; + ParticleAttribute baseIdAttr; + bool baseHasIdentifier = base.attributeInfo(identifier.c_str(), baseIdAttr); + if (baseHasIdentifier) { + if (baseIdAttr.type == INT) { + for (int i=0; i(baseIdAttr,i)[0]] = i; + } + } else { + baseHasIdentifier = false; + } + } + + // Locate the identifier attribute in the delta + ParticleAttribute deltaIdAttr; + bool deltaHasIdentifier = delta.attributeInfo(identifier.c_str(), deltaIdAttr); + if (deltaHasIdentifier) { + deltaHasIdentifier &= deltaIdAttr.type == INT; + } + bool hasIdentifier = baseHasIdentifier && deltaHasIdentifier; + + // Identify the attributes to be copied (base present in delta) + std::vector> attrs; + std::vector indexStrAttrs; /* indexes into attrs */ + for (int i=0; i({std::move(baseAttr), std::move(deltaAttr)})); + } + } + + // Identify fixed attributes to override + for (int i=0; i(deltaAttr); + void *dst = base.fixedDataWrite(baseAttr); + std::memcpy(dst, src, size); + } + } + + // Identify fixed attributes to extend + for (int i=0; i(deltaAttr); + void *dst = base.fixedDataWrite(baseAttr); + std::memcpy(dst, src, size); + } + } + + // Merge the indexed strings. If the delta holds new strings for the same attribute, + // we have to re-index it and extend the base string list with the new strings. + // If the string exists in both, we still have to map the delta index to the base index. + std::unordered_map > stringToString; + for (size_t index : indexStrAttrs) { + const AttributePair& attr = attrs[index]; + + /* For each string in the delta, add to base if missing. And re-index. */ + const std::vector& baseStrs = base.indexedStrs(attr.base); + // Map source indices name->index for faster searching + std::unordered_map indexInBase; + for (size_t i=0; i& deltaStrs = delta.indexedStrs(attr.delta); + for (size_t i=0; isecond; + } else { + int newIndex = base.registerIndexedStr(attr.base, deltaStr.c_str()); + stringToString[attr.base.name][i] = newIndex; + } + } + } + + + // Loop through the delta particles and incorporate into the base + for (int i=0; i(deltaIdAttr, i)); + auto it = idToParticleIndex.find(idValue); + if (it != idToParticleIndex.end()) { + index = it->second; + } + } + if (index == -1) { + index = base.addParticle(); + } + + // Copy the attributes to the new/overridden particle + for (const AttributePair& attr : attrs) { + size_t size = Partio::TypeSize(attr.base.type) * attr.base.count; + void *dst = base.dataWrite(attr.base, index); + const void* src; + std::unique_ptr newIndices; + if (attr.base.type == INDEXEDSTR) { + newIndices.reset(new int[attr.base.count]); + const int* indices = delta.data(attr.delta, i); + for (int j=0; j(attr.delta, i); + } + std::memcpy(dst, src, size); + } + } +} + } diff --git a/src/py/example/listAttr.py b/src/py/example/listAttr.py index f21e5279..054fa32f 100644 --- a/src/py/example/listAttr.py +++ b/src/py/example/listAttr.py @@ -34,7 +34,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. import sys -import papi +import partio if __name__=="__main__": filename=None @@ -45,13 +45,13 @@ sys.exit(1) # read particle name - p=papi.read(filename) + p=partio.read(filename) for i in range(p.numAttributes()): attr=p.attributeInfo(i) typeStr="NONE" - if attr.type==papi.VECTOR: typeStr="VECTOR" - if attr.type==papi.FLOAT: typeStr="FLOAT" - if attr.type==papi.INT: typeStr="INT" + if attr.type==partio.VECTOR: typeStr="VECTOR" + if attr.type==partio.FLOAT: typeStr="FLOAT" + if attr.type==partio.INT: typeStr="INT" print "%10s[%d] %-30s "%(typeStr,attr.count,attr.name) diff --git a/src/py/partio.i b/src/py/partio.i index e9218246..c9d82001 100644 --- a/src/py/partio.i +++ b/src/py/partio.i @@ -43,6 +43,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. #if PARTIO_SE_ENABLED #include #endif +#include #include namespace Partio{ typedef uint64_t ParticleIndex; @@ -85,6 +86,23 @@ public: //int attributeIndex; }; +%feature("docstring","A handle for operating on fixed attribbutes of a particle set"); +class FixedAttribute +{ +public: + %feature("docstring","Type of the particle data (VECTOR,INT,FLOAT)"); + ParticleAttributeType type; + + %feature("docstring","Number of primitives (int's or float's)"); + int count; + + %feature("docstring","Attribute name"); + std::string name; + + // internal use + //int attributeIndex; +}; + %typemap(in) uint64_t { $1 = (uint64_t) PyLong_AsLong($input); @@ -453,7 +471,7 @@ public: %feature("autodoc"); %feature("docstring","Searches for and returns the attribute handle for a named fixed attribute"); - %newobject attributeInfo; + %newobject fixedAttributeInfo; FixedAttribute* fixedAttributeInfo(const char* name) { FixedAttribute a; @@ -479,7 +497,7 @@ public: %feature("autodoc"); %feature("docstring","Returns the fixed attribute handle by index"); - %newobject attributeInfo; + %newobject fixedAttributeInfo; FixedAttribute* fixedAttributeInfo(const int index) { if(index<0 || index>=$self->numFixedAttributes()){ @@ -537,7 +555,6 @@ ParticlesDataMutable* read(const char* filename,bool verbose=true,std::ostream& } %} - %feature("autodoc"); %feature("docstring","Reads a particle set headers from disk"); %newobject readHeaders; @@ -545,7 +562,7 @@ ParticlesInfo* readHeaders(const char* filename,bool verbose=true,std::ostream& %feature("autodoc"); %feature("docstring","Writes a particle set to disk"); -void write(const char* filename,const ParticlesData&,const bool=false); +void write(const char* filename,const ParticlesData&,const bool=false,const bool=true); %feature("autodoc"); %feature("docstring","Print a summary of particle file"); @@ -555,7 +572,20 @@ void print(const ParticlesData* particles); %feature("docstring","Creates a clustered particle set"); ParticlesDataMutable* computeClustering(ParticlesDataMutable* particles,const int numNeighbors,const double radiusSearch,const double radiusInside,const int connections,const double density)=0; +%feature("autodoc"); +%feature("docstring","Merge two particle sets"); +void merge(ParticlesDataMutable& base, const ParticlesData& delta, const std::string& identifier=std::string()); + +%feature("autodoc"); +%feature("docstring","Clone a particle set"); +ParticlesDataMutable* clone(const ParticlesData& other, bool particles); + +%feature("autodoc"); +%feature("docstring","Return string name of given attribute type"); +std::string TypeName(ParticleAttributeType attrType); + #if PARTIO_SE_ENABLED + class PartioSe{ public: PartioSe(ParticlesDataMutable* parts,const char* expr); @@ -565,4 +595,5 @@ class PartioSe{ bool runRange(int istart,int iend); void setTime(float val); }; + #endif diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 6956a215..85f44585 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -31,10 +31,20 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. -link_directories(${CMAKE_BINARY_DIR}/src/lib) +set(CMAKE_INSTALL_PARTIO_TESTDIR ${CMAKE_INSTALL_DATAROOTDIR}/partio/test) +find_library(GTEST_LIB gtest HINTS ${GTEST_LINK_PATH}) -foreach(item testiterator testio testcache testclonecopy testcluster teststr makecircle makeline testkdtree) - ADD_EXECUTABLE(${item} "${item}.cpp") - target_link_libraries(${item} ${PARTIO_LIBRARIES}) - install(TARGETS ${item} DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/partio/test) -endforeach(item) +if ("${GTEST_LIB}" STREQUAL "GTEST_LIB-NOTFOUND") + message("${GTEST_LIB} libgtest not found, tests disabled") +else() + link_directories(${CMAKE_BINARY_DIR}/src/lib) + + foreach(item testiterator testio testcache testclonecopy testcluster teststr makecircle makeline testkdtree) + add_executable(${item} "${item}.cpp") + target_link_libraries(${item} ${PARTIO_LIBRARIES} ${GTEST_LIB}) + install(TARGETS ${item} DESTINATION ${CMAKE_INSTALL_PARTIO_TESTDIR}) + add_test(NAME ${item} COMMAND ${item}) + endforeach(item) + + install(PROGRAMS testpartjson.py DESTINATION ${CMAKE_INSTALL_PARTIO_TESTDIR} RENAME testpartjson) +endif() diff --git a/src/tests/testmerge.cpp b/src/tests/testmerge.cpp new file mode 100644 index 00000000..1ea5a56b --- /dev/null +++ b/src/tests/testmerge.cpp @@ -0,0 +1,124 @@ +/* +PARTIO SOFTWARE +Copyright 2010 Disney Enterprises, Inc. All rights reserved + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in +the documentation and/or other materials provided with the +distribution. + +* The names "Disney", "Walt Disney Pictures", "Walt Disney Animation +Studios" or the names of its contributors may NOT be used to +endorse or promote products derived from this software without +specific prior written permission from Walt Disney Pictures. + +Disclaimer: THIS SOFTWARE IS PROVIDED BY WALT DISNEY PICTURES AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, NONINFRINGEMENT AND TITLE ARE DISCLAIMED. +IN NO EVENT SHALL WALT DISNEY PICTURES, THE COPYRIGHT HOLDER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND BASED ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. +*/ + + +#include +#include +#include + +using namespace Partio; + +// TODD - Create test base class to read in base and delta files +// and validate them, as well as release() on test completion. +// Add test to merge two particle sets without an identifier +// (should be fully additive) + +class PartioTest : public ::testing::Test { +public: + PartioTest() {} + virtual ~PartioTest() {} + + void SetUp() { + // Read in base and delta files + std::string datadir = std::string(getenv("PARTIO")) + "/src/data/"; + std::string base_geo = datadir + "base.bgeo"; + std::string delta_geo = datadir + "delta.bgeo"; + base = Partio::read(base_geo.c_str()); + delta = Partio::read(delta_geo.c_str()); + + // Validate each before merging (don't allow test data to change) + ASSERT_EQ(5, base->numParticles()); + ASSERT_EQ(3, delta->numParticles()); + ASSERT_TRUE(base->attributeInfo("life", base_life_attr)); + ASSERT_TRUE(delta->attributeInfo("life", delta_life_attr)); + ASSERT_TRUE(base->attributeInfo("position", base_pos_attr)); + ASSERT_TRUE(delta->attributeInfo("position", delta_pos_attr)); + for (int i=0; i<5; i++) { + ASSERT_EQ(base_values_life[i], base->data(base_life_attr, i)[0]); + ASSERT_EQ(base_values_posx[i], base->data(base_pos_attr, i)[0]); + } + for (int i=0; i<3; i++) { + ASSERT_EQ(delta_values_life[i], delta->data(delta_life_attr, i)[0]); + ASSERT_EQ(delta_values_posx[i], delta->data(delta_pos_attr, i)[0]); + } + } + + void TearDown() { + base->release(); + delta->release(); + } + + Partio::ParticlesDataMutable* base; + Partio::ParticlesDataMutable* delta; + ParticleAttribute base_life_attr, base_pos_attr; + ParticleAttribute delta_life_attr, delta_pos_attr; + std::vector base_values_life{ -1.2, -0.2, 0.8, 1.8, 2.8 }; + std::vector base_values_posx{ 0.0, 0.1, 0.2, 0.3, 0.4 }; + std::vector delta_values_life{ 1.0, 3.0, 5.0 }; + std::vector delta_values_posx{ 0.1, 0.3, 0.5 }; +}; + +TEST_F(PartioTest, merge) +{ + std::cout << "\nBase particle set:\n"; + Partio::print(base); + std::cout << "\nDelta particle set:\n"; + Partio::print(delta); + + // Do the merge + Partio::merge(*base, *delta, "id"); + std::cout << "\nMerged particle set:\n"; + Partio::print(base); + ASSERT_EQ(6, base->numParticles()); + std::vector expected_life({-1.2, 1.0, 0.8, 3.0, 2.8, 5.0}); + std::vector expected_posx({0.0, 0.1, 0.2, 0.3, 0.4, 0.5}); + for (int i=0; i<6; i++) { + ASSERT_EQ(expected_life[i], base->data(base_life_attr, i)[0]); + ASSERT_EQ(expected_posx[i], base->data(base_pos_attr, i)[0]); + } + + int numFixed = base->numFixedAttributes(); + ASSERT_EQ(3, numFixed); +} + +TEST_F(PartioTest, mergenoid) +{ +} + +int main(int argc, char* argv[]) +{ + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/tests/testpartjson.py b/src/tests/testpartjson.py new file mode 100644 index 00000000..de9eeb5d --- /dev/null +++ b/src/tests/testpartjson.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python + +""" +Test for partjson - back and forth +""" + +__copyright__ = """ +CONFIDENTIAL INFORMATION: This software is the confidential and +proprietary information of Walt Disney Animation Studios ("WDAS"). +This software may not be used, disclosed, reproduced or distributed +for any purpose without prior written authorization and license +from WDAS. Reproduction of any section of this software must +include this legend and all copyright notices. +Copyright Disney Enterprises, Inc. All rights reserved. +""" + +import os, unittest +import partjson, partio + + +class test(unittest.TestCase): + """ Test json conversions """ + + def testPartJson(self): + """ Test round-tripping """ + + filename = os.path.join(os.getenv('PARTIO'), 'src/data/json.bgeo') + particleSet = partio.read(filename) + json1 = partjson.toJson(particleSet) + particleSet2 = partjson.fromJson(json1) + json2 = partjson.toJson(particleSet2) + self.assertEquals(json1, json2) + +if __name__ == '__main__': + unittest.main() diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 1ab481db..07e5d299 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -31,10 +31,7 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. -include_directories( - ${CMAKE_SOURCE_DIR}/src/lib -) - +include_directories(${CMAKE_SOURCE_DIR}/src/lib) link_directories(${CMAKE_BINARY_DIR}/src/lib) IF(GLUT_FOUND AND OPENGL_FOUND) @@ -54,3 +51,13 @@ ADD_EXECUTABLE(partattr partattr.cpp) target_link_libraries(partattr ${PARTIO_LIBRARIES}) install(TARGETS partattr partconvert partinfo DESTINATION ${CMAKE_INSTALL_BINDIR}) + +install(PROGRAMS partedit.py DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME partedit) + +install(PROGRAMS partjson.py DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME partjson) + +EXECUTE_PROCESS(COMMAND python -c "import sys;print('%s.%s'%sys.version_info[0:2])" + OUTPUT_VARIABLE PYTHON_VERSION OUTPUT_STRIP_TRAILING_WHITESPACE) +SET(PYTHON_DEST "${CMAKE_INSTALL_LIBDIR}/python${PYTHON_VERSION}/site-packages" ) +INSTALL(FILES partedit.py DESTINATION ${PYTHON_DEST}) +INSTALL(FILES partjson.py DESTINATION ${PYTHON_DEST}) diff --git a/src/tools/partedit.py b/src/tools/partedit.py new file mode 100644 index 00000000..9a6359bb --- /dev/null +++ b/src/tools/partedit.py @@ -0,0 +1,1089 @@ +#!/usr/bin/env python +# pylint:disable=C0302 + +""" +An interactive spreadsheet for viewing, editing, and saving +partio (bgeo) files. + +Usage: + % partedit [FLAGS] [bgeoFile] + +Supported FLAGS: + -h/--help: Print this help message + +""" + +# TODO: +# Support for fixed attribute delete and rename +# Support for indexed strings +# Tighten up particle table whitespace usage (smaller font? popup matrix?) +# Performance - delay widget construction + +# NEXT UP: +# - delete fixed attribute +# - rename fixed attribute + +__copyright__ = """ +CONFIDENTIAL INFORMATION: This software is the confidential and +proprietary information of Walt Disney Animation Studios ("WDAS"). +This software may not be used, disclosed, reproduced or distributed +for any purpose without prior written authorization and license +from WDAS. Reproduction of any section of this software must +include this legend and all copyright notices. +Copyright Disney Enterprises, Inc. All rights reserved. +""" + +import os, sys, math +import partio + +# pylint:disable=E0611,E0401 +from Qt.QtGui import QKeySequence, QIcon, QIntValidator, QDoubleValidator +from Qt.QtWidgets import QShortcut, QApplication, QMainWindow, \ + QPushButton, QTableWidget, QLabel, QWidget, QVBoxLayout, QHeaderView,\ + QHBoxLayout, QLineEdit, QFileDialog, QFrame, QDialog, QFormLayout, \ + QComboBox, QCheckBox, QTableWidgetItem, QSplitter +from Qt.QtCore import Qt, QSize, QObject#, pyqtSignal +from PyQt5.QtCore import pyqtSignal + +#------------------------------------------------------------------------------_ +_attrTypes = [partio.NONE, partio.VECTOR, partio.FLOAT, partio.INT, partio.INDEXEDSTR] + +#------------------------------------------------------------------------------ +def copy(srcData): + """ Creates a copy of the given partio data set """ + + dstData = partio.create() + srcAttrs = [] + dstAttrs = [] + for anum in range(srcData.numAttributes()): + attr = srcData.attributeInfo(anum) + srcAttrs.append(attr) + dstAttrs.append(dstData.addAttribute(attr.name, attr.type, attr.count)) + dstData.addParticles(srcData.numParticles()) + for pnum in range(srcData.numParticles()): + for anum, srcAttr in enumerate(srcAttrs): + dstData.set(dstAttrs[anum], pnum, srcData.get(srcAttr, pnum)) + return dstData + +#-------------------------------------------------------------------------- +def getAttrs(numAttributesFunc, attributeInfoFunc, sort=False): + """ Return list of tuples of (attributeNum, attribute) """ + attrs = [] + numAttr = numAttributesFunc() + + nameToIndex = {attributeInfoFunc(anum).name:anum for anum in range(numAttr)} + names = nameToIndex.keys() + if sort: + names.sort() + + id_offset = 0 + for name in names: + anum = nameToIndex[name] + attr = attributeInfoFunc(anum) + if sort and attr.name == 'id': + attrs.insert(0, (anum, attr)) + id_offset += 1 + elif sort and 'id' in attr.name: + attrs.insert(id_offset, (anum, attr)) + id_offset += 1 + else: + attrs.append((anum, attr)) + + return attrs + +#-------------------------------------------------------------------------- +def copyParticles(src, dst): + """ Copy particles from src to dst. """ + + # Identify the attributes that are in both src and dst + srcAttrs = [src.attributeInfo(i) for i in range(src.numAttributes())] + dstAttrs = [dst.attributeInfo(i) for i in range(dst.numAttributes())] + srcAttrs = {attr.name:attr for attr in srcAttrs} + dstAttrs = {attr.name:attr for attr in dstAttrs} + attrs = {'src':[], 'dst':[]} + for name, srcAttr in srcAttrs.iteritems(): + if name in dstAttrs: + attrs['src'].append(srcAttr) + attrs['dst'].append(dstAttrs[name]) + + numParticles = src.numParticles() + dst.addParticles(numParticles) + for pnum in range(numParticles): + for anum in range(len(attrs)): + dst.set(attrs['dst'][anum], pnum, src.get(attrs['src'][anum], pnum)) + +#------------------------------------------------------------------------------ +#------------------------------------------------------------------------------ +class ParticleData(QObject): + """ UI Controller class for partio data """ + + particleAdded = pyqtSignal(int) + attributeAdded = pyqtSignal(str) + fixedAttributeAdded = pyqtSignal(str) + dataReset = pyqtSignal() + dirtied = pyqtSignal(bool) + + def __init__(self): + QObject.__init__(self) + self.setData(partio.create()) + self.filename = None + self.dirty = False + + #-------------------------------------------------------------------------- + def setDirty(self, dirty): + """ Stores the dirty state of the data """ + + if dirty != self.dirty: + self.dirty = dirty + self.dirtied.emit(dirty) + + #-------------------------------------------------------------------------- + def setData(self, data): + """ Sets the data, linking class methods to partio methods and + notifying all observers that the data set has changed. + """ + self.data = data + self.originalData = copy(data) + self.facade() + self.dataReset.emit() + + #-------------------------------------------------------------------------- + def facade(self): + """ Facades methods through to data """ + + self.get = self.data.get + self.getFixed = self.data.getFixed + self.numAttributes = self.data.numAttributes + self.numFixedAttributes = self.data.numFixedAttributes + self.numParticles = self.data.numParticles + self.attributeInfo = self.data.attributeInfo + self.fixedAttributeInfo = self.data.fixedAttributeInfo + self.indexedStrs = self.data.indexedStrs + + #-------------------------------------------------------------------------- + def set(self, *args): + """ Sets a value on the partio data, marking dirty. """ + + self.setDirty(True) + self.data.set(*args) + + #-------------------------------------------------------------------------- + def setFixed(self, *args): + """ Sets a fixed attribute value on the partio data, marking dirty. """ + + self.setDirty(True) + self.data.setFixed(*args) + + #-------------------------------------------------------------------------- + def read(self, filename): + """ Opens a file from disk and populates the UI """ + + if not os.path.exists(filename): + sys.stderr.write('Invalid filename: {}\n'.format(filename)) + return + + data = partio.read(filename) + if not data: + sys.stderr.write('Invalid particle file: {}\n'.format(filename)) + data = partio.create() + + self.filename = filename + self.setData(data) + self.setDirty(False) + + #-------------------------------------------------------------------------- + def write(self, filename, delta): + """ Write data to file. If delta is False, saves a full copy + of the data, rebaselining. If delta is True, saves only + the particles (todo: and attributes) that have changed, + but maintains the original baseline + """ + + if not self.data: + return + + # If we're saving a delta, create a new particle set with just + # the differences from the original. + if delta: + data = self.createDelta() + else: + data = self.data + + partio.write(filename, data) + + # If we saved a full copy, rebaseline + if not delta: + self.filename = filename + self.originalData = copy(data) + self.setDirty(False) + + #-------------------------------------------------------------------------- + def createDelta(self): + """ Creates a delta particle set between the current and original + data set. This is the brute-force method, simply comparing the + current data set against the original, but it's easier than + tracking individual changes. + """ + + def hashParticles(data): + """ Given a partio data set, create a dictionary of hashes + to indices + """ + items = {} + numAttrs = data.numAttributes() + for pnum in range(data.numParticles()): + item = [] + for anum in range(numAttrs): + attr = data.attributeInfo(anum) + item.append(data.get(attr, pnum)) + items[hash(str(item))] = pnum + return items + + # TODO: Handle new attributes as deltas + # For now, any new attributes will write all of the particles + + # Hash up the new data into an index table + newParticles = hashParticles(self.data) + oldParticles = hashParticles(self.originalData) + + # If nothing changed, easy out + data = partio.create() + if newParticles == oldParticles: + return data + + # Identify which particles changed + oldHashes = set(oldParticles.keys()) + newHashes = set(newParticles.keys()) + modifiedHashes = newHashes - oldHashes + + # Create the new particle set + numAttrs = self.data.numAttributes() + newAttrs = [] + oldAttrs = [] + for anum in range(numAttrs): + attr = self.data.attributeInfo(anum) + oldAttrs.append(attr) + newAttr = data.addAttribute(attr.name, attr.type, attr.count) + newAttrs.append(newAttr) + + data.addParticles(len(modifiedHashes)) + for newIndex, modifiedHash in enumerate(modifiedHashes): + oldIndex = newParticles[modifiedHash] + for anum, oldAttr in enumerate(oldAttrs): + value = self.data.get(oldAttr, oldIndex) + data.set(newAttrs[anum], newIndex, value) + + return data + + #-------------------------------------------------------------------------- + def addParticle(self): + """ Adds a new particle, emitting its new index. + The new particle's values are copied from the last particle. + If the particle set has the 'id' attribute, the new + particle id is set to max(ids)+1. + """ + + if not self.data: + return + + numParticles = self.numParticles() + index = self.data.addParticle() + numAttr = self.numAttributes() + + idAttr = self.attributeInfo('id') + if idAttr: + newId = max(self.data.get(idAttr, pnum)[0] for pnum in range(numParticles)) + 1 + + for anum in range(numAttr): + attr = self.attributeInfo(anum) + if idAttr and attr.name == 'id': + value = (newId,) + else: + value = self.get(attr, numParticles-1) + self.set(attr, numParticles, value) + + self.particleAdded.emit(index) + self.setDirty(True) + + #-------------------------------------------------------------------------- + def removeParticles(self, indices): + """ Removes the particles at the given indices. + partio doesn't support removing data, so we have + to construct all new data sans the given particle + """ + + for anum in range(self.data.numAttributes()): + attr = self.data.attributeInfo(anum) + attributes = [self.data.attributeInfo(anum) for anum in range(self.data.numAttributes())] + + want = [pnum for pnum in range(self.data.numParticles()) if pnum not in indices ] + newData = partio.clone(self.data, False) + for attr in attributes: + newData.addAttribute(attr.name, attr.type, attr.count) + newData.addParticles(len(want)) + for i, idx in enumerate(want): + for attr in attributes: + newData.set(attr, i, self.data.get(attr, idx)) + + self.setData(newData) + self.setDirty(True) + + #-------------------------------------------------------------------------- + def addAttribute(self, name, attrType, count, fixed, defaultValue): + """ Adds a new attribute for the particles, returning a + handle to the new attribute. + """ + + if not isinstance(defaultValue, tuple): + defaultValue = (defaultValue,) + + if fixed: + attr = self.data.addFixedAttribute(name, attrType, count) + self.data.setFixed(attr, defaultValue) + self.fixedAttributeAdded.emit(attr.name) + else: + attr = self.data.addAttribute(name, attrType, count) + for pnum in range(self.numParticles()): + self.data.set(attr, pnum, defaultValue) + self.attributeAdded.emit(attr.name) + + self.setDirty(True) + + #-------------------------------------------------------------------------- + def removeAttributes(self, names): + """ Removes the attributes with the given names. + partio doesn't support removing data, so we have + to construct all new data sans the given attribute(s). + """ + + newData = partio.create() + for anum in range(self.numAttributes()): + attr = self.attributeInfo(anum) + if attr.name not in names: + newData.addAttribute(attr.name, attr.type, attr.count) + + # Copy particle data with new attributes + copyParticles(src=self.data, dst=newData) + + # Copy fixed attributes + for anum in range(self.data.numFixedAttributes()): + oldAttr = self.data.fixedAttributeInfo(anum) + newAttr = newData.addFixedAttribute(oldAttr.name, oldAttr.type, oldAttr.count) + newData.setFixed(newAttr, self.data.getFixed(oldAttr)) + + self.setData(newData) + self.setDirty(True) + + + #-------------------------------------------------------------------------- + def removeFixedAttributes(self, names): + """ Removes the fixed attributes with the given names. + partio doesn't support removing data, so we have + to construct all new data sans the given attribute(s). + """ + + newData = partio.create() + + # Copy the regular (non-fixed) attributes and particles + for anum in range(self.data.numAttributes()): + attr = self.attributeInfo(anum) + newData.addAttribute(attr.name, attr.type, attr.count) + copyParticles(src=self.data, dst=newData) + + # Create new fixed attributes + for anum in range(self.data.numFixedAttributes()): + srcAttr = self.fixedAttributeInfo(anum) + if srcAttr.name not in names: + dstAttr = newData.addFixedAttribute(srcAttr.name, srcAttr.type, srcAttr.count) + newData.setFixed(dstAttr, self.data.getFixed(srcAttr)) + + self.setData(newData) + self.setDirty(True) + +#------------------------------------------------------------------------------ +class NumericalEdit(QLineEdit): # pylint:disable=R0903 + """ A LineEdit that auto installs a validator for numerical types """ + + def __init__(self, value, parent=None): + QLineEdit.__init__(self, str(value), parent) + self.setAlignment(Qt.AlignRight) + if isinstance(value, int): + self.setValidator(QIntValidator()) + elif isinstance(value, float): + self.setValidator(QDoubleValidator()) + +#------------------------------------------------------------------------------ +class AttrWidget(QFrame): # pylint:disable=R0903 + """ The primary widget for table entries representing a particle attribute """ + + widgetNumber = 0 + + def __init__(self, value, data, attr, particleNum, numColumns, parent=None): + QWidget.__init__(self, parent) + self.value = value + self.data = data + self.attr = attr + self.particleNum = particleNum + self.setFrameShape(QFrame.NoFrame) + + self.name = 'AttrWidget{}'.format(AttrWidget.widgetNumber) + self.setObjectName(self.name) + AttrWidget.widgetNumber += 1 + self.withBorderStyle = '#%s {border: 1px solid dodgerblue;}' % self.name + self.noBorderStyle = '#%s {border: 0px;}' % self.name + self.setStyleSheet(self.noBorderStyle) + + layout = QVBoxLayout() + layout.setContentsMargins(0,0,0,0) + self.setLayout(layout) + + idx = 0 + self.items = [] + self.textValues = [] + numRows = int(math.ceil(len(value) / float(numColumns))) + for _ in range(numRows): + row = QHBoxLayout() + layout.addLayout(row) + for _ in range(numColumns): + item = NumericalEdit(value[idx]) + self.textValues.append(str(value[idx])) + item.editingFinished.connect(self.applyEdit) + row.addWidget(item, Qt.AlignHCenter|Qt.AlignTop) + self.items.append(item) + idx += 1 + if idx == len(self.value): + break + + #-------------------------------------------------------------------------- + def applyEdit(self): + """ Callback when editing finished on a cell. Sets data value. """ + + newValue = [] + changed = False + for i, item in enumerate(self.items): + text = item.text() + if text != self.textValues[i]: + changed = True + if isinstance(self.value[0], int): + newValue.append(int(text)) + else: + newValue.append(float(text)) + item.clearFocus() + if changed: + self.value = tuple(newValue) + if self.particleNum >= 0: + self.data.set(self.attr, self.particleNum, self.value) + else: + self.data.setFixed(self.attr, self.value) + self.drawBorder(True) + + #-------------------------------------------------------------------------- + def drawBorder(self, border): + """ Sets or clears the border around the frame """ + + if border: + self.setStyleSheet(self.withBorderStyle) + else: + self.setStyleSheet(self.noBorderStyle) + +#------------------------------------------------------------------------------ +def getWidget(value, data, attr, particleNum=-1): + """ Returns the correct type of QWidget based off of the item type. + A particleNum<0 means a fixed attribute. + """ + + if isinstance(value, tuple): + size = len(value) + if size == 16: + result = AttrWidget(value, data, attr, particleNum, 4) + elif size == 9: + result = AttrWidget(value, data, attr, particleNum, 3) + else: + result = AttrWidget(value, data, attr, particleNum, size) + else: + result = QLabel(str(value)) + return result + + +#------------------------------------------------------------------------------ +#------------------------------------------------------------------------------ +class ParticleTableWidget(QTableWidget): # pylint:disable=R0903 + """ A QTableWidget interfacing with ParticleData""" + + def __init__(self, data, parent=None): + QTableWidget.__init__(self, parent) + self.data = data + + # Connect data signals to my slots + self.data.particleAdded.connect(self.particleAddedSlot) + self.data.attributeAdded.connect(self.attributeAddedSlot) + self.data.dataReset.connect(self.dataResetSlot) + self.data.dirtied.connect(self.dataDirtiedSlot) + + style = 'QTableWidget::item { border: 1px solid gray; }' + self.setStyleSheet(style) + self.ignoreSignals = False + self.populate() + + #-------------------------------------------------------------------------- + def populate(self): + """ Populate the table with the data """ + + self.clear() + + numAttr = self.data.numAttributes() + numParticles = self.data.numParticles() + + self.attrs = getAttrs(self.data.numAttributes, self.data.attributeInfo, True) + self.setColumnCount(numAttr) + self.setRowCount(numParticles) + self.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + for col, (_, attr) in enumerate(self.attrs): + item = QTableWidgetItem(attr.name) + tooltip = '

 Name: {}
 Type: {}
Count: {}

'.\ + format(attr.name, partio.TypeName(attr.type), attr.count) + item.setToolTip(tooltip) + self.setHorizontalHeaderItem(col, item) + self.horizontalHeader().setStretchLastSection(False) + self.setVerticalHeaderLabels([str(pnum) for pnum in range(numParticles)]) + self.setTabKeyNavigation(True) + self.horizontalHeader().setSectionsMovable(False) + + # Populate it with the particle data + self.widgets = [] + for pnum in range(numParticles): + self.populateParticle(pnum) + + self.horizontalHeader().resizeSections(QHeaderView.ResizeToContents) + self.verticalHeader().resizeSections(QHeaderView.ResizeToContents) + + #-------------------------------------------------------------------------- + def populateParticle(self, pnum, border=False): + """ Populates the table with a new particle - a full row """ + + for col, (_, attr) in enumerate(self.attrs): + self.populateAttribute(pnum, col, attr, border) + + #-------------------------------------------------------------------------- + def populateAttribute(self, pnum, col, attr, border=False): + """ Populates a single cell in the table """ + + value = self.data.get(attr, pnum) + widget = getWidget(value, self.data, attr, pnum) + if border: + widget.drawBorder(border) + self.setCellWidget(pnum, col, widget) + self.widgets.append(widget) + + #-------------------------------------------------------------------------- + def keyPressEvent(self, event): + """ Handles certain keys """ + + if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): + self.handleDeleteKey(event) + else: + QTableWidget.keyPressEvent(self, event) + + #-------------------------------------------------------------------------- + def handleDeleteKey(self, event): # pylint:disable=W0613 + """ Handles the delete or backspace key """ + + model = self.selectionModel() + rows = model.selectedRows() + columns = model.selectedColumns() + + if not rows and not columns: + return + + # Ignore signals as we rebuild + self.ignoreSignals = True + if rows: + particles = [row.row() for row in rows] + self.data.removeParticles(particles) + + if columns: + indices = [col.column() for col in columns] + attributes = [str(self.horizontalHeaderItem(index).text()) for index in indices] + self.data.removeAttributes(attributes) + + self.ignoreSignals = False + self.dataResetSlot() + + + #-------------------------------------------------------------------------- + def particleAddedSlot(self, index): # pylint:disable=W0613 + """ SLOT when a particle is added """ + + if self.ignoreSignals: + return + + numParticles = self.data.numParticles() + self.setRowCount(numParticles) + self.populateParticle(numParticles-1, True) + self.verticalHeader().resizeSections(QHeaderView.ResizeToContents) + + #-------------------------------------------------------------------------- + def attributeAddedSlot(self, name): # pylint:disable=W0613 + """ SLOT when attribute is added """ + + numAttrs = self.data.numAttributes() + anum = numAttrs - 1 + name = str(name) # partio doesn't like unicode + attr = self.data.attributeInfo(name) + self.attrs.append((anum, attr)) + self.setColumnCount(numAttrs) + self.setHorizontalHeaderItem(numAttrs-1, QTableWidgetItem(attr.name)) + for pnum in range(self.data.numParticles()): + self.populateAttribute(pnum, anum, attr, True) + self.verticalHeader().resizeSections(QHeaderView.ResizeToContents) + + #-------------------------------------------------------------------------- + def dataResetSlot(self): + """ SLOT when particle data is reconstructed """ + if not self.ignoreSignals: + self.populate() + + #-------------------------------------------------------------------------- + def dataDirtiedSlot(self, dirty): + """ SLOT when the particle data is dirtied or cleaned. + When cleaned, reset the style sheets on widgets for border. + """ + if not dirty: + for widget in self.widgets: + widget.drawBorder(False) + +#------------------------------------------------------------------------------ +class FixedAttributesWidget(QWidget): + """ A widget for viewing/editing fixed attributes (non-varying) """ + + def __init__(self, data, parent=None): + QWidget.__init__(self, parent) + self.data = data + + vbox = QVBoxLayout() + self.setLayout(vbox) + title = QLabel('Fixed Attributes') + vbox.addWidget(title) + + self.frame = QFrame() + vbox.addWidget(self.frame) + self.vbox = QVBoxLayout() + self.frame.setLayout(self.vbox) + self.frame.setFrameShape(QFrame.Panel) + self.frame.setFrameShadow(QFrame.Sunken) + + self.table = QTableWidget() + self.table.horizontalHeader().hide() + self.vbox.addWidget(self.table) + self.table.hide() + + self.noAttrLabel = QLabel('No fixed attributes') + self.vbox.addWidget(self.noAttrLabel) + + + self.widgets = [] + self.populate() + + self.data.fixedAttributeAdded.connect(self.fixedAttributeAddedSlot) + self.data.dataReset.connect(self.dataResetSlot) + self.data.dirtied.connect(self.dataDirtiedSlot) + + def dataDirtiedSlot(self, dirty): + """ SLOT when the particle data is dirtied or cleaned.""" + if not dirty: + for widget in self.widgets: + widget.drawBorder(False) + + def dataResetSlot(self): + """ SLOT when particle data is reconstructed """ + self.populate() + + def fixedAttributeAddedSlot(self, name): #pylint:disable=W0613 + """ SLOT when a fixed attribute is added to the particle set """ + self.populate() + + def populate(self): + """ Populates the table of fixed attributes """ + + self.widgets = [] + + # If no widgets, just drop that in + numAttrs = self.data.numFixedAttributes() + if not numAttrs: + self.table.hide() + self.noAttrLabel.show() + return + + self.table.show() + self.noAttrLabel.hide() + self.table.setColumnCount(1) + self.table.setRowCount(numAttrs) + self.attrs = getAttrs(self.data.numFixedAttributes, self.data.fixedAttributeInfo, True) + + for row, (_, attr) in enumerate(self.attrs): + item = QTableWidgetItem(attr.name) + tooltip = '

 Name: {}
 Type: {}
Count: {}

'.\ + format(attr.name, partio.TypeName(attr.type), attr.count) + item.setToolTip(tooltip) + self.table.setVerticalHeaderItem(row, item) + value = self.data.getFixed(attr) + widget = getWidget(value, self.data, attr) + self.table.setCellWidget(row, 0, widget) + self.widgets.append(widget) + self.table.horizontalHeader().setStretchLastSection(False) + self.table.setTabKeyNavigation(True) + self.table.horizontalHeader().setSectionsMovable(False) + + self.table.horizontalHeader().resizeSections(QHeaderView.ResizeToContents) + self.table.verticalHeader().resizeSections(QHeaderView.ResizeToContents) + + +class IndexedStringsWidget(QWidget): + """ Holds the list of indexed string attributes """ + def __init__(self, data, parent=None): + QWidget.__init__(self, parent) + self.data = data + + vbox = QVBoxLayout() + self.setLayout(vbox) + title = QLabel('Indexed Strings') + vbox.addWidget(title) + + self.frame = QFrame() + vbox.addWidget(self.frame) + self.vbox = QVBoxLayout() + self.frame.setLayout(self.vbox) + self.frame.setFrameShape(QFrame.Panel) + self.frame.setFrameShadow(QFrame.Sunken) + + self.table = QTableWidget() + self.table.horizontalHeader().hide() + self.vbox.addWidget(self.table) + self.table.hide() + + self.noStringsLabel = QLabel('No indexed strings') + self.vbox.addWidget(self.noStringsLabel) + + self.widgets = [] + self.populate() + + self.data.attributeAdded.connect(self.attributeAddedSlot) + self.data.dataReset.connect(self.dataResetSlot) + self.data.dirtied.connect(self.dataDirtiedSlot) + + def dataDirtiedSlot(self, dirty): + """ SLOT when the particle data is dirtied or cleaned.""" + if not dirty: + for widget in self.widgets: + widget.drawBorder(False) + + def dataResetSlot(self): + """ SLOT when particle data is reconstructed """ + self.populate() + + def attributeAddedSlot(self, name): #pylint:disable=W0613 + """ SLOT when an attribute is added to the particle set """ + attr = self.data.attributeInfo(name) + if attr.type == partio.INDEXEDSTR: + self.populate() + + def populate(self): + """ Populates the table of indexed strings """ + + self.widgets = [] + + # If no widgets, just drop that in + attrs = [] + for anum in range(self.data.numAttributes()): + attr = self.data.attributeInfo(anum) + if attr.type == partio.INDEXEDSTR: + attrs.append(attr) + + if not attrs: + self.table.hide() + self.noStringsLabel.show() + return + + self.table.show() + self.noStringsLabel.hide() + self.table.setColumnCount(1) + self.table.setRowCount(len(attrs)) + + for row, attr in enumerate(attrs): + item = QTableWidgetItem(attr.name) + self.table.setVerticalHeaderItem(row, item) + strings = self.data.indexedStrs(attr) + table = QTableWidget() + table.setColumnCount(1) + table.setRowCount(len(strings)) + table.horizontalHeader().hide() + table.setVerticalHeaderLabels([str(i) for i in range(len(strings))]) + for i, string in enumerate(strings): + widget = QLabel(string) + table.setCellWidget(i, 0, widget) + self.widgets.append(widget) + self.table.setCellWidget(row, 0, table) + + self.table.horizontalHeader().setStretchLastSection(False) + self.table.setTabKeyNavigation(True) + self.table.horizontalHeader().setSectionsMovable(False) + + self.table.horizontalHeader().resizeSections(QHeaderView.ResizeToContents) + self.table.verticalHeader().resizeSections(QHeaderView.ResizeToContents) + +#------------------------------------------------------------------------------ +#------------------------------------------------------------------------------ +class PartEdit(QMainWindow): + """ Main window / editor """ + + def __init__(self, parent=None): + QMainWindow.__init__(self, parent) + + self.data = ParticleData() + + toolbar = self.addToolBar("Test") + + openButton = QPushButton("") + openButton.setFlat(True) + openButton.setIconSize( QSize(32, 32) ) + openButton.setIcon(QIcon("/jobs2/soft/icons/dlight/open.png")) + openButton.setToolTip( "Open File" ) + toolbar.addWidget(openButton) + openButton.clicked.connect(self.openSlot) + QShortcut( QKeySequence(Qt.CTRL + Qt.Key_O), self, self.openSlot ) + + saveButton = QPushButton("") + saveButton.setFlat(True) + saveButton.setIconSize( QSize(32, 32) ) + saveButton.setIcon(QIcon("/jobs2/soft/icons/dlight/file_save.png")) + saveButton.setToolTip( "Save File" ) + toolbar.addWidget(saveButton) + saveButton.clicked.connect(self.saveSlot) + QShortcut( QKeySequence(Qt.CTRL + Qt.Key_S), self, self.saveSlot ) + + saveDeltaButton = QPushButton("") + saveDeltaButton.setFlat(True) + saveDeltaButton.setIconSize( QSize(32, 32) ) + saveDeltaButton.setIcon(QIcon("/jobs2/soft/icons/dlight/file_save_as.png")) + saveDeltaButton.setToolTip( "Save File As Delta" ) + toolbar.addWidget(saveDeltaButton) + saveDeltaButton.clicked.connect(self.saveDeltaSlot) + QShortcut( QKeySequence(Qt.CTRL + Qt.SHIFT + Qt.Key_S), self, self.saveDeltaSlot ) + + addParticleButton = QPushButton("Particle") + addParticleButton.setFlat(True) + addParticleButton.setIconSize( QSize(32, 32) ) + addParticleButton.setIcon(QIcon("/jobs2/soft/icons/shared/plus.png")) + addParticleButton.setToolTip( "Add Particle" ) + toolbar.addWidget(addParticleButton) + addParticleButton.clicked.connect(self.addParticleSlot) + + addAttributeButton = QPushButton("Attribute") + addAttributeButton.setFlat(True) + addAttributeButton.setIconSize( QSize(32, 32) ) + addAttributeButton.setIcon(QIcon("/jobs2/soft/icons/shared/plus.png")) + addAttributeButton.setToolTip( "Add Attribute" ) + toolbar.addWidget(addAttributeButton) + addAttributeButton.clicked.connect(self.addAttributeSlot) + + splitter = QSplitter(self) + self.setCentralWidget(splitter) + + particleTable = ParticleTableWidget(self.data, self) + splitter.addWidget(particleTable) + + right = QWidget(self) + splitter.addWidget(right) + vbox = QVBoxLayout(right) + right.setLayout(vbox) + + fixedAttrWidget = FixedAttributesWidget(self.data, self) + vbox.addWidget(fixedAttrWidget) + + indexedStrings = IndexedStringsWidget(self.data, self) + vbox.addWidget(indexedStrings) + + vbox.addStretch() + + # TODD: SCROLLABLE AREAS FOR EVERYTHING + + self.data.dirtied.connect(self.dataDirtiedSlot) + + + # Configure ctrl-w to close the window + QShortcut( QKeySequence(Qt.CTRL + Qt.Key_W), self, self.close ) + + + #-------------------------------------------------------------------------- + def openSlot(self): + """ Callback from Open button """ + + # TODO: Check for edits and prompt to save dirty + if self.data.filename: + dirname = os.path.dirname(self.data.filename) + else: + dirname = os.getcwd() + filename = QFileDialog.getOpenFileName(self, "Open particle file", dirname, "(*.bgeo *.geo *.bhclassic *.ptc *.pdb)") + if filename: + if isinstance(filename, tuple): + filename = filename[0] + self.open(str(filename)) + + #-------------------------------------------------------------------------- + def open(self, filename): + """ Opens a file from disk and populates the UI """ + + self.data.read(filename) + self.setWindowTitle(filename) + + #-------------------------------------------------------------------------- + def setData(self, particleSet): + """ Uses the given particle set as its data """ + + self.data.setData(particleSet) + + #-------------------------------------------------------------------------- + def saveSlot(self): + """ Callback from Save button """ + self.save(False) + + #-------------------------------------------------------------------------- + def saveDeltaSlot(self): + """ Callback from Save-Delta button """ + self.save(True) + + #-------------------------------------------------------------------------- + def save(self, delta): + """ Saves the file, either as full or delta """ + if self.data.filename: + filename = self.data.filename + else: + filename = os.getcwd() + filename = QFileDialog.getSaveFileName(self, "Save particle file", filename, + 'Particle Files (*.bgeo *.geo *.bhclassic *.ptc *.pdb );;All files(*)') + if isinstance(filename, tuple): + filename = filename[0] + filename = str(filename) + if not filename: + return + self.data.write(filename, delta) + + #-------------------------------------------------------------------------- + def addParticleSlot(self): + """ Adds a new particle (row) to the table """ + self.data.addParticle() + + #-------------------------------------------------------------------------- + def addAttributeSlot(self): + """ Adds a new attribute (column) to the table """ + + dialog = QDialog(self) + dialog.setModal(True) + dialog.setWindowTitle('Add Attribute') + + layout = QVBoxLayout() + dialog.setLayout(layout) + + form = QFormLayout() + nameBox = QLineEdit() + typeCombo = QComboBox() + for attrType in _attrTypes: + typeName = partio.TypeName(attrType) + typeCombo.addItem(typeName) + typeCombo.setCurrentIndex(partio.FLOAT) + countBox = QLineEdit() + countBox.setValidator(QIntValidator()) + countBox.setText('1') + fixedCheckbox = QCheckBox() + valueBox = QLineEdit() + valueBox.setText('0') + form.addRow('Name:', nameBox) + form.addRow('Type:', typeCombo) + form.addRow('Count:', countBox) + form.addRow('Fixed:', fixedCheckbox) + form.addRow('Default Value:', valueBox) + layout.addLayout(form) + + buttons = QHBoxLayout() + layout.addLayout(buttons) + + add = QPushButton('Add') + add.clicked.connect(dialog.accept) + buttons.addWidget(add) + + cancel = QPushButton('Cancel') + cancel.clicked.connect(dialog.reject) + buttons.addWidget(cancel) + + if not dialog.exec_(): + return + + name = str(nameBox.text()) + if not name: + print 'Please supply a name for the new attribute' # TODO: prompt + return + + attrType = typeCombo.currentIndex() + count = int(countBox.text()) + fixed = fixedCheckbox.isChecked() + values = list(str(valueBox.text()).strip().split()) + for i in range(count): + if i < len(values): + value = values[i] + else: + value = values[-1] + if attrType == partio.INT or attrType == partio.INDEXEDSTR: + values[i] = int(value) + elif attrType == partio.FLOAT or attrType == partio.VECTOR: + values[i] = float(value) # pylint:disable=R0204 + else: + values[i] = 0.0 # pylint:disable=R0204 + value = tuple(values) + + self.data.addAttribute(name, attrType, count, fixed, value) + + #-------------------------------------------------------------------------- + def dataDirtiedSlot(self, dirty): + """ Sets the window title with or without "*" for dirty state """ + + title = self.data.filename + if dirty: + title += '*' + self.setWindowTitle(title) + + +#---------------------------------------------------------------------------------------- +#---------------------------------------------------------------------------------------- +def main(): + """ Main """ + + # Process command-line arguments + filename = None + for arg in sys.argv[1:]: + if arg in ('-h', '--help'): + print __doc__ + sys.exit(0) + + filename = arg + + # Start up the QApplication + app = QApplication([]) + from minibar.gui import mbWidgetStyling + mbWidgetStyling.styleTheApplication() + window = PartEdit() + + # Open file if provided + if filename: + window.open(filename) + + window.show() + + # Configure ctrl-q to quit + QShortcut( QKeySequence(Qt.CTRL + Qt.Key_Q), window, window.close ) + + # Go + app.exec_() + + +if __name__ == '__main__': + main() diff --git a/src/tools/partjson.py b/src/tools/partjson.py new file mode 100644 index 00000000..700ecbb7 --- /dev/null +++ b/src/tools/partjson.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python + +""" +Converts partio files to and from json. + +Usage: partjson [FLAGS] + +Supported FLAGS: + -c/--compress: When converting to partio, compress the output + -v/--verbose : Turn on verbosity for partio + -h/--help : Print this help message +""" + +# TODO: Unicode compliance + +__copyright__ = """ +CONFIDENTIAL INFORMATION: This software is the confidential and +proprietary information of Walt Disney Animation Studios ("WDAS"). +This software may not be used, disclosed, reproduced or distributed +for any purpose without prior written authorization and license +from WDAS. Reproduction of any section of this software must +include this legend and all copyright notices. +Copyright Disney Enterprises, Inc. All rights reserved. +""" + +import os, sys, json +import partio + +def toJson(particleSet): + """ Converts a particle set to json """ + + data = {} + + # Put types in json just for readability + data['__types__'] = { i : partio.TypeName(i) for i in range(5) } + + # Convert fixed attributes + fixedAttributes = {} + fixedIndexedStrings = {} + for i in range(particleSet.numFixedAttributes()): + attr = particleSet.fixedAttributeInfo(i) + fixedAttributes[attr.name] = {'type': attr.type, + 'count': attr.count, + 'value': particleSet.getFixed(attr), + } + + # Convert indexed string attributse + if attr.type == partio.INDEXEDSTR: + fixedIndexedStrings[attr.name] = particleSet.fixedIndexedStrs(attr) + + if fixedAttributes: + data['fixedAttributes'] = fixedAttributes + if fixedIndexedStrings: + data['fixedIndexedStrings'] = fixedIndexedStrings + + # Convert particle attributes + attributes = {} + attrs = [] + indexedStrings = {} + for i in range(particleSet.numAttributes()): + attr = particleSet.attributeInfo(i) + attrs.append(attr) + attributes[attr.name] = {'type': attr.type, 'count': attr.count } + + # Convert indexed string attributse + if attr.type == partio.INDEXEDSTR: + indexedStrings[attr.name] = particleSet.indexedStrs(attr) + + if attributes: + data['attributes'] = attributes + if indexedStrings: + data['indexedStrings'] = indexedStrings + + # Convert particles to an indexed dictionary + particles = {} + for i in range(particleSet.numParticles()): + particle = {} + for attr in attrs: + particle[attr.name] = particleSet.get(attr, i) + # Add an index purely for readability & debugging (not consumed converting back) + particles[i] = particle + + if particles: + data['particles'] = particles + + return data + + +def fromJson(data): + """ Converts a json dictionary to a particle set """ + + particleSet = partio.create() + + # Convert fixed attributes + fixedAttributes = {} + if 'fixedAttributes' in data: + for attrName, attrInfo in data['fixedAttributes'].items(): + attrName = str(attrName) + attr = particleSet.addFixedAttribute(attrName, attrInfo['type'], attrInfo['count']) + fixedAttributes[attrName] = attr + if len(attrInfo['value']) == attrInfo['count']: + particleSet.setFixed(attr, attrInfo['value']) + else: + sys.stderr.write('Mismatched count for fixed attribute {}. Skipping.\n'.format(attrName)) + + # Convert attributes + attributes = {} + if 'attributes' in data: + for attrName, attrInfo in data['attributes'].items(): + attrName = str(attrName) + attr = particleSet.addAttribute(attrName, attrInfo['type'], attrInfo['count']) + attributes[attrName] = attr + + # Convert fixed indexed strings + if 'fixedIndexedStrings' in data: + for attrName, strings in data['fixedIndexedStrings'].items(): + if attrName not in fixedAttributes: + sys.stderr.write('Could not match fixed indexed string {} with any defined fixed attribute. Skipping.\n'.format(attrName)) + continue + for string in strings: + particleSet.registerFixedIndexedStr(fixedAttributes[attrName], string) + + # Convert indexed strings + if 'indexedStrings' in data: + for attrName, strings in data['indexedStrings'].items(): + if attrName not in attributes: + sys.stderr.write('Could not match indexed string {} with any defined attribute. Skipping.\n'.format(attrName)) + continue + for string in strings: + particleSet.registerIndexedStr(attributes[attrName], str(string)) + + # Convert particles + if 'particles' in data: + particleSet.addParticles(len(data['particles'])) + for pnum, particle in data['particles'].items(): + pnum = int(pnum) + for attrName, value in particle.items(): + try: + attr = attributes[attrName] + except IndexError: + sys.stderr.write('Could not match attribute "{}" for particle {} with any defined attributes. Skipping.\n'.format(attrName, pnum)) + continue + if len(value) != attr.count: + sys.stderr.write('Mismatched count for attribute "{}" ({}) and particle {} ({}). Skipping.\n'.format(attrName, attr.count, pnum, len(value))) + continue + + particleSet.set(attr, pnum, value) + + return particleSet + +def main(): + """ Main """ + + # Process command-line arguments + filenames = [] + verbose = False + compress = False + for arg in sys.argv[1:]: + if arg in ('-h', '--help'): + print __doc__ + return + + if arg in ('-v', '--verbose'): + verbose = True + continue + + if arg in ('-c', '--compress'): + compress = True + continue + + filenames.append(arg) + + if len(filenames) != 2: + print __doc__ + sys.stderr.write('Incorrect number of arguments.\n') + sys.exit(1) + + file1, file2 = filenames[0:2] + ext1 = os.path.splitext(file1)[1] + ext2 = os.path.splitext(file2)[1] + + partio_extensions = ('.bgeo', '.geo', '.bhclassic', '.ptc', '.pdb') + + # Validate files + if not os.path.exists(file1): + sys.stderr.write('Invalid input file: {}\n'.format(file1)) + sys.exit(1) + + # Convert from json to partio + if ext1 == '.json': + if ext2 not in partio_extensions: + sys.stderr.write('Unknown partio extension for: {}\n'.format(file2)) + sys.exit(1) + + with open(file1, 'r') as fp: + data = json.load(fp) + particleSet = fromJson(data) + partio.write(file2, particleSet, compress) + sys.exit(0) + + if ext1 not in partio_extensions: + sys.stderr.write('Unknown partio extension for: {}\n'.format(file1)) + sys.exit(1) + + # Convert from partio to json + if ext1 in partio_extensions: + particleSet = partio.read(file1, verbose) + data = toJson(particleSet) + with open(file2, 'w') as fp: + json.dump(data, fp, indent=2, sort_keys=True) + sys.exit(0) + + print __doc__ + sys.stderr.write('Unknown file extension(s)') + sys.exit(1) + + +if __name__ == '__main__': + main()