2013年3月1日金曜日

svgのpath文字列をスクリプトで直接変形する

以前作成したsvgフォント編集スクリプトの絡みで色々と調べていた時に組んだスクリプトが発掘されたので供養.面倒なパスの変形を簡単に行えるようになります.
2014/09/09 バグを見つけましたのでメモ
2014/09/11 バグを見つけましたので再修正.多分これで最終版





パス図形を変形したい時,transform属性を記述するのが最も簡単なものの,敢えてpath文字列を変形したい時があります.例えば,図形を変形したいがstrokeの幅を変更したくない場合などです.その際svg1.2 tinyのvector-effect属性を使えば良いのですが,ie9・10ではこの属性をサポートしていません.また,path要素からglyph要素を生成する場合はそもそもtransform属性を使うことができません.

よってpath文字列を構成している頂点や制御点の位置そのものをずらしてパス図形を変形したくなります.この考え方は非常にシンプルで概ね簡単に実装出来るのですが,一点注意すべき点があります.A操作に依る楕円弧の変換です.

円弧の変換


通常ベジェ曲線ではその端点と制御点の座標を単純にアフィン変換すれば済むのですが,svgにおける楕円弧の定義では短径と長径の半径と楕円の傾きで形状を定義します.そのため,変形後の円弧のパラメータを求めなければならず,単純な座標変換では不可能ということになります.

ですから,本スクリプトが提供するパスの直接変形スクリプトにはそれなりの存在価値があることになります.(1から実装するとなると結論に至るまでかな〜り面倒な調査・検討・計算が必要となるので,気をつけてください.)

ソースコード


