1 //
2 // SuperTuxKart - a fun racing game with go-kart
3 // Copyright (C) 2009-2015 Joerg Henrichs
4 //
5 // This program is free software; you can redistribute it and/or
6 // modify it under the terms of the GNU General Public License
7 // as published by the Free Software Foundation; either version 3
8 // of the License, or (at your option) any later version.
9 //
10 // This program is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with this program; if not, write to the Free Software
17 // Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
18
19 #include "animations/ipo.hpp"
20
21 #include "io/xml_node.hpp"
22 #include "utils/vs.hpp"
23 #include "utils/log.hpp"
24
25 #include <string.h>
26 #include <algorithm>
27 #include <cmath>
28
29 const std::string Ipo::m_all_channel_names[IPO_MAX] =
30 {"LocX", "LocY", "LocZ", "LocXYZ",
31 "RotX", "RotY", "RotZ",
32 "ScaleX", "ScaleY", "ScaleZ" };
33
34 // ----------------------------------------------------------------------------
35 /** Initialise the Ipo from the specifications in the XML file.
36 * \param curve The XML node with the IPO data.
37 * \param fps Frames per second value, necessary to convert frame values
38 * into time.
39 * \param reverse If this is set to true, the ipo data will be reverse. This
40 * is used by the cannon if the track is driven in reverse.
41 */
IpoData(const XMLNode & curve,float fps,bool reverse)42 Ipo::IpoData::IpoData(const XMLNode &curve, float fps, bool reverse)
43 {
44 if(curve.getName()!="curve")
45 {
46 Log::warn("Animations", "Expected 'curve' for animation, got '%s' --> Ignored.",
47 curve.getName().c_str());
48 return;
49 }
50 std::string channel;
51 curve.get("channel", &channel);
52 m_channel=IPO_MAX;
53 for(unsigned int i=IPO_LOCX; i<IPO_MAX; i++)
54 {
55 if(m_all_channel_names[i]==channel)
56 {
57 m_channel=(IpoChannelType)i;
58 break;
59 }
60 }
61 if(m_channel==IPO_MAX)
62 {
63 Log::error("Animation", "Unknown animation channel: '%s' --> Ignored",
64 channel.c_str());
65 return;
66 }
67
68 std::string interp;
69 curve.get("interpolation", &interp);
70 if (interp=="const" ) m_interpolation = IP_CONST;
71 else if(interp=="linear") m_interpolation = IP_LINEAR;
72 else m_interpolation = IP_BEZIER;
73
74 std::string extend;
75 curve.get("extend", &extend);
76 if (extend=="cyclic") m_extend = ET_CYCLIC;
77 else if (extend=="const" ) m_extend = ET_CONST;
78 else
79 {
80 // For now extrap and cyclic_extrap do not work
81 Log::warn("Animation", "Unsupported extend '%s' - defaulting to CONST.",
82 extend.c_str());
83 m_extend = ET_CONST;
84 }
85
86 if(m_channel==IPO_LOCXYZ)
87 readCurve(curve, reverse);
88 else
89 readIPO(curve, fps, reverse);
90
91 } // IpoData
92
93 // ----------------------------------------------------------------------------
94 /** Reads a blender IPO curve, which constists of a frame number and a control
95 * point. This only handles a single axis.
96 * \param node The root node with all curve data points.
97 * \param fps Frames per second value, necessary to convert the frame based
98 * data from blender into times.
99 * \param reverse If this is set, the data are read in reverse. This is used
100 * for a cannon in reverse mode.
101 */
readIPO(const XMLNode & curve,float fps,bool reverse)102 void Ipo::IpoData::readIPO(const XMLNode &curve, float fps, bool reverse)
103 {
104 m_start_time = 999999.9f;
105 m_end_time = -999999.9f;
106 for(unsigned int i=0; i<curve.getNumNodes(); i++)
107 {
108 int node_index = reverse ? curve.getNumNodes()-i-1 : i;
109 const XMLNode *node = curve.getNode(node_index);
110 core::vector2df xy;
111 node->get("c", &xy);
112 // Convert blender's frame number (1 ...) into time (0 ...)
113 float t = (xy.X-1)/fps;
114 Vec3 point(xy.Y, 0, 0, t);
115 m_points.push_back(point);
116 m_start_time = std::min(m_start_time, t);
117 m_end_time = std::max(m_end_time, t);
118 if(m_interpolation==IP_BEZIER)
119 {
120 Vec3 handle1, handle2;
121 core::vector2df handle;
122 node->get(reverse ? "h2" : "h1", &handle);
123 handle1.setW((xy.X-1)/fps);
124 handle1.setX(handle.Y);
125 node->get(reverse ? "h1" : "h2", &handle);
126 handle2.setW((xy.X-1)/fps);
127 handle2.setX(handle.Y);
128 m_handle1.push_back(handle1);
129 m_handle2.push_back(handle2);
130 }
131 } // for i<getNumNodes()
132 } // IpoData::readIPO
133
134 // ----------------------------------------------------------------------------
135 /** Reads in 3 dimensional curve data - i.e. the xml file contains xyz, but no
136 * time. If the curve is using bezier interpolation, the curve is
137 * approximated by piecewise linear functions. Reason is that bezier curves
138 * can not (easily) be used for smooth (i.e. constant speed) driving:
139 * A linear time variation in [0, 1] will result in non-linear distances
140 * for the bezier function, which is a 3rd degree polynomial (--> the speed
141 * which is the deviation of this function is a 2nd degree polynomial, and
142 * therefore not constant!
143 * \param node The root node with all curve data points.
144 * \param reverse If this is set, the data are read in reverse. This is used
145 * for a cannon in reverse mode.
146 */
readCurve(const XMLNode & curve,bool reverse)147 void Ipo::IpoData::readCurve(const XMLNode &curve, bool reverse)
148 {
149 m_start_time = 0;
150 m_end_time = -999999.9f;
151 float speed = 30.0f;
152 curve.get("speed", &speed);
153
154 for(unsigned int i=0; i<curve.getNumNodes(); i++)
155 {
156 int node_index = reverse ? curve.getNumNodes()-i-1 : i;
157 const XMLNode *node = curve.getNode(node_index);
158 Vec3 point;
159 node->get("c", &point);
160
161 if(m_interpolation==IP_BEZIER)
162 {
163 Vec3 handle;
164 node->get(reverse ? "h2" : "h1", &handle);
165 m_handle1.push_back(handle);
166 node->get(reverse ? "h1" : "h2", &handle);
167 m_handle2.push_back(handle);
168 if(i>0)
169 {
170 // We have to take a copy of the end point, since otherwise
171 // it can happen that as more points are added to m_points
172 // in the approximateBezier function, the data gets
173 // reallocated and then the reference to the original point
174 // is not correct anymore.
175 Vec3 end_point = m_points[m_points.size()-1];
176 approximateBezier(0.0f, 1.0f, end_point, point,
177 m_handle2[i-1], m_handle1[i]);
178 }
179 }
180 m_points.push_back(point);
181 } // for i<getNumNodes()
182
183 // The handles of a bezier curve are not needed anymore and can be
184 // removed now (since the bezier funciton has been replaced with a
185 // piecewise linear
186 if(m_interpolation==IP_BEZIER)
187 {
188 m_handle1.clear();
189 m_handle2.clear();
190 m_interpolation = IP_LINEAR;
191 }
192
193 if(m_points.size()==0) return;
194
195 // Compute the time for each segment based on the speed and
196 // store it in the W component.
197 m_points[0].setW(0);
198 for(unsigned int i=1; i<m_points.size(); i++)
199 {
200 m_points[i].setW( (m_points[i]-m_points[i-1]).length()/speed
201 + m_points[i-1].getW() );
202 }
203 m_end_time = m_points.back().getW();
204 } // IpoData::readCurve
205
206 // ----------------------------------------------------------------------------
207 /** This function approximates a bezier curve by piecewise linear functions.
208 * It uses quite primitive approximations: if the estimated distance of
209 * the bezier curve at between t=t0 and t=t1 is greater than 2, it
210 * inserts one point at (t0+t1)/2, and recursively splits the two intervals
211 * further. End condition is either a maximum recursion depth of 6 or
212 * an estimated curve length of less than 2. It does not add any points
213 * at t=t0 or t=t1, only between this interval.
214 * \param t0, t1 The interval which is approximated.
215 * \param p0, p1, h0, h1: The bezier parameters.
216 * \param rec_level The recursion level to avoid creating too many points.
217 */
approximateBezier(float t0,float t1,const Vec3 & p0,const Vec3 & p1,const Vec3 & h0,const Vec3 & h1,unsigned int rec_level)218 void Ipo::IpoData::approximateBezier(float t0, float t1,
219 const Vec3 &p0, const Vec3 &p1,
220 const Vec3 &h0, const Vec3 &h1,
221 unsigned int rec_level)
222 {
223 // Limit the granularity by limiting the recursion depth
224 if(rec_level>6)
225 return;
226
227 float distance = approximateLength(t0, t1, p0, p1, h0, h1);
228 // A more sophisticated estimation might be useful (e.g. taking the
229 // difference between a linear approximation and the actual bezier
230 // curve into accound.
231 if(distance<=0.2f)
232 return;
233
234 // Insert one point at (t0+t1)/2. First split the left part of
235 // the interval by a recursive call, then insert the point at
236 // (t0+t1)/2, then approximate the right part of the interval.
237 approximateBezier(t0, (t0+t1)*0.5f, p0, p1, h0, h1, rec_level + 1);
238 Vec3 middle;
239 for(unsigned int j=0; j<3; j++)
240 middle[j] = getCubicBezier((t0+t1)*0.5f, p0[j], h0[j], h1[j], p1[j]);
241 m_points.push_back(middle);
242 approximateBezier((t0+t1)*0.5f, t1, p0, p1, h0, h1, rec_level + 1);
243
244 } // approximateBezier
245
246 // ----------------------------------------------------------------------------
247 /** Approximates the length of a bezier curve using a simple Euler
248 * approximation by dividing the interval [t0, t1] into 10 pieces. Good enough
249 * for our needs in STK.
250 * \param t0, t1 Approximate for t in [t0, t1].
251 * \param p0, p1 The start and end point of the curve.
252 * \param h0, h1 The control points for the corresponding points.
253 */
approximateLength(float t0,float t1,const Vec3 & p0,const Vec3 & p1,const Vec3 & h0,const Vec3 & h1)254 float Ipo::IpoData::approximateLength(float t0, float t1,
255 const Vec3 &p0, const Vec3 &p1,
256 const Vec3 &h0, const Vec3 &h1)
257 {
258 assert(m_interpolation == IP_BEZIER);
259
260 float distance=0;
261 const unsigned int NUM_STEPS=10;
262 float delta = (t1-t0)/NUM_STEPS;
263 Vec3 prev_point;
264 for(unsigned int j=0; j<3; j++)
265 prev_point[j] = getCubicBezier(t0, p0[j], h0[j], h1[j], p1[j]);
266 for(unsigned int i=1; i<=NUM_STEPS; i++)
267 {
268 float t = t0 + i * delta;
269 Vec3 next_point;
270 // Interpolate all three axis
271 for(unsigned j=0; j<3; j++)
272 {
273 next_point[j] = getCubicBezier(t, p0[j], h0[j], h1[j], p1[j]);
274 }
275 distance += (next_point - prev_point).length();
276 prev_point = next_point;
277 } // for i< NUM_STEPS
278
279 return distance;
280 } // IpoData::approximateLength
281
282 // ----------------------------------------------------------------------------
283 /** Adjusts the time so that it is between start and end of this Ipo. This
284 * takes the extend type into account, e.g. cyclic animations will just
285 * use a modulo operation, while constant extends will return start or
286 * end time directly.
287 * \param time The time to adjust.
288 */
adjustTime(float time)289 float Ipo::IpoData::adjustTime(float time)
290 {
291 if(time<m_start_time)
292 {
293 switch(m_extend)
294 {
295 case IpoData::ET_CYCLIC:
296 time = m_start_time + fmodf(time, m_end_time-m_start_time); break;
297 case ET_CONST:
298 time = m_start_time; break;
299 default:
300 // FIXME: ET_CYCLIC_EXTRAP and ET_EXTRAP missing
301 assert(false);
302 } // switch m_extend
303 } // if time < m_start_time
304
305 else if(time > m_end_time)
306 {
307 switch(m_extend)
308 {
309 case ET_CYCLIC:
310 time = m_start_time + fmodf(time, m_end_time-m_start_time); break;
311 case ET_CONST:
312 time = m_end_time; break;
313 default:
314 // FIXME: ET_CYCLIC_EXTRAP and ET_EXTRAP missing
315 assert(false);
316 } // switch m_extend
317 } // if time > m_end_time
318 return time;
319 } // adjustTime
320
321 // ----------------------------------------------------------------------------
get(float time,unsigned int index,unsigned int n)322 float Ipo::IpoData::get(float time, unsigned int index, unsigned int n)
323 {
324 switch(m_interpolation)
325 {
326 case IP_CONST : return m_points[n][index];
327 case IP_LINEAR : {
328 float t = time-m_points[n].getW();
329 return m_points[n][index]
330 + t*(m_points[n+1][index]-m_points[n][index]) /
331 (m_points[n+1].getW()-m_points[n].getW());
332 }
333 case IP_BEZIER: { if(n==m_points.size()-1)
334 {
335 // FIXME: only const implemented atm.
336 return m_points[n][index];
337 }
338 float t = (time-m_points[n].getW())
339 / (m_points[n+1].getW()-m_points[n].getW());
340 return getCubicBezier(t,
341 m_points [n ][index],
342 m_handle2[n ][index],
343 m_handle1[n+1][index],
344 m_points [n+1][index]);
345 }
346 } // switch
347 // Keep the compiler happy:
348 return 0;
349 } // IpoData::get
350
351 // ----------------------------------------------------------------------------
352 /** Computes a cubic bezier curve for a given t in [0,1] and four control
353 * points. The curve will go through p0 (t=0), p3 (t=1).
354 * \param t The parameter for the bezier curve, must be in [0,1].
355 * \param p0, p1, p2, p3 The four control points.
356 */
getCubicBezier(float t,float p0,float p1,float p2,float p3) const357 float Ipo::IpoData::getCubicBezier(float t, float p0, float p1,
358 float p2, float p3) const
359 {
360 float c = 3.0f*(p1-p0);
361 float b = 3.0f*(p2-p1)-c;
362 float a = p3 - p0 - c - b;
363 return ((a*t+b)*t+c)*t+p0;
364 } // getCubicBezier
365
366 // ----------------------------------------------------------------------------
367 /** Determines the derivative of a IPO at a given point.
368 * \param time At what time value the derivative is to be computed.
369 * \param index IpoData is based on 3d data. The index specified which
370 * value to use (0=x, 1=y, 2=z).
371 * \param n Curve segment to be used for the computation. It must be correct
372 * for the specified time value.
373 */
getDerivative(float time,unsigned int index,unsigned int n)374 float Ipo::IpoData::getDerivative(float time, unsigned int index,
375 unsigned int n)
376 {
377 switch (m_interpolation)
378 {
379 case IP_CONST: return 0; // Const --> Derivative is 0
380 case IP_LINEAR: {
381 return (m_points[n + 1][index] - m_points[n][index]) /
382 (m_points[n + 1].getW() - m_points[n].getW());
383 }
384 case IP_BEZIER: {
385 if (n == m_points.size() - 1)
386 {
387 // Only const, so derivative is 0
388 return 0;
389 }
390 float t = (time - m_points[n].getW())
391 / (m_points[n + 1].getW() - m_points[n].getW());
392 return getCubicBezierDerivative(t,
393 m_points [n ][index],
394 m_handle2[n ][index],
395 m_handle1[n + 1][index],
396 m_points [n + 1][index] );
397 } // case IPBEZIER
398 default:
399 Log::warn("Ipo::IpoData", "Incorrect interpolation %d",
400 m_interpolation);
401 } // switch
402 return 0;
403 } // IpoData::getDerivative
404
405
406 // ----------------------------------------------------------------------------
407 /** Returns the derivative of a cubic bezier curve for a given t in [0,1] and
408 * four control points. The curve will go through p0 (t=0).
409 * \param t The parameter for the bezier curve, must be in [0,1].
410 * \param p0, p1, p2, p3 The four control points.
411 */
getCubicBezierDerivative(float t,float p0,float p1,float p2,float p3) const412 float Ipo::IpoData::getCubicBezierDerivative(float t, float p0, float p1,
413 float p2, float p3) const
414 {
415 float c = 3.0f*(p1 - p0);
416 float b = 3.0f*(p2 - p1) - c;
417 float a = p3 - p0 - c - b;
418 // f(t) = ((a*t + b)*t + c)*t + p0;
419 // = a*t^3 +b*t^2 + c*t + p0
420 // --> f'(t) = 3*a*t^2 + 2*b*t + c
421 return (3*a * t + 2*b) * t + c;
422 } // bezier
423
424 // ============================================================================
425 /** The Ipo constructor. Ipos can share the actual data to interpolate, which
426 * is stored in a separate IpoData object, see Ipo(const Ipo *ipo)
427 * constructor. This is used for cannons: the actual check line stores the
428 * 'master' Ipo, and each actual IPO that animate a kart just use a copy
429 * of this read-only data.
430 * \param curve The XML data for this curve.
431 * \param fps Frames per second, used to convert all frame based value
432 * in the xml file into seconds.
433 * \param reverse If this is set to true, the ipo data will be reverse. This
434 * is used by the cannon if the track is driven in reverse.
435 */
Ipo(const XMLNode & curve,float fps,bool reverse)436 Ipo::Ipo(const XMLNode &curve, float fps, bool reverse)
437 {
438 m_ipo_data = new IpoData(curve, fps, reverse);
439 m_own_ipo_data = true;
440 reset();
441 } // Ipo
442
443 // ----------------------------------------------------------------------------
444 /** A copy constructor. It shares the read-only data with the source Ipo
445 * \param ipo The ipo to copy from.
446 */
Ipo(const Ipo * ipo)447 Ipo::Ipo(const Ipo *ipo)
448 {
449 // Share the read-only data
450 m_ipo_data = ipo->m_ipo_data;
451 m_own_ipo_data = false;
452 reset();
453 } // Ipo(Ipo*)
454
455 // ----------------------------------------------------------------------------
456 /** Creates a copy of this object (the copy constructor is disabled in order
457 * to avoid implicit copies happening).
458 */
clone()459 Ipo *Ipo::clone()
460 {
461 return new Ipo(this);
462 } // clone
463
464 // ----------------------------------------------------------------------------
465 /** The destructor only frees IpoData if it was created by this instance (and
466 * not if this instance was copied, therefore sharing the IpoData).
467 */
~Ipo()468 Ipo::~Ipo()
469 {
470 if(m_own_ipo_data)
471 delete m_ipo_data;
472 } // ~Ipo
473
474 // ----------------------------------------------------------------------------
475 /** Stores the initial transform. This is necessary for relative IPOs.
476 * \param xyz Position of the object.
477 * \param hpr Rotation of the object.
478 */
setInitialTransform(const Vec3 & xyz,const Vec3 & hpr)479 void Ipo::setInitialTransform(const Vec3 &xyz,
480 const Vec3 &hpr)
481 {
482 m_ipo_data->m_initial_xyz = xyz;
483 m_ipo_data->m_initial_hpr = hpr;
484 } // setInitialTransform
485
486 // ----------------------------------------------------------------------------
487 /** Resets the IPO for (re)starting an animation.
488 */
reset()489 void Ipo::reset()
490 {
491 m_next_n = 1;
492 } // reset
493
494 // ----------------------------------------------------------------------------
495 /** Updates the time of this ipo and interpolates the new position and
496 * rotation (taking the cycle length etc. into account). If a NULL is
497 * given, the value is not updated.
498 * \param time Current time for which to determine the interpolation.
499 * \param xyz The position that needs to be updated (can be NULL).
500 * \param hpr The rotation that needs to be updated (can be NULL).
501 * \param scale The scale that needs to be updated (can be NULL)
502 */
update(float time,Vec3 * xyz,Vec3 * hpr,Vec3 * scale)503 void Ipo::update(float time, Vec3 *xyz, Vec3 *hpr,Vec3 *scale)
504 {
505 assert(!std::isnan(time));
506 switch(m_ipo_data->m_channel)
507 {
508 case Ipo::IPO_LOCX : if(xyz) xyz ->setX(get(time, 0)); break;
509 case Ipo::IPO_LOCY : if(xyz) xyz ->setY(get(time, 0)); break;
510 case Ipo::IPO_LOCZ : if(xyz) xyz ->setZ(get(time, 0)); break;
511 case Ipo::IPO_ROTX : if(hpr) hpr ->setX(get(time, 0)); break;
512 case Ipo::IPO_ROTY : if(hpr) hpr ->setY(get(time, 0)); break;
513 case Ipo::IPO_ROTZ : if(hpr) hpr ->setZ(get(time, 0)); break;
514 case Ipo::IPO_SCALEX : if(scale) scale->setX(get(time, 0)); break;
515 case Ipo::IPO_SCALEY : if(scale) scale->setY(get(time, 0)); break;
516 case Ipo::IPO_SCALEZ : if(scale) scale->setZ(get(time, 0)); break;
517 case Ipo::IPO_LOCXYZ :
518 {
519 if(xyz)
520 {
521 for(unsigned int j=0; j<3; j++)
522 (*xyz)[j] = get(time, j);
523 }
524 break;
525 }
526
527 default: assert(false); // shut up compiler warning
528 } // switch
529
530 } // update
531
532 // ----------------------------------------------------------------------------
533 /** Updates the value of m_next_n to point to the right ipo segment based on
534 * the time.
535 * \param t Time for which m_next_n needs to be updated.
536 */
updateNextN(float * time) const537 void Ipo::updateNextN(float *time) const
538 {
539 *time = m_ipo_data->adjustTime(*time);
540
541 // Time was reset since the last cached value for n,
542 // reset n to start from the beginning again.
543 if (*time < m_ipo_data->m_points[m_next_n - 1].getW())
544 m_next_n = 1;
545 // Search for the first point in the (sorted) array which is greater or equal
546 // to the current time.
547 while (m_next_n < m_ipo_data->m_points.size() - 1 &&
548 *time >= m_ipo_data->m_points[m_next_n].getW())
549 {
550 m_next_n++;
551 } // while
552 } // updateNextN
553
554 // ----------------------------------------------------------------------------
555 /** Returns the interpolated value at the current time (which this objects
556 * keeps track of).
557 * \param time The time for which the interpolated value should be computed.
558 */
get(float time,unsigned int index) const559 float Ipo::get(float time, unsigned int index) const
560 {
561 assert(!std::isnan(time));
562
563 // Avoid crash in case that only one point is given for this IPO.
564 if(m_next_n==0)
565 return m_ipo_data->m_points[0][index];
566
567 updateNextN(&time);
568
569 float rval = m_ipo_data->get(time, index, m_next_n-1);
570 assert(!std::isnan(rval));
571 return rval;
572 } // get
573
574 // ----------------------------------------------------------------------------
575 /** Returns the derivative for any location based curves.
576 * \param time Time for which the derivative is being computed.
577 * \param xyz Pointer where the results should be stored.
578 */
getDerivative(float time,Vec3 * xyz)579 void Ipo::getDerivative(float time, Vec3 *xyz)
580 {
581 // Avoid crash in case that only one point is given for this IPO.
582 if (m_next_n == 0)
583 {
584 // Derivative has no real meaning in case of a single point.
585 // So just return a dummy value.
586 xyz->setValue(1, 0, 0);
587 return;
588 }
589
590 updateNextN(&time);
591 switch (m_ipo_data->m_channel)
592 {
593 case Ipo::IPO_LOCX: xyz->setX(m_ipo_data->getDerivative(time, m_next_n, 0)); break;
594 case Ipo::IPO_LOCY: xyz->setY(m_ipo_data->getDerivative(time, m_next_n, 0)); break;
595 case Ipo::IPO_LOCZ: xyz->setZ(m_ipo_data->getDerivative(time, m_next_n, 0)); break;
596 case Ipo::IPO_LOCXYZ:
597 {
598 if (xyz)
599 {
600 for (unsigned int j = 0; j < 3; j++)
601 (*xyz)[j] = m_ipo_data->getDerivative(time, j, m_next_n-1);
602 }
603 break;
604 }
605 default: Log::warn("IPO", "Unexpected channel %d for derivate.",
606 m_ipo_data->m_channel );
607 xyz->setValue(1, 0, 0);
608 break;
609 } // switch
610
611
612 } // getDerivative
613
614