0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

曲線上に等間隔で点を配置する

0
Posted at

はじめに

 点を等間隔に配置するのは大変です。普通にコードを書くと面倒です。そこで関数を作りました。

wrwfwfwfw.png

ベクトルの列の点の間隔を操作する関数です。

コード全文

p5.js 2.2.3

// evenlySpacing

function setup(){
	createCanvas(800, 800);
	background(0);
	noStroke();
	const vs = [];
	for(let i=20; i<1900; i++){
		const t = i*Math.PI*2/200;
		const x = 6*t*Math.cos(t);
		const y = 6*t*Math.sin(t);
		vs.push(createVector(x, y));
	}
	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {partition:1023});
	console.log(vs.length); // 256.
	colorMode(HSL,1,1,1,1);
	translate(width/2,height/2);
	for(const v of vs){
		fill(0.35,1,1-v.mag()/400);
		circle(v.x, v.y, 8);
	}
}

function evenlySpacing(points, options = {}){
  const {partitionType = "custom", partition, minLength = 1, closed = false, showDetail = false} = options;

  // closedの場合はおしりに頭を付ける
  // そして最後におしりを外す
  const q = points.slice();
  if(closed){ q.push(q[0].copy()); }

  // まず全長を計算する
  let totalLength = 0;
  let N = 0;
  let l = 0;
  for(let i=0; i<q.length-1; i++){ totalLength += q[i].dist(q[i+1]); }

  if(partitionType !== 'custom'){
	// partitionTypeにcustom以外の値が指定されている場合、auto,even,oddに応じてNとlを決める。
	const pointCount = points.length;
	N = (closed ? pointCount : pointCount-1);
	switch(partitionType){
	  case "auto": break;
	  case "even":
		if(N % 2 === 1){ N++; } break;
	  case "odd":
		if(N % 2 === 0){ N++; } break;
	  default:
		console.error("use auto/even/odd/custom type.");
		return null;
	}
	l = totalLength/N;
  }else if(partition !== undefined){
	// 'custom'で、かつpartitionが定義されている場合は、それでNを決めてlはそれとtotalLengthで決める
	N = Math.max(1, Math.floor(partition));
	l = totalLength/N;
  }else{
	// partitionも未定義の場合、minLengthを使う。デフォルトは1. これでNを決めてそこからlを決める。
	N = Math.floor(totalLength/minLength) + 1;
	l = totalLength/N;
  }

  // lを基準の長さとして分けていく。まず頭を採用する。次の点と差を取る。これの累積を
  // 取っていってlを超えるようならそこで比率を計算しlerpして加えて差分を新しい
  // elapsedとする。
  let elapsed = 0;
  const prev = q[0].copy();
  const next = createVector();
  const result = [q[0]];
  for(let i=1; i<q.length; i++){
	next.set(q[i]);
	const d = prev.dist(next);
	if(elapsed + d < l){
	  elapsed += d;
	  prev.set(next);
	  continue;
	}
	// prevとnextをratio:(l-elapsed)/dで分割。

	// この時点でelapsedはlより小さいことが想定されている。が...
	// 厳密にやるならelapsed+d>=lであるからして
	// (l*m-elapsed)/dによるlerpをelapsed+d>=m*lであるすべてのmに対して実行し
	// elapsedにd-m*lを足して終わりにする. m*l <= elapsed+d < (m+1)*lなので
	// 0<=elapsed+d-m*l<lである。
	// 数学のお時間です。
	// mの想定される上限値というのはおおよそ(elapsed+d)/lですが、
	// elapsedはl以下が想定されているし、dはtotalLength以下。
	// そしてd/lというのはtotalLength/lで抑えられる。これは何か。Nである。つまり?
	// mがN+1より大きくなることは「ありえない」。安全のためN+2をとっても、
	// せいぜいそのくらい。だからm>N+2になったらbreakしていい。
	// 無限ループにはならない。その場合はもうelapsedを0にしよう。
	let m=1;
	while(elapsed + d >= m*l){
	  const newPoint = prev.lerp(next, (m*l - elapsed)/d).copy();
	  result.push(newPoint);
	  m++;
	  if(m > N+2) break;
	}
	elapsed += d-(m-1)*l;
	if(m > N+2) elapsed = 0;
	prev.set(next);
  }
  // 最後の点が入ったり入んなかったりするのがめんどくさい。
  // そこで
  // 最後の点についてはもう入れてしまって
  // 末尾とその一つ前がl/2より小さいときにカットする。
  result.push(q[q.length-1].copy());
  if(result[result.length-1].dist(result[result.length-2]) < l/2){
	result.pop();
  }
  // closedの場合は末尾をカットする
  if(closed){ result.pop(); }

  points.length = 0;
  points.push(...result);

  if(showDetail){
	let minL = Infinity;
	let maxL = -Infinity;
	for(let i=0; i<points.length; i++){
	  if(!closed && i===points.length-1) break;
	  const d = points[i].dist(points[(i+1)%points.length]);
	  minL = Math.min(d, minL);
	  maxL = Math.max(d, maxL);
	}
	console.log(`minL:${minL}, maxL:${maxL}`);
	// showDetailの場合はきちんと計算して返す
	return {minL, maxL};
  }
  // そうでない場合は単純にlを返す。まあそこまで外れてはいない。
  return {minL:l, maxL:l};
}