前置きが長くなりましたが,以下のスクリプトがパスそのものを変形するスクリプトです.
※実はバグを見つけてしまいました.H,Vコマンドが含まれていた場合,実際にはLコマンドに置き換えねばならないのですが,その処理が抜けています.
pathUtils = (function(){
 
 //関数宣言
 var sin = Math.sin;
 var cos = Math.cos;
 var tan = Math.tan;
 var atan2 = Math.atan2;
 var atan = Math.atan;
 var pow = Math.pow;
 var sqrt = Math.sqrt;
 var abs = Math.abs;
 var PI = Math.PI;
 var PI_DEG = 180;
 
 //作業領域
 var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
 var target = path;
 
 //処理準備
 function ready(d){
  if(!d || d==""){return;}
  if(d instanceof SVGPathElement){
   target = d;
  }else{
   target = path;
   target.setAttribute("d", d);
  }
  return target.pathSegList;
 }

 //処理結果の取得
 function getResult(){
  if(path === target){
   return target.getAttribute("d");   
  }else{
   return target;
  }
 }
 
 //角度をラジアンに変換する
 function deg2rad(deg){
  return deg / PI_DEG * PI;
 }

 //ラジアンを角度に変換する
 function rad2deg(rad){
  return rad / PI * PI_DEG;
 }
 
 //自乗する
 function sq(val){
  return val * val;
 }
 
 //円弧のmatrix変換を行う
 function arcMatrix(seg, a, b, c, d){
  var r1 = seg.r1;
  var r2 = seg.r2;
  var angle = seg.angle;
  var rad = deg2rad(angle);

  //abcdの逆行列の有無を確かめる
  var div = a * d - b * c;
  if(div != 0){
   
   //step1 逆行列を求め,改めてa~dと置く
   var a_ = d / div;
   var b_ = -b / div;
   var c_ = -c / div;
   var d_ = a / div;
   
   //step2 左から逆回転行列を掛ける
   var a__ = cos(rad)*a_ + sin(rad)*b_;
   var b__ = -sin(rad)*a_ + cos(rad)*b_;
   var c__ = cos(rad)*c_ + sin(rad)*d_;
   var d__ = -sin(rad)*c_ + cos(rad)*d_;

   //step3 楕円の方程式に当てはめ,係数を求める
   //see http://schoolhmath3c.blogspot.jp/2012/02/blog-post_19.html
   //A*x^2 + 2*B*x*y + C*y^2=1
   var A = sq(a__)/sq(r1) + sq(b__)/sq(r2);
   var B = (a__*c__/sq(r1) + b__*d__/sq(r2));
   var C = sq(c__)/sq(r1) + sq(d__)/sq(r2);

   //step4 上記を行列(A B B C)に見立て,固有値を求める
   var D = A + C;
   var E = A * C - sq(B);
   var L = sqrt(sq(D) / 4 - E);
   var F = D / 2 - L;
   var G = D / 2 + L;
   
   //step5 楕円の半径と傾きを求める
   seg.r1 = 1 / sqrt(F);//長径
   seg.r2 = 1 / sqrt(G);//短径

   //step6 楕円の傾きを求める
   //長径と短径は90°で交わることを使って,角度を求める.
   //NOTE:atan2の第一引数が0に近づく(=90°に近づく)程計算結果が狂うので
   //より正確な値が求まる方を使って計算する.
   if(abs(A - F) > abs(A - G)){
    seg.angle = rad2deg(atan2(A - F, -B));
   }else{
    seg.angle = rad2deg(atan2(A - G, -B)) - 90;
   }

   //step7 楕円の向きを求める
   //see http://pisan-dub.jp/doc/2009/20091214001/3_6.html
   //変換行列内に鏡像成分が含まれているかを判定する
   var e;//sin
   var f;//cos
   if(b * d > 0){
    e = b / sqrt(b * b + d * d);
    f = -d / sqrt(b * b + d * d);
   }else if(b * d < 0){
    e = b / sqrt(b * b + d * d);
    f = d / sqrt(b * b + d * d);
   }else if(b == 0){
    e = 0;
    f = 1;
   }else{//d==0
    e = 1;
    f = 0;
   }
   //上三角行列の対角線を計算し,掛け合わせる.
   var g = a * f + c * b;
   var i = -b * e + d * f;
   seg.sweepFlag =  g * i > 0 ? seg.sweepFlag: !seg.sweepFlag;
  }else{
   //逆行列が存在しない
   seg.r1 = 0;
   seg.r2 = 0;
  }
 }
 
 //外部公開関数定義
 return {
  //matrix変換(共通関数)
  matrix: function(d_, a, b, c, d, e, f){
   if(a == undefined){a = 1;}
   if(b == undefined){b = 0;}
   if(c == undefined){c = 0;}
   if(d == undefined){d = 1;}
   if(e == undefined){e = 0;}
   if(f == undefined){f = 0;}
   var segs = ready(d_);
   if(segs == undefined){return "";}
   
   for(var i=0, len=segs.numberOfItems; i<len; i++){
    var seg = segs.getItem(i);
    var x = seg.x;
    var y = seg.y;
    var x1 = seg.x1;
    var y1 = seg.y1;
    var x2 = seg.x2;
    var y2 = seg.y2;
    if(seg.x != undefined){seg.x = a * x + c * y;}
    if(seg.y != undefined){seg.y = b * x + d * y;}
    if(seg.x1 != undefined){seg.x1 = a * x1 + c * y1;}
    if(seg.y1 != undefined){seg.y1 = b * x1 + d * y1;}
    if(seg.x2 != undefined){seg.x2 = a * x2 + c * y2;}
    if(seg.y2 != undefined){seg.y2 = b * x2 + d * y2;}
    switch(seg.pathSegType){
     //円弧については独自の処理が必要.
     case 10: case 11:
      arcMatrix(seg, a, b, c, d);
    }
    switch(seg.pathSegType%2){
     case 0://絶対位置指定
      if(seg.x != undefined){seg.x += e;}
      if(seg.y != undefined){seg.y += f;}
      if(seg.x1 != undefined){seg.x1 += e;}
      if(seg.y1 != undefined){seg.y1 += f;}
      if(seg.x2 != undefined){seg.x2 += e;}
      if(seg.y2 != undefined){seg.y2 += f;}
      break;
     default:
    }
   }
   return getResult();
  },
  
  //path文字列を水平方向に反転させる
  flipX: function(d, width){
   return this.matrix(d, -1, 0, 0, 1, width, 0);
  },
  
  //path文字列を垂直方向に反転させる
  flipY: function(d, height){
   return this.matrix(d, 1, 0, 0, -1, 0, height);
  },

  //平行移動する
  translate: function(d, dx, dy){
   return this.matrix(d, 1, 0, 0, 1, dx, dy);
  },
  
  //パスを回転する(ラジアン)
  rotateRad: function(d, rad, cx, cy){
   if(cx == undefined){cx = 0;}
   if(cy == undefined){cy = 0;}
   this.translate(d, -cx, -cy);
   this.matrix(d, cos(rad), sin(rad), -sin(rad), cos(rad), 0, 0);
   return this.translate(d, cx, cy);
  },
  
  //パスを回転する
  rotate:function(d, deg, cx, cy){
   return this.rotateRad(d, deg2rad(deg), cx, cy);
  },
  
  //パスを拡大縮小する
  scale: function(d, sx, sy){
   return this.matrix(d, sx, 0, 0, sy);
  },
  
  //x軸方向のせん断変形を行う(ラジアン)
  skewXRad: function(d, rad){
   return this.matrix(d, 1, 0, tan(rad), 1);
  },
  //x軸方向のせん断変形を行う
  skewX: function(d, deg){
   return this.skewXRad(d, deg2rad(deg));
  },

  //y軸方向のせん断変形を行う(ラジアン)
  skewYRad: function(d, rad){
   return this.matrix(d, 1, tan(rad), 0, 1);
  },
  //y軸方向のせん断変形を行う
  skewY: function(d, deg){
   return this.skewYRad(d, deg2rad(deg));
  },
  
  //パス文字列を成形する(エラー部を削除する)
  fix: function(d){
   var segs = ready(d);
   if(segs == undefined){return "";}
   var s = [];
   for(var i=0, len=segs.numberOfItems; i<len; i++){
    var seg = segs.getItem(i);
    s.push(seg.pathSegTypeAsLetter);
    //スクリプトから間違って付け加えたプロパティが有るかも知れないので,
    //切片の種類ごとに分けて考える.
    switch(seg.pathSegType){
     case 0://unknown
      break;
     case 1://z
      break;
     case 2://M
     case 3://m
      s.push(seg.x, seg.y);
      break;
     case 4://L
     case 5://l
      s.push(seg.x, seg.y);
      break;
     case 6://C
     case 7://c
      s.push(seg.x1, seg.y1, seg.x2, seg.y2, seg.x, seg.y);
      break;
     case 8://Q
     case 9://q
      s.push(seg.x1, seg.y1, seg.x, seg.y);
      break;
     case 10://A
     case 11://a
      s.push(seg.r1, seg.r2, 
       seg.angle, 
       seg.largeArcFlag ? 1:0, seg.sweepFlag ? 1:0, 
       seg.x, seg.y);
      break;
     case 12://H
     case 13://h
      s.push(seg.x);
      break;
     case 14://V
     case 15://v
      s.push(seg.y);
      break;
     case 16://S
     case 17://s
      s.push(seg.x2, seg.y2, seg.x, seg.y);
      break;
     case 18://T
     case 19://t
      s.push(seg.x, seg.y);
      break;
    }
   }
   return s.join(" ");
  }
 };
})();

