├── 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 |
174 |
175 |
--------------------------------------------------------------------------------
/examples/testImage.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | testImage
5 |
179 |
180 |
181 |
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 |
2417 |
2419 |
2421 |
2423 |
2426 |
2427 |
2428 |
2429 |
2430 |
2431 |
--------------------------------------------------------------------------------