使い方

 第一引数のpointsはベクトルの集合です。等間隔にしたいベクトルの列を送ります。オブジェクトや数列でもいいんでしょうが、中で作るのが面倒だし、操作ができないのでベクトル前提の方が、コードがすっきりします。

オプションはいくつかあります。

partitionType

 デフォルトはcustomです。まずこれがauto,even,oddの場合を考えます。これら3つのタイプというのは「点の数を大きく変えない」という意味で共通しています。なお基本的なこととして、点は順番に並んでいる必要があるので注意してください。
 ざっくり言うとまずautoが「点の数を変えずに間隔を等しくしようとする」で、evenが「偶数になるように調整」、oddが「奇数になるように調整」です。なおこの数字はclosedに左右されます。ここでいうeven/oddは「間隔の個数」なので、デフォルトのnon-closedの場合は点の個数が1つずつ大きくなります。closedの場合は偶奇が一致します。
 しかしauto,even,oddは普通に使うのはあんま推奨されないです。理由はサンプルが少ないとこの関数はうまく機能しないからです:

function setup(){
	createCanvas(800, 800);
	background(0);
	noStroke();
	const vs = [];

  for(let k=0; k<100; k++){
	 vs.push(createVector(40+random(720),400));
  }
	vs.sort((u,v)=>u.x-v.x);
	
	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {partitionType:'even'});
	console.log(vs.length); // 256.
	
	for(const v of vs){
		fill(255);
		circle(v.x,v.y,6);
	}
}

wfsww1111111.png

いずれ改善するかもしれませんが、他の便利な方法があるので逆に廃止されるかもしれないです(わかんない)。

partition

 partitionTypeが未定義で「custom」である場合、partitionが指定されていれば、それに応じた処理となります。これもまた「間隔の個数」です。closedでは点の個数と一致するんですが、non-closedの場合これが間隔の個数ですから、点の個数はこれ+1となります。これは精度が重要です。たとえば点の個数100個で長さ720のエリアに等間隔に配置する場合:

function setup(){
	createCanvas(800, 800);
	background(0);
	noStroke();
	const vs = [];

  for(let k=0; k<100; k++){
	 vs.push(createVector(40+random(720),400));
  }
	vs.sort((u,v)=>u.x-v.x);
	
	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {partition:100});
	console.log(vs.length); // 256.
	
	for(const v of vs){
		fill(255);
		circle(v.x,v.y,6);
	}
}

wdqwfwq.png

ですが、ここを600にすると:

  for(let k=0; k<600; k++){
	 vs.push(createVector(40+random(720),400));
  }
	vs.sort((u,v)=>u.x-v.x);

ww2f2f2223222.png

綺麗に並びました。サンプルの点の間隔をそのまま使うので、サンプルが多いほど、結果は綺麗になります。なおnon-closedなのでpartitionが100の場合点の個数は101個となります。

minLength

 partitionTypeも、partitionも未定義の場合、minLengthが使われます。なおこれはデフォルトが1なので、これすらない場合間隔1で最適化されます。長さ指定です。
 これもサンプルの個数が重要です。たとえばminLength:10の点600個でやった場合はこちらです:

function setup(){
	createCanvas(800, 800);
	background(0);
	noStroke();
	const vs = [];

  for(let k=0; k<600; k++){
	 vs.push(createVector(40+random(720),400));
  }
	vs.sort((u,v)=>u.x-v.x);
	
	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {minLength:10});
	console.log(vs.length); // 256.
	
	for(const v of vs){
		fill(255);
		circle(v.x,v.y,6);
	}
}

wdwfwfw22w.png

点100個だとこうはいかないです。サンプルが多いほど結果は綺麗になります。

closed

 デフォルトではfalse(non-closed)となっています。閉曲線に適用する場合はtrueにしてください。閉曲線の例も参考までに上げておきます。これはリサージュ曲線と呼ばれるものです。若干長いので、点の個数を2000にしました。