使い方


スクリプトからSVGPathElementオブジェクトを取得し,pathUtilsの各メソッドを適用します.
var path = document.querySelector("path:first-child");
pathUtils.matrix(path, 1,3,3,0,100,1);
var path = document.querySelector("path:nth-child(2)");
pathUtils.skewX(path, 30);

役に立たない解説

  • 楕円の変換を行う場合は「線形代数」と呼ばれる数学の知識が必要となります.
  • 行列の固有値,固有ベクトル,またアフィン行列の岩沢分解と言ったものを利用しています.
    (ここらへんちょっとうろ覚え)

もう少しヒントを…

えーもう憶えてないよー

svgでの楕円弧を定義するには
(1)長径・半径
(2)楕円の傾き
(3)楕円弧の向き(時計回り/反時計回り)
の3つを求めなければならない.

そこで次の処理を行う事になる.

・まずは変形後の楕円の方程式を求める.
とっかかりとしては傾きを考えない楕円の方程式
r12/x2+r22/y2=1
から,xとyを変換後の内容で置き換えて
Ax2+2Bxy+Cy2=1
と言った楕円の方程式の一般形を求める.
※元の楕円弧が既に傾いているのでそれを加味する必要がある.

・するとそこから行列
|AB|
|BC|
を考える事が出来て,この行列の固有値と固有ベクトルを計算することが出来る.

