├── examples ├── walk.blend ├── testImage.png ├── testImage.blend ├── transformTest.blend ├── transformTest.html ├── testImage.html └── walk.html ├── README.markdown └── io_export_css_transform.py /examples/walk.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesu/csstransformexport/HEAD/examples/walk.blend -------------------------------------------------------------------------------- /examples/testImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesu/csstransformexport/HEAD/examples/testImage.png -------------------------------------------------------------------------------- /examples/testImage.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesu/csstransformexport/HEAD/examples/testImage.blend -------------------------------------------------------------------------------- /examples/transformTest.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesu/csstransformexport/HEAD/examples/transformTest.blend -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # csstransformexport 2 | 3 | ## What is it? 4 | 5 | It's an exporter for [Blender 3D][1], which exports any Blender scene to a HTML document which uses [CSS Transforms][2] and [Animations][3] to construct and animate the scene. 6 | 7 | Scenes can either be exported in 2D or 3D. Currently the only objects supported are Empties and Planes. 8 | 9 | ## Why? 10 | 11 | Using a text editor is far from the most intuitive method for making a complex animated scene using CSS Transforms and Animations. Simply put, this is intended to cut out the tedious and unfriendly editing process to let you get on with the important part: the actual content. 12 | 13 | ## How do i construct a scene for export? 14 | 15 | Refer to the example blender files. Generally speaking you need to use Planes or Empties, laying out everything on the XY plane (top view). Each blender unit equals 1 pixel, which can be modified by altering the "Scale" factor. 16 | 17 | ## Which versions of blender are supported? 18 | 19 | Currently Blender 3.6.3 is supported. 20 | 21 | [1]: http://www.blender.org/ 22 | [2]: http://webkit.org/blog/130/css-transforms/ 23 | [3]: http://webkit.org/blog/138/css-animation/ 24 | -------------------------------------------------------------------------------- /examples/transformTest.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | transformTest 5 | 157 | 158 | 159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 | 175 | -------------------------------------------------------------------------------- /examples/testImage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | testImage 5 | 179 | 180 | 181 |
182 |
183 | 184 | -------------------------------------------------------------------------------- /io_export_css_transform.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (C) 2009-2012 James S Urquhart (contact@jamesu.net) 3 | 4 | This program is free software; you can redistribute it and/or modify it 5 | under the terms of the GNU General Public License as published by the 6 | Free Software Foundation; either version 2 of the License, 7 | or (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | See the GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program; if not, write to the 16 | Free Software Foundation, Inc., 59 Temple Place, 17 | Suite 330, Boston, MA 02111-1307 USA 18 | """ 19 | 20 | bl_info = { 21 | "name": "CSS Transform Export (.html)", 22 | "description": "Magically Exports to HTML&CSS Transform", 23 | "author": "James Urquhart", 24 | "version": (2, 0), 25 | "blender": (3, 6, 3), 26 | "location": "File > Export > CSS Transform (.html)", 27 | "warning": "", # used for warning icon and text in addons panel 28 | "wiki_url": "https://github.com/jamesu/csstransformexport", 29 | "tracker_url": "https://github.com/jamesu/csstransformexport", 30 | "category": "Import-Export"} 31 | 32 | import os 33 | import time 34 | import bpy 35 | import mathutils 36 | import random 37 | import operator 38 | import math 39 | import string 40 | 41 | from bpy.props import * 42 | 43 | # NOTE: Keyframes only interpolate between individual keys, i.e. values don't 44 | # interpolate across the entire animation. 45 | 46 | # BEGIN TEMPLATES 47 | 48 | WEBKIT_TPL = """ 49 | 50 | 51 | %(title)s 52 | 53 | 54 | 55 | 56 | 57 |
%(scene)s
58 | 59 | 60 | """ 61 | 62 | TRACKS_TPL = """ 63 | /* Animation keyframes */ 64 | %(content)s 65 | """ 66 | 67 | # END TEMPLATES 68 | 69 | def initSceneProperties(scn): 70 | bpy.types.Scene.cssexportanimtrackonly = BoolProperty( 71 | name="Only Export Animation Track", 72 | description="Only export CSS tracks", 73 | default=False) 74 | 75 | bpy.types.Scene.cssexportanimloop = BoolProperty( 76 | name="Loop Animation", 77 | description="Loop Animation", 78 | default=True) 79 | 80 | bpy.types.Scene.cssexportbakeanim = BoolProperty( 81 | name="Bake Animation", 82 | description="Sample animation each frame (interpolation will be forced to linear)", 83 | default=True) 84 | 85 | bpy.types.Scene.cssexport3d = BoolProperty( 86 | name="Export 3D", 87 | description="Incorporates Z axis and camera perspective", 88 | default=False) 89 | 90 | bpy.types.Scene.cssexportswitchaxis = BoolProperty( 91 | name="Switch Axis", 92 | description="Switch Z and Y axes (useful if incoporating simulated physics)", 93 | default=False) 94 | 95 | bpy.types.Scene.cssexportcollapsetransforms = BoolProperty( 96 | name="Collapse Transforms", 97 | description="Use world space transforms instead of relying on parent-child transforms. Buggy with anims.", 98 | default=False) 99 | 100 | bpy.types.Scene.cssexportanimfps = IntProperty( 101 | name="Override FPS", 102 | description="Override FPS", 103 | default=0) 104 | 105 | bpy.types.Scene.cssexportglobalscale = FloatProperty( 106 | name="Global Scale", 107 | description="Global Scale", 108 | default=1.0) 109 | 110 | initSceneProperties(bpy.context.scene) 111 | 112 | bpy.context.scene.cssexportcollapsetransforms = False 113 | bpy.context.scene.cssexport3d = False 114 | bpy.context.scene.cssexportswitchaxis = False 115 | 116 | # Lookups 117 | 118 | InterpolationLookup = { 119 | 'CONSTANT' : 'linear', 120 | 'LINEAR': 'linear', 121 | 'BEZIER': 'bezier' 122 | } 123 | 124 | # Util 125 | 126 | import os.path 127 | 128 | 129 | # Gets base path with trailing / 130 | def basepath(filepath): 131 | if "\\" in filepath: sep = "\\" 132 | else: sep = "/" 133 | words = filepath.split(sep) 134 | # join drops last word (file name) 135 | return sep.join(words[:-1]) 136 | 137 | class Bitfield: 138 | INT_WIDTH=32 139 | 140 | def __init__(self, size): 141 | self.size = int(size) 142 | self.field = [0] * int(math.ceil(float(self.size) / Bitfield.INT_WIDTH)) 143 | 144 | def __setitem__(self, position, value): 145 | if value: 146 | self.field[int(position) // Bitfield.INT_WIDTH] |= 1 << (int(position) % Bitfield.INT_WIDTH) 147 | elif self.field[int(position) // Bitfield.INT_WIDTH] & 1 << (int(position) % Bitfield.INT_WIDTH) > 0: 148 | self.field[int(position) // Bitfield.INT_WIDTH] ^= 1 << (int(position) % Bitfield.INT_WIDTH) 149 | 150 | def __getitem__(self, position): 151 | try: 152 | if self.field[int(position) // Bitfield.INT_WIDTH] & 1 << (int(position) % Bitfield.INT_WIDTH) > 0: 153 | return 1 154 | else: 155 | return 0 156 | except: 157 | return 0 158 | 159 | def dump(self): 160 | out = [] 161 | for item in self.field: 162 | st = map(lambda y:str((item>>y)&1), range(Bitfield.INT_WIDTH-1, -1, -1)) 163 | st.reverse() 164 | out.append("".join(st)) 165 | return (''.join(out)) 166 | 167 | # e.g. [0,0,1,1,0,0].setFrom([1,1,1,1,1,1], -6) == [1,1,1,1,1,1,0,0,1,1,0,0] 168 | def setFrom(self, other, offset): 169 | pos = other.size + offset 170 | new_size = self.size 171 | start_pos = 0 172 | if pos > new_size: 173 | # Expand 174 | new_size = pos 175 | if offset < 0: 176 | new_size += -offset 177 | start_pos = -offset 178 | 179 | new_field = Bitfield(new_size) 180 | # Copy existing 181 | for i in range(start_pos, start_pos+self.size): 182 | new_field[i] = self[i-start_pos] 183 | # Copy new 184 | for i in range(offset, offset+other.size): 185 | if not new_field[i]: 186 | new_field[i] = other[i-offset] 187 | return new_field 188 | 189 | # Helper class for CSS transforms 190 | class SimpleTransform: 191 | MATTERS_LOCX=1<<0 192 | MATTERS_LOCY=1<<1 193 | MATTERS_LOCZ=1<<2 194 | 195 | MATTERS_LOC2D = MATTERS_LOCX | MATTERS_LOCY 196 | MATTERS_LOC3D = MATTERS_LOCX | MATTERS_LOCY | MATTERS_LOCZ 197 | 198 | MATTERS_ROTX=1<<3 199 | MATTERS_ROTY=1<<4 200 | MATTERS_ROTZ=1<<5 201 | 202 | MATTERS_ROT3D = MATTERS_ROTX | MATTERS_ROTY | MATTERS_ROTZ 203 | 204 | MATTERS_SCLX=1<<6 205 | MATTERS_SCLY=1<<7 206 | MATTERS_SCLZ=1<<8 207 | 208 | MATTERS_SCL2D = MATTERS_SCLX | MATTERS_SCLY 209 | MATTERS_SCL3D = MATTERS_SCLX | MATTERS_SCLY | MATTERS_SCLZ 210 | 211 | MATTERS_VIS=1<<9 212 | 213 | # Global scaling 214 | GLOBAL_SCALE = 10.0 215 | 216 | def __init__(self): 217 | self.matters = 0 218 | self.loc = [0,0,0] 219 | self.rot = [0,0,0] 220 | self.scl = [0,0,0] 221 | self.vis = True 222 | 223 | self.is3D = False 224 | 225 | def setLocation(self, x, y, z): 226 | if x != None and x != self.loc[0]: 227 | self.matters |= SimpleTransform.MATTERS_LOCX 228 | self.loc[0] = x 229 | if y != None and y != self.loc[1]: 230 | self.matters |= SimpleTransform.MATTERS_LOCY 231 | self.loc[1] = y 232 | if z != None and z != self.loc[2]: 233 | self.matters |= SimpleTransform.MATTERS_LOCZ 234 | self.loc[2] = z 235 | 236 | def setRotation(self, x, y, z): 237 | if x != None and x != self.rot[0]: 238 | self.matters |= SimpleTransform.MATTERS_ROTX 239 | self.rot[0] = x 240 | if y != None and y != self.rot[1]: 241 | self.matters |= SimpleTransform.MATTERS_ROTY 242 | self.rot[1] = y 243 | if z != None and z != self.rot[2]: 244 | self.matters |= SimpleTransform.MATTERS_ROTZ 245 | self.rot[2] = z 246 | 247 | def setScale(self, x, y, z): 248 | if x != None and x != self.scl[0]: 249 | self.matters |= SimpleTransform.MATTERS_SCLX 250 | self.scl[0] = x 251 | if y != None and y != self.scl[1]: 252 | self.matters |= SimpleTransform.MATTERS_SCLY 253 | self.scl[1] = y 254 | if z != None and z != self.scl[2]: 255 | self.matters |= SimpleTransform.MATTERS_SCLZ 256 | self.scl[2] = z 257 | 258 | def setVis(self, vis): 259 | if self.vis != vis: 260 | self.matters |= SimpleTransform.MATTERS_VIS 261 | self.vis = vis 262 | 263 | def transformValue(self, threedee=False): 264 | string = "" 265 | list = [] 266 | 267 | # Location 268 | if threedee and self.matters & SimpleTransform.MATTERS_LOC3D == SimpleTransform.MATTERS_LOC3D: 269 | list.append("translate3d(%fpx, %fpx, %fpx)" % (self.loc[0], self.loc[1], self.loc[2])) 270 | elif self.matters & SimpleTransform.MATTERS_LOC2D == SimpleTransform.MATTERS_LOC2D: 271 | list.append("translate(%fpx, %fpx)" % (self.loc[0], self.loc[1])) 272 | else: 273 | if self.matters & SimpleTransform.MATTERS_LOCX: 274 | list.append("translateX(%fpx)" % self.loc[0]) 275 | if self.matters & SimpleTransform.MATTERS_LOCY: 276 | list.append("translateY(%fpx)" % self.loc[1]) 277 | if threedee and self.matters & SimpleTransform.MATTERS_LOCZ: 278 | list.append("translateZ(%fpx)" % self.loc[2]) 279 | 280 | # Rotation 281 | # TODO: rotate3d() 282 | if threedee: 283 | if self.matters & SimpleTransform.MATTERS_ROTX: 284 | list.append("rotateX(%frad)" % -self.rot[0]) 285 | if self.matters & SimpleTransform.MATTERS_ROTY: 286 | list.append("rotateY(%frad)" % -self.rot[1]) 287 | if self.matters & SimpleTransform.MATTERS_ROTZ: 288 | list.append("rotateZ(%frad)" % -self.rot[2]) 289 | else: 290 | if self.matters & SimpleTransform.MATTERS_ROTZ: 291 | list.append("rotate(%frad)" % -self.rot[2]) 292 | 293 | # Scale 294 | if threedee and self.matters & SimpleTransform.MATTERS_SCL3D == SimpleTransform.MATTERS_SCL3D: 295 | list.append("scale3d(%f, %f, %f)" % (self.scl[0], self.scl[1], self.scl[2])) 296 | elif self.matters & SimpleTransform.MATTERS_SCL2D == SimpleTransform.MATTERS_SCL2D: 297 | list.append("scale(%f, %f)" % (self.scl[0], self.scl[1])) 298 | else: 299 | if self.matters & SimpleTransform.MATTERS_SCLX: 300 | list.append("scaleX(%f)" % self.scl[0]) 301 | if self.matters & SimpleTransform.MATTERS_SCLY: 302 | list.append("scaleY(%f)" % self.scl[1]) 303 | if threedee and self.matters & SimpleTransform.MATTERS_SCLZ: 304 | list.append("scaleZ(%f)" % self.scl[2]) 305 | 306 | return " ".join(list) 307 | 308 | def scaleVA(arr, scale): 309 | return [x*scale for x in arr] 310 | 311 | class SimpleObject: 312 | def __init__(self, obj, scene, op): 313 | self.name = obj.name.replace(".", "__") 314 | self.obj = obj 315 | self.parent = None 316 | self.children = [] 317 | self.anim = None 318 | self.material = None 319 | self.transformOrigin = None 320 | self.scene = scene 321 | self.op = op 322 | 323 | if obj.type == 'MESH': 324 | self.mesh = obj.data 325 | else: 326 | self.mesh = None 327 | 328 | if self.mesh != None: 329 | mat_list = obj.material_slots 330 | if len(mat_list) > 0: 331 | self.material = mat_list[0].material 332 | 333 | def importIpo(self, ipo): 334 | anim = SimpleAnim(self, self.op) 335 | anim.grabAllFrameTimes(ipo) 336 | self.anim = anim 337 | return anim 338 | 339 | def blenderChildren(self): 340 | return [obj for obj in self.scene.objects if obj.parent == self.obj ] 341 | 342 | def getTransform(self): 343 | mat = self.obj.matrix_local 344 | # Handle collapsed transforms 345 | if self.scene.cssexportcollapsetransforms: 346 | mat = self.obj.matrix_world 347 | #mat = parentMat * mat 348 | 349 | loc = scaleVA(mat.to_translation(), SimpleTransform.GLOBAL_SCALE) 350 | rot = mat.to_euler() 351 | scl = mat.to_scale() 352 | 353 | trans = SimpleTransform() 354 | 355 | #self.op.report({'INFO'}, "[%i] %s getLocation: %f %f %f" % (bpy.context.scene.frame_current, self.obj.name, loc[0], -loc[1], loc[2])) 356 | #self.op.report({'INFO'}, "[%i] %s getRotation: %f %f %f" % (bpy.context.scene.frame_current, self.obj.name, rot[0], rot[1], rot[2])) 357 | 358 | if self.scene.cssexportswitchaxis: 359 | trans.setLocation(loc[0], -loc[2], loc[1]) 360 | trans.setRotation(rot[0], rot[2], rot[1]) 361 | trans.setScale(scl[0], scl[2], scl[1]) 362 | else: 363 | trans.setLocation(loc[0], -loc[1], loc[2]) 364 | trans.setRotation(rot[0], rot[1], rot[2]) 365 | trans.setScale(scl[0], scl[1], scl[2]) 366 | 367 | trans.setVis(not self.obj.hide_render) 368 | return trans 369 | 370 | def getUVBounds(self): 371 | msh = self.mesh 372 | 373 | mshuv = None 374 | for uv in msh.uv_textures: 375 | if uv.active: 376 | mshuv = uv.data 377 | break 378 | 379 | if msh != None and mshuv: 380 | minp = [10e30,10e30] 381 | maxp = [-10e30,-10e30] 382 | 383 | uvcoords = [] 384 | for f in mshuv: 385 | for uv in f.uv: 386 | uvcoords.append(tuple(uv)) 387 | for pos in uvcoords: 388 | for i in range(0,2): 389 | if pos[i] < minp[i]: 390 | minp[i] = pos[i] 391 | if pos[i] > maxp[i]: 392 | maxp[i] = pos[i] 393 | return minp, maxp 394 | 395 | return [0.0, 0.0], [1.0, 1.0] 396 | 397 | def getBounds(self): 398 | msh = self.mesh 399 | if msh != None: 400 | minp = [10e30,10e30,10e30] 401 | maxp = [-10e30,-10e30,-10e30] 402 | 403 | for v in msh.vertices: 404 | pos = v.co 405 | for i in range(0,3): 406 | if pos[i] < minp[i]: 407 | minp[i] = pos[i] 408 | if pos[i] > maxp[i]: 409 | maxp[i] = pos[i] 410 | return scaleVA(minp, SimpleTransform.GLOBAL_SCALE), scaleVA(maxp, SimpleTransform.GLOBAL_SCALE) 411 | 412 | box = obj.bound_box 413 | return scaleVA(min(box), SimpleTransform.GLOBAL_SCALE), scaleVA(max(box), SimpleTransform.GLOBAL_SCALE) 414 | 415 | def getWorldCenter(self): 416 | if self.parent != None: 417 | center = self.parent.getWorldCenter() 418 | else: 419 | center = [0,0,0] 420 | center[0] += self.center[0] 421 | center[1] += self.center[1] 422 | center[2] += self.center[2] 423 | return center 424 | 425 | class SimpleAnim: 426 | def __init__(self, obj, op): 427 | self.object = obj 428 | self.identifier = obj.name + '-anim' 429 | self.matters = None 430 | self.interpolation = None 431 | self.animates_layer = False 432 | self.frames = None # generated frames 433 | self.propertyInterpolation = {} 434 | self.start = 0 435 | self.len = 0 436 | self.op = op 437 | self.animates_vis = False 438 | 439 | def encompassesFrame(self, fid): 440 | if fid >= self.start and fid < self.start+self.len: 441 | return True 442 | return False 443 | 444 | def setPropertyInterpolationTypes(self): 445 | for interpolation in self.interpolation: 446 | if interpolation != None: 447 | self.propertyInterpolation["TRANSFORM"] = interpolation 448 | break 449 | 450 | def combineFrom(self, other): 451 | #print "COMBINING %s with %s" % (self.identifier, other.identifier) 452 | self.matters = self.matters.setFrom(other.matters, 0) 453 | 454 | # Fit start,len 455 | if other.start < self.start: 456 | self.len += self.start - other.start 457 | self.start = other.start 458 | sEnd = self.start + self.len 459 | oEnd = other.start + other.len 460 | if oEnd > sEnd: 461 | self.len += oEnd - sEnd 462 | 463 | for key in other.propertyInterpolation.keys(): 464 | if not key in self.propertyInterpolation.keys(): 465 | self.propertyInterpolation[key] = other.propertyInterpolation[key] 466 | 467 | def grabAllFrameTimes(self, ipo): 468 | frames = {} 469 | 470 | checkList = ['location', 'scale', 'rotation_euler', 'layer'] 471 | has_hide_render_track = False 472 | 473 | self.op.report({'INFO'}, "ANIM: %s" % ipo.name) 474 | curveFrameList = [] 475 | for fcurve in ipo.fcurves: 476 | # Determine frame times for this curve 477 | curveFrames = self.getFrameTimes(fcurve) 478 | if curveFrames != None: 479 | self.op.report({'INFO'}, "CURVEFRAMELIST: %i, start=%i, end=%i" % (len(curveFrames['frames']), curveFrames['start'], curveFrames['end'])) 480 | curveFrameList.append(curveFrames) 481 | if fcurve.data_path == 'hide_render': 482 | has_hide_render_track = True 483 | break 484 | 485 | # Combine all 486 | earliest, latest = tuple(ipo.frame_range) # e.g. 1, 2 487 | numFrames = int(((latest+1) - earliest)) # e.g. 1, 2 == 2 488 | 489 | self.op.report({'INFO'}, "NUMBER OF FRAMES = %i, START == %i" % (numFrames, earliest)) 490 | 491 | for frameList in curveFrameList: 492 | self.combineFrameTimes(frameList, earliest, latest, frames) 493 | 494 | framesList = list(frames.values()) 495 | framesList = sorted(framesList, key=lambda a:a[0]) 496 | 497 | self.matters = Bitfield(latest+1) 498 | self.interpolation = list(map(lambda x:None, range(int(latest)+1))) 499 | for frame in framesList: 500 | self.matters[frame[0]] = 1 # NOTE: frame 0 will be ignored 501 | self.interpolation[frame[0]] = frame[1] 502 | 503 | #print "\tSTART=%i,END=%d,LEN=%d" % (earliest, latest, numFrames) 504 | self.start = earliest # e.g. 1 505 | self.len = numFrames # e.g. 2 [1,2] 506 | self.setPropertyInterpolationTypes() 507 | self.animates_vis = has_hide_render_track 508 | 509 | def combineFrameTimes(self, frames, startFrame, endFrame, outList): 510 | fl = endFrame - startFrame 511 | for frame in frames["frames"]: 512 | percent = float(frame[0]-startFrame) / fl # e.g. 1-1 / 2-1 == 0; 2-1 / 2-1 == 1.0 513 | key = ("%2.2f" % (percent*100)) + "%" 514 | if not key in outList: 515 | outList[key] = [int(frame[0]), frame[2]] 516 | 517 | def getFrameTimes(self, curve): 518 | if curve == None: 519 | return None 520 | 521 | # time, value 522 | fr = list(map(lambda f: [f.co[0], f.co[1], f.interpolation], curve.keyframe_points)) 523 | return {"frames":fr, 524 | "start": fr[0][0], 525 | "end": fr[-1][0]} 526 | 527 | # Calculates overall start & stop time 528 | def getFrameTimeBounds(self, list): 529 | earliest = 99999999 530 | latest = -1 531 | 532 | for f in list: 533 | if f["start"] < earliest: 534 | earliest = f["start"] 535 | if f["end"] > latest: 536 | latest = f["end"] 537 | 538 | return earliest, latest 539 | 540 | def halfOf(p1, p2): 541 | x = (p2[0] - p1[0]) * 0.5 542 | y = (p2[1] - p1[1]) * 0.5 543 | z = (p2[2] - p1[2]) * 0.5 544 | return [x, y, z] 545 | 546 | # Export action 547 | 548 | 549 | 550 | class ExportCSSData(bpy.types.Operator): 551 | global exportmessage 552 | bl_idname = "export_scene.css_html" 553 | bl_label = "Export CSS Transform" 554 | __doc__ = """Exports scene to a CSS Transform animation""" 555 | 556 | # List of operator properties, the attributes will be assigned 557 | # to the class instance from the operator settings before calling. 558 | 559 | filepath: StringProperty( 560 | subtype='FILE_PATH', 561 | ) 562 | filter_glob: StringProperty( 563 | default="*.html", 564 | options={'HIDDEN'}, 565 | ) 566 | 567 | @classmethod 568 | def poll(cls, context): 569 | return context.active_object != None 570 | 571 | def execute(self, context): 572 | if not self.filepath: 573 | self.report({'ERROR'}, "No file selected") 574 | return {'CANCELLED'} 575 | 576 | if not self.filepath.endswith('.html'): 577 | self.filepath += '.html' 578 | 579 | self.doExport(self.filepath, context) 580 | 581 | return {'FINISHED'} 582 | 583 | def invoke(self, context, event): 584 | context.window_manager.fileselect_add(self) 585 | return {'RUNNING_MODAL'} 586 | 587 | # Recursively makes sure child elements have anim tracks (for collapsed transforms) 588 | def recursiveAnimClone(self, obj, new_anims): 589 | parent = obj.parent 590 | if parent != None and parent.anim != None: 591 | if obj.anim == None: 592 | obj.anim = SimpleAnim(obj, self) 593 | obj.anim.matters = Bitfield(parent.anim.matters.size) 594 | new_anims.append(obj.anim) 595 | obj.anim.combineFrom(parent.anim) 596 | 597 | for child in obj.children: 598 | self.recursiveAnimClone(child, new_anims) 599 | 600 | def importObjects(self, olist, out_list, anims_list, scene, parent=None): 601 | for obj in olist: 602 | self.report({'INFO'}, "IMPORTING OBJECT: %s %s" % (obj.name, obj.type)) 603 | obj_parent = obj.parent 604 | if (parent == None and obj_parent != None) or (parent != None and obj_parent != parent.obj): 605 | continue 606 | 607 | if obj.type != "MESH" and obj.type != "EMPTY": 608 | continue 609 | 610 | ipo = obj.animation_data 611 | built_object = SimpleObject(obj, scene, self) 612 | 613 | if ipo != None and ipo.action != None and len(ipo.action.fcurves) != 0: 614 | self.report({'INFO'}, "Importing curve for %s" % obj.name) 615 | anims_list.append(built_object.importIpo(ipo.action)) 616 | 617 | # Insert into correct list 618 | if parent != None: 619 | built_object.parent = parent 620 | parent.children.append(built_object) 621 | else: 622 | out_list.append(built_object) 623 | 624 | # Recurse 625 | self.importObjects(built_object.blenderChildren(), out_list, anims_list, scene, built_object) 626 | 627 | 628 | def doExport(self, filePath, context): 629 | scene = context.scene 630 | 631 | ctx = scene.render 632 | 633 | objects = [] 634 | anims = [] 635 | 636 | SimpleTransform.GLOBAL_SCALE = scene.cssexportglobalscale 637 | 638 | scene.frame_set(1) 639 | 640 | # Import objects and frame times 641 | self.importObjects(scene.objects, objects, anims, scene) 642 | 643 | # Collapse transforms if neccesary 644 | if scene.cssexportcollapsetransforms: 645 | new_anims = [] 646 | for anim in anims: 647 | self.recursiveAnimClone(anim.object, new_anims) 648 | anims += new_anims 649 | 650 | # Clear anim frames 651 | for anim in anims: 652 | anim.frames = [] 653 | 654 | doBake = scene.cssexportbakeanim 655 | 656 | # Grab frames for all anims 657 | for fid in range(scene.frame_start, scene.frame_end): 658 | scene.frame_set(fid) 659 | bpy.context.view_layer.update() 660 | 661 | for anim in anims: 662 | if anim.matters[fid] or (doBake and anim.encompassesFrame(fid)): 663 | # TODO: grab material color, etc 664 | interpolation = None 665 | try: 666 | interpolation = anim.propertyInterpolation["TRANSFORM"] 667 | except: 668 | interpolation = "linear" 669 | 670 | if doBake: 671 | interpolation = "linear" 672 | 673 | anim.frames.append([fid, anim.object.getTransform(), interpolation]) 674 | 675 | self.exportCSS(objects, anims, scene, filePath) 676 | 677 | def exportCSS(self, objects, anims, scene, filename): 678 | # Second step: output webkit stuff 679 | doc = [] 680 | style = ["#root div {position: absolute;}\n", 681 | "#root {background-color: #eeeeee; position: absolute; width:640px; height: 480px;"] 682 | 683 | # 3D Needs to have a perspective and origin 684 | # TODO: some form of logical calculation using a camera 685 | if scene.cssexport3d: 686 | style.append("perspective: %i; " % (70)) 687 | style.append("perspective-origin: center 240px;") 688 | style.append("}\n") 689 | 690 | self.exportObjects(objects, doc, style, scene) 691 | 692 | self.report({'INFO'}, f"Exporting html to: {filename}") 693 | 694 | className = bpy.path.ensure_ext(bpy.path.basename(filename), '') 695 | classPath = basepath(str(filename)) 696 | animName = "%s-%s" % (className, scene.name) 697 | doBake = scene.cssexportbakeanim 698 | 699 | tracks = [] 700 | # Animation keyframes 701 | for anim in anims: 702 | tracks.append("@keyframes %s {\n" % anim.identifier) 703 | 704 | earliest = anim.start 705 | fl = anim.len-1 706 | frames = anim.frames 707 | for frame in frames: 708 | # e.g. two frames 1 2 709 | # (1 - 1) / 2 = 0% 710 | # (2 - 1) / 2 = 100% 711 | percent = float(frame[0] - earliest) / fl 712 | fid = ("%2.2f" % (percent*100)) + "%" 713 | 714 | tracks.append("%s {\n" % fid) 715 | tracks.append("transform: %s;\n" % frame[1].transformValue(scene.cssexport3d)) 716 | if anim.animates_vis: 717 | if not frame[1].vis: 718 | tracks.append("visibility: hidden;\n") 719 | else: 720 | tracks.append("visibility: visible;\n") 721 | if not doBake: 722 | tracks.append("animation-timing-function: %s;\n" % InterpolationLookup[frame[2]]) 723 | tracks.append("}\n") 724 | 725 | tracks.append("}\n") 726 | 727 | substitutions = {'title': className, 'style': "".join(style), 'track_path':animName, 'scene': "".join(doc)} 728 | css_substitutions = {'content': "".join(tracks)} 729 | 730 | # Dump tracks 731 | fs = open("%s/%s.css" % (classPath, animName), "w") 732 | fs.write(TRACKS_TPL % css_substitutions) 733 | fs.close() 734 | 735 | # Dump to document 736 | if not scene.cssexportanimtrackonly: 737 | fs = open("%s/%s.html" % (classPath, className), "w") 738 | fs.write(WEBKIT_TPL % substitutions) 739 | fs.close() 740 | 741 | def exportObjects(self, olist, doc, style, scene): 742 | threedee = scene.cssexport3d 743 | fps = None 744 | if scene.cssexportanimfps == 0.0: 745 | fps = scene.render.fps 746 | else: 747 | fps = scene.cssexportanimfps 748 | 749 | for obj in olist: 750 | self.report({'INFO'}, "EXPORTING OBJECT %s" % obj.obj.name) 751 | # Actual div 752 | doc.append("
" % obj.name) 753 | 754 | # CSS 755 | style.append("#%s {\n" % obj.name) 756 | 757 | if obj.mesh != None: 758 | minb, maxb = obj.getBounds() 759 | 760 | # Problem: we need to fix the origin of the HTML element. 761 | # -transform-origin only works for rotation and scaling. 762 | # Solution: 763 | # use left and top to offset element center instead, 764 | # taking into account the origin is by default at the center 765 | # e.g. bound size = 64,64 766 | # bound origin = 0,0 767 | # webkit origin = 32,32 768 | # left, top = -32, -32 (i.e. bound origin - webkit origin) 769 | halfSize = halfOf(minb, maxb) 770 | 771 | boundOrigin = [ 772 | halfSize[0] + minb[0], 773 | halfSize[1] + minb[1], 774 | halfSize[2] + minb[2]] 775 | 776 | boundOrigin[1] = -boundOrigin[1] # scene is -y 777 | 778 | obj.center = [ 779 | boundOrigin[0] - halfSize[0], 780 | boundOrigin[1] - halfSize[1], 781 | boundOrigin[2] - halfSize[2]] 782 | 783 | # transformOrigin to correct rotation and scaling 784 | obj.transformOrigin = [-obj.center[0], -obj.center[1]] 785 | else: 786 | obj.center = [0,0,0] 787 | 788 | #print "%s actual center=%s" % (obj.obj.getName(), str(obj.center)) 789 | 790 | if not scene.cssexportcollapsetransforms and obj.parent != None: 791 | wc = obj.getWorldCenter() 792 | wc[0] -= obj.center[0] 793 | wc[1] -= obj.center[1] 794 | 795 | # Center needs to be expressed in parents coordinate system 796 | obj.center[0] = obj.center[0] - wc[0] 797 | obj.center[1] = obj.center[1] - wc[1] 798 | # 799 | #print "%s center=%s" % (obj.obj.getName(), str(obj.center)) 800 | 801 | style.append("transform: %s;\n" % obj.getTransform().transformValue(threedee)) 802 | 803 | if obj.mesh != None: 804 | style.append("width: %dpx;\n" % (maxb[0] - minb[0])) 805 | style.append("height: %dpx;\n" % (maxb[1] - minb[1])) 806 | style.append("left: %dpx;\n" % (obj.center[0])) 807 | style.append("top: %dpx;\n" % (obj.center[1])) 808 | if obj.transformOrigin != None: 809 | style.append("transform-origin: %dpx %dpx;\n" % (obj.transformOrigin[0], obj.transformOrigin[1])) 810 | 811 | if scene.cssexport3d: 812 | style.append("transform-style: preserve-3d;\n") 813 | 814 | # color, texture, etc 815 | if obj.material != None: 816 | # 817 | mat = obj.material 818 | self.report({'INFO'}, obj.material.blend_method) 819 | 820 | if obj.material.blend_method == 'OPAQUE': 821 | # color 822 | style.append("background-color: rgb(%d,%d,%d);\n" % (mat.diffuse_color[0] * 255, mat.diffuse_color[1] * 255, mat.diffuse_color[2] * 255)) 823 | 824 | if mat.diffuse_color[3] < 1.0: 825 | style.append("opacity: %f;\n" % mat.diffuse_color[3]) 826 | 827 | # Use any existing texture node to determine primary image 828 | for node in mat.node_tree.nodes: 829 | if node.type == 'TEX_IMAGE': 830 | # Dump & save 831 | img = node.image 832 | if img != None: 833 | # Image file 834 | name = img.filepath 835 | style.append("background-image: url(\"%s.png\");\n" % bpy.path.ensure_ext(bpy.path.basename(name), '')) 836 | 837 | # Background position 838 | uv_min, uv_max = obj.getUVBounds() 839 | style.append("background-position: %i%% %i%%;\n" % (uv_min[0] * 100, uv_min[1] * 100)) 840 | 841 | # Background scaling 842 | scale = [uv_max[0] - uv_min[0], 843 | uv_max[1] - uv_min[1]] 844 | 845 | # Calculate difference in image scale 846 | sz = img.size 847 | oWidth = sz[0] / (maxb[0] - minb[0]) 848 | oHeight = sz[1] / (maxb[1] - minb[1]) 849 | 850 | scale[0] = round(scale[0] * 100, 2) 851 | scale[1] = round(scale[1] * 100, 2) 852 | 853 | if oWidth != 1.0 or oHeight != 1.0: 854 | style.append("background-size: %.2f%% %.2f%%;\n" % (scale[0], scale[1])) 855 | 856 | # animation 857 | if obj.anim != None: 858 | anim = obj.anim 859 | 860 | duration = anim.len / fps 861 | delay = (anim.start-1) / fps 862 | 863 | style.append("animation-name: %s;\n" % anim.identifier) 864 | style.append("animation-duration: %fs;\n" % duration) 865 | style.append("animation-delay: %fs;\n" % delay) 866 | 867 | if scene.cssexportanimloop: 868 | style.append("animation-iteration-count: infinite;\n") 869 | if scene.cssexportbakeanim: 870 | style.append("animation-timing-function: linear;\n") 871 | 872 | style.append("}\n") 873 | 874 | # Children are part of element 875 | if not scene.cssexportcollapsetransforms: 876 | self.exportObjects(obj.children, doc, style, scene) 877 | 878 | doc.append("
\n") 879 | 880 | # Children are part of root 881 | if scene.cssexportcollapsetransforms: 882 | self.exportObjects(obj.children, doc, style, scene) 883 | 884 | 885 | 886 | 887 | export_classes = ( 888 | ExportCSSData, 889 | ) 890 | 891 | def menu_func(self, context): 892 | default_path = os.path.splitext(bpy.data.filepath)[0] + ".html" 893 | self.layout.operator(ExportCSSData.bl_idname, text="Export CSS Transform (.html)").filepath = default_path 894 | 895 | def register(): 896 | for cls in export_classes: 897 | bpy.utils.register_class(cls) 898 | bpy.types.TOPBAR_MT_file_export.append(menu_func) 899 | 900 | def unregister(): 901 | for cls in reverse(export_classes): 902 | bpy.utils.unregister_class(cls) 903 | bpy.types.TOPBAR_MT_file_export.remove(menu_func) 904 | 905 | if __name__ == "__main__": 906 | register() 907 | -------------------------------------------------------------------------------- /examples/walk.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | walk 5 | 2412 | 2413 | 2414 |
2415 |
2416 |
2417 |
2418 |
2419 |
2420 |
2421 |
2422 |
2423 |
2424 |
2425 |
2426 |
2427 |
2428 |
2429 |
2430 | 2431 | --------------------------------------------------------------------------------