function setup(){
	createCanvas(800, 800);
	background(0);
	noStroke();
	const a = [];
	const vs = [];

  for(let k=0; k<2000; k++){
	 a.push(random(TAU));
  }
	a.sort();
	for(let k=0; k<2000; k++){
		vs.push(createVector(400+300*cos(3*a[k]),400+300*sin(2*a[k])));
	}
	
	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {minLength:10, closed:true});
	console.log(vs.length); // 256.
	
	for(const v of vs){
		fill(255);
		circle(v.x,v.y,6);
	}
}

wsfqwfqwfwqw.png

点の個数は459個になりました。なおclosedなので、partitionを459にすると同じような結果になります。つまり長さはおおよそ4590で、まあ2000個くらい無いと厳しいですね...ちなみにリサージュ曲線の長さの計算は基本的に積分では無理です。不可能積分というやつです。円弧の長さのようには出ません。

showDetail

 デフォルトではfalseです。これをtrueにすると、最小長さと最大長さを出してくれます。上記のリサージュの場合、

minL:9.169369540439204, maxL:10.816386096564127

なので最大で1.8くらいの開きがあります。ちなみに3000個でやった場合:

minL:9.903546201069581, maxL:10.082207430771218

なので改善されます。そんな感じで運用します。

WebGLの場合

 単純にベクトルと距離しか出てこないので、当然ですが3Dにも使えます。最後にWebGLの例を挙げておきます。

function setup(){
	createCanvas(800, 800, WEBGL);
	background(0);
	noStroke();
	const a = [];
	const vs = [];

  for(let k=0; k<3000; k++){
	 a.push(random(TAU));
  }
	a.sort();
	for(let k=0; k<3000; k++){
		vs.push(createVector(300*sin(3*a[k]),300*sin(4*a[k]), 300*sin(5*a[k])));
	}

	// non-closedでpartition:255の場合、間隙が255で、要するに256個になる。
	evenlySpacing(vs, {minLength:10, closed:true, showDetail:true});
	console.log(vs.length); // 256.

	draw = () => {
		background(0);
		lights();
		orbitControl();
	for(const v of vs){
		fill(255);
		push();
		translate(v.x,v.y,v.z);
		sphere(5);
		pop();
	}
	}
}

wrwr3egege.png

個数は915個です。インスタンシングとかやると速いかもしれないですね。ドローコール900回はちょっと多すぎるので。こういうときorbitControlのありがたみを感じます。あれはどちらかというとカメラワーク用というよりはこの手のテスト用なんですよね(裏側とか見たい場合に使う)。

おわりに

 所詮素人仕事なので、いい感じに見えればいい、という具合でやっています。コーディングは肩ひじ張らず気楽にやるのが一番楽しいですね。
 ここまでお読みいただいてありがとうございました。

余談

 これを走らせるといちいちエラーが出ます。原因はここです:

  // lを基準の長さとして分けていく。まず頭を採用する。次の点と差を取る。これの累積を
  // 取っていってlを超えるようならそこで比率を計算しlerpして加えて差分を新しい
  // elapsedとする。
  let elapsed = 0;
  const prev = q[0].copy();
  const next = createVector();
  const result = [q[0]];
In 1.x, createVector() was a shortcut for createVector(0, 0, 0). In 2.x, p5.js has vectors of any dimension, so you must provide your desired number of zeros. Use createVector(0, 0) for a 2D vector and createVector(0, 0, 0) for a 3D vector.

1.x では、createVector() は createVector(0, 0, 0) のショートカットでした。2.x では、p5.js は任意の次元のベクトルを扱うことができるため、必要な数のゼロを指定する必要があります。2 次元ベクトルには createVector(0, 0) を、3 次元ベクトルには createVector(0, 0, 0) を使用してください。

 これはどちらかというとコーディング哲学の話になります。nextはこれの直後のforループ内で毎回最初に特定のベクトルで初期化されるんですが、最初の時点では特に何にも設定されていない純粋な「次」です。適切な設定すべき値がありません。それで空にしているんですが、p5の2系はそれを許さない作りになっています。しかしまあ無視して大丈夫です。

 文言を読めばこの場合に設定すべき適切な値が無いことを理解できると思います。evenlySpacingはdimension freeです。2次元でも、3次元でも使います。おそらく用途が無いですが4次元以上でも使えます(有限次元距離空間の議論なので)。0の個数なんか決められるわけがありません。まあ0でも入れればいいんですがダサいのでやりたくないです(その0を使う機会がない)。prevのcopyを設定するのも不適切です。なんでもいいというならありですが、使わない値をわざわざ設定したくないです。それで空っぽにしています。好きにコードを書きたいのです。

 気になる人はこうしてください:

  let elapsed = 0;
  const prev = q[0].copy();
  const next = createVector(0);
  const result = [q[0]];

自分は自作のベクトルクラスでやってるので、普通に()にしています。

余談は以上です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?