・この固有値の逆数が楕円の長径と半径に相当する.→(1)が求まった
・固有ベクトルの向きが楕円の傾きに相当するのでatan関数を使って角度が求まる.→(2)が求まった

・最後の楕円弧の向きは与えられたtransform行列の鏡像成分が含まれているかで値がひっくり返るので,
岩沢分解の公式を使ってその有無を判定する.→(3)が求まった

まあ,数学的な根拠とか確からしさは各自調べてもらうとしても,
実際問題わりといい雰囲気で動作していることからこれで正しいはず.

注意点


あんまりデバックしていないので,バグが有るかも知れません.
スクリプトを使って画像を回転させるといった用途には適しません.変換する際の誤差が蓄積することで徐々にデータが劣化していきます.(transform属性を用いた変形ではこの懸念がありません.)
ライセンス・MITと言う事で.勝手に使っちゃってください.

※後半のfixメソッドって余分じゃね?→途中でぶん投げた名残(汗


バグが見つかったので修正しました.
・無駄な部分を削除しました.
・apiが変わっているのでご注意ください.
map関数に変形したいパスとSVGMatrixオブジェクトを渡すと指定した行列の内容でパスが変形されます.

//transform path by matrix directly.
CustomShapes.map = (function(){
 var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
 var sin = Math.sin, cos = Math.cos;
 var tan = Math.tan, atan2 = Math.atan2, atan = Math.atan;
 var pow = Math.pow, sqrt = Math.sqrt;
 var abs = Math.abs;
 var D = "d";
 function deg2rad(deg){return deg / 180 * Math.PI;}
 function rad2deg(rad){return rad / Math.PI * 180;}
 function sq(val){return val * val;}

 function arcMatrix(seg, a, b, c, d){
  var r1 = seg.r1;
  var r2 = seg.r2;
  var angle = seg.angle;
  var rad = deg2rad(angle);

  //abcdの逆行列の有無を確かめる
  var div = a * d - b * c;
  if(div != 0){
  
   //step1 逆行列を求め,改めてa~dと置く
   var a_ = d / div;
   var b_ = -b / div;
   var c_ = -c / div;
   var d_ = a / div;
  
   //step2 左から逆回転行列を掛ける
   var a__ = cos(rad)*a_ + sin(rad)*b_;
   var b__ = -sin(rad)*a_ + cos(rad)*b_;
   var c__ = cos(rad)*c_ + sin(rad)*d_;
   var d__ = -sin(rad)*c_ + cos(rad)*d_;

   //step3 楕円の方程式に当てはめ,係数を求める
   //see http://schoolhmath3c.blogspot.jp/2012/02/blog-post_19.html
   //A*x^2 + 2*B*x*y + C*y^2=1
   var A = sq(a__)/sq(r1) + sq(b__)/sq(r2);
   var B = (a__*c__/sq(r1) + b__*d__/sq(r2));
   var C = sq(c__)/sq(r1) + sq(d__)/sq(r2);

   //step4 上記を行列(A B B C)に見立て,固有値を求める
   var D = A + C;
   var E = A * C - sq(B);
   var L = sqrt(sq(D) / 4 - E);
   var F = D / 2 - L;
   var G = D / 2 + L;
  
   //step5 楕円の半径と傾きを求める
   seg.r1 = 1 / sqrt(F);//長径
   seg.r2 = 1 / sqrt(G);//短径

   //step6 楕円の傾きを求める
   //長径と短径は90°で交わることを使って,角度を求める.
   //NOTE:atan2の第一引数が0に近づく(=90°に近づく)程計算結果が狂うので
   //より正確な値が求まる方を使って計算する.
   if(abs(A - F) > abs(A - G)){
    seg.angle = rad2deg(atan2(A - F, -B));
   }else{
    seg.angle = rad2deg(atan2(A - G, -B)) - 90;
   }

   //step7 楕円の向きを求める
   //see http://pisan-dub.jp/doc/2009/20091214001/3_6.html
   //変換行列内に鏡像成分が含まれているかを判定する
   var e;//sin
   var f;//cos
   if(b * d > 0){
    e = b / sqrt(b * b + d * d);
    f = -d / sqrt(b * b + d * d);
   }else if(b * d < 0){
    e = b / sqrt(b * b + d * d);
    f = d / sqrt(b * b + d * d);
   }else if(b == 0){
    e = 0;
    f = 1;
   }else{//d==0
    e = 1;
    f = 0;
   }
   //上三角行列の対角線を計算し,掛け合わせる.
   var g = a * f + c * b;
   var i = -b * e + d * f;
   seg.sweepFlag =  g * i > 0 ? seg.sweepFlag: !seg.sweepFlag;
  }else{
   //逆行列が存在しない
   seg.r1 = 0;
   seg.r2 = 0;
  }
 }
 var X = "x", Y = "y", X1 = "x1", Y1 = "y1", X2 = "x2", Y2 = "y2";
 return function(_d, m){
  var a = m.a, b = m.b, c = m.c, d = m.d, e = m.e, f = m.f;
  path.setAttribute(D, _d);
  var segs = path.pathSegList;
  var cx = 0, cy = 0;//current position.
  var seg, newSeg;
  for(var i=0, len=segs.numberOfItems; i<len; i++){
   seg = segs.getItem(i);
   if(seg.pathSegType%2){//abs
    cx = X in seg ? seg.x : cx;
    cy = Y in seg ? seg.y : cy;
   }else{//rel
    cx += X in seg ? seg.x : 0;
    cy += Y in seg ? seg.y : 0;
   }
   //replace H,h,V,v to L,l command
   newSeg = undefined;
   switch(seg.pathSegType){
    case 12://H
     newSeg = path.createSVGPathSegLinetoAbs(seg.x, cy);
     break;
    case 14://V
     newSeg = path.createSVGPathSegLinetoAbs(cx, seg.y);
     break;
    case 13://h 
     newSeg = path.createSVGPathSegLinetoRel(seg.x, 0);
     break;
    case 15://v
     newSeg = path.createSVGPathSegLinetoRel(0, seg.y);
     break;   
   }
   if(newSeg){
    segs.replaceItem(newSeg, i);
    seg = newSeg;
   }
   //map position.
   var x = X in seg ? seg.x: 0, 
    y = Y in seg ? seg.y: 0, 
    x1 = X1 in seg ? seg.x1: 0, 
    y1 = Y1 in seg ? seg.y1: 0, 
    x2 = X2 in seg ? seg.x2: 0, 
    y2 = Y2 in seg ? seg.y2: 0;
   if(X in seg){seg.x = a * x + c * y;}
   if(Y in seg){seg.y = b * x + d * y;}
   if(X1 in seg){   
    seg.x1 = a * x1 + c * y1;
    seg.y1 = b * x1 + d * y1;
   }
   if(X2 in seg){
    seg.x2 = a * x2 + c * y2;
    seg.y2 = b * x2 + d * y2;
   }
   switch(seg.pathSegType){
    case 10: case 11:
     arcMatrix(seg, a, b, c, d);
   }
   if(i == 0 || seg.pathSegType%2 == 0){
    //absolute position.
    if(X in seg){seg.x += e;}
    if(Y in seg){seg.y += f;}
    if(X1 in seg){
     seg.x1 += e;
     seg.y1 += f;
    }
    if(X2 in seg){
     seg.x2 += e;
     seg.y2 += f;
    }
   }
  }
  return path.getAttribute(D);
 };
})();

0 件のコメント:

コメントを投稿