1
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?

昇降引き出しの構想から始めたDIY設計アプリ

1
Posted at

私の部屋は狭いので、できるだけスペースを節約したい。そこから思いついたのが、引き出しを上下に動かす「昇降引き出し」でした。

インターネットで調べると、すでにそうした製品はありました。ただ、自分には手が出ない価格でした。

それなら、油圧やリニアレールを使って、自分で作れないだろうか。

もちろん、作れそうだと思うことと、実際に動くものを作れることは別です。家具を仮想環境で組み立てて、仕組みを試せるWebアプリがあればいい。物理的な動作もそこで確かめられたら、自作を検討する助けになる。そう考えて作り始めたのが、Let’s DIYです。

この記事では、そんな構想から始めたアプリと、実装レビューで見つかった問題、そして「つながった部材をまとめて動かす」ための設計を紹介します。

1. 作りたかったのは、家具の仕組みを試せる場所

出発点は、昇降引き出しを自作できるか、仮想環境で試したいという思いです。最終的には物理的な動作も確かめたいのですが、その前に、部材を置き、つなぎ、家具の形にする操作が必要になります。

Let’s DIYは、その土台となる、ブラウザ上で木材を作成し、3D空間に配置・加工するDIY設計アプリとして開発しています。React・TypeScript・React Three Fiberを使っています。

最初の設計では、初心者が説明書なしでも、板を3枚置いて箱形にし、穴を開け、保存して開き直せることを目標にしました。まずは家具を形にして試せる入口を作る、という考え方です。

実際の編集画面

以下は、残っていたローカルビルドを2026-10-18に起動し、撮影用の部材を新たに配置した画面です。当時の開発途中のスクリーンショットではありません。

まず、底板・背板・側板の3枚を作り、位置と回転を数値で指定して配置しました。左に部材一覧、中央に3D表示、右に寸法・位置・回転の設定が並びます。

底板・背板・側板を3D空間に配置したLet’s DIYの編集画面

続いて、底板に直径60mmの貫通する丸穴を追加しました。加工後は3D表示に穴が現れ、加工一覧にも設定が残ります。

底板へ直径60mmの丸穴を追加した加工画面

この撮影用モデルで確認したのは、木材の生成・配置・丸穴加工です。金物の接続や昇降動作は含めていません。

今回紹介するのは、その中の金物やジョイントでつながった組立品を、移動・回転して配置し直す処理です。昇降機構そのものや、荷重・油圧の物理検証までできたという話ではありません。

2. 動く家具を扱うには、「位置」の意味から変える必要があった

開発当初の分担は、Claude Codeが設計・評価、Codexが実装・バージョン管理です。途中ではClaude Codeも実装に参加しています。設計上の判断やレビュー結果はMarkdownに残していました。

今回読み返した docs/REVIEW_LOG.md には、次の開発経過が記録されています。

レビュー日 その時点で扱ったこと 記録された確認内容
2026-09-22 木材の生成・配置・加工・保存 生成から面スナップ、加工、Undo/Redo、自動保存までの操作
2026-09-23 Ver.2の可動部品 2枚の部材とヒンジを作り、0〜90度の開閉とUndo/Redoを確認。自動テスト432件と記録

この経過を読むと、部材を並べるアプリから動く家具を扱うアプリへ進む際に、データ構造と履歴処理まで見直していたことが分かります。以下は当時のレビュー記録と、現在残っている実装を照合した内容です。

形状と、画面に表示する姿勢を分ける

板を一枚ずつ置いている間は、Part.transform をそのまま画面上の位置・回転として扱えます。しかし、ヒンジの角度によって扉が動くようになると、保存した配置と、今表示したい姿勢を分ける必要があります。

Ver.2では、Part.transform を rest pose(すべてのjointの値が0のときの基準姿勢) とし、表示する姿勢を solvePoses() で計算する構成になりました。

保存する情報:部材の基準姿勢 + jointの値
画面に出す姿勢:基準姿勢とjointの値から計算
部材の形状:寸法と加工内容から生成

たとえば扉を開いても、板の寸法や穴の形は変わりません。そこで、形状のキャッシュを判定する hashPartShape() に位置・回転を含めず、描画側では計算済みの姿勢を適用しています。

9月23日のレビューには、ヒンジを0〜90度の6段階で動かし、形状を再構築する [geometry] build ログが出なかったと記録されています。これは当時の確認結果です。今回追加したスクリーンショットでは、木材の生成・配置・丸穴加工までを操作しています。

関節は動くのに、Undoの履歴に残らなかった

可動部品を足した際には、履歴処理にも不具合が出ていました。

ドラッグ中の変更は一時状態として更新し、操作が終わったときに commitTransient() で一つの履歴へ確定します。ところが、それまでの処理は部材の parts だけを写しており、jointだけを変更した操作が履歴に残りませんでした。

レビュー記録のB-06では、変更対象のフィールドをすべて写す修正が報告されています。現存コードでも、parts に加えて次のフィールドを確定する処理を確認できました。

draft.parts = final.parts as typeof draft.parts;
draft.joints = final.joints as typeof draft.joints;
draft.hardware = final.hardware as typeof draft.hardware;
draft.gasSprings = final.gasSprings as typeof draft.gasSprings;

変更を履歴に確定する処理からの抜粋です。新しいデータを追加したときは、「画面が動くか」に加え、その変更がUndo/Redoで往復できるかも確認する必要がありました。

設計書の式にも、符号の取り違えがあった

面スナップでは、2枚の板の面を合わせ、指定した分だけ隙間を空けます。ここでも、設計書の式がレビューで修正されています。

固定側の外向き法線を nB、その面上の点を pB とすると、正の gap で隙間を作る目標点は pB + nB * gap です。そのため、動かす側の面上の点 pA から法線方向へ移動する量は次になります。

d = dot(pB - pA, nB) + gap

当初の設計書は最後が - gap になっていました。Codexからの確認を受け、Claude Codeが符号を訂正したことが記録されています。現存実装も + gap です。

この符号なら、gap = 0 で密着、正なら隙間、負ならめり込み、という意味が揃います。実装だけでなく、設計書の式と操作の意味を突き合わせるレビューが必要だった例です。

こうした見直しを踏まえ、次は配置編集で「どの部材を一緒に動かすか」を考えます。ここから先の処理は、扉を開く操作と、組立品そのものを置き直す操作を区別するためのものです。

3. 板を一枚動かす処理では、組立品を動かせない

板を一枚ずつ並べる段階なら、選択した板の座標を変えれば移動できます。ところが、金具や丁番で部材をつなぐと、「どこまで一緒に動かすか」を決める必要が出てきます。

選んだ板の座標だけを更新すると、接続先の部材が元の場所に残り、組み立てた配置が崩れてしまいます。家具として組み合わせたものを置き直すには、選択部材より広い範囲を扱わなければなりません。

そこで、処理を「接続から一緒に動かす範囲を決める」「全体へ同じ変換をかける」の2段階に分けます。後半では、この仕組みを描画環境なしのJavaScriptでも試せるようにしています。

たとえば、固定金具でつないだ板A・Bと、丁番で接続された板Cがあるとします。

Aを選んで組立品を移動するなら、B・Cも一緒に動かします。接続されていないDは、その場に残します。

ここで行いたいのは、組立品を置き直す操作です。丁番を曲げて扉を開く操作とは、動かす範囲の決め方が異なります。 配置の編集では丁番の両側をまとめて動かし、関節を動かすときは固定側・可動側を区別する必要があります。

実装上の対応

本体では、次の処理に分けています。

処理 実コードの関数 役割
接続範囲を集める hardwareConnectedGroup() 部材IDを起点に、金物・ジョイントの接続をたどる
ロックを調べる movableConnectedGroup() 接続範囲内にロックされた部材があれば操作を止める
全体を変換する transformConnectedParts() 選択部材の変換差分を、同じ範囲へ適用する

接続探索は src/core/hardware/brackets.ts、操作の入口と変換は src/store/commands/transformCommands.ts にあります。

4. 一緒に動かす範囲を、選択状態だけで決めない

選択しているのはAだけでも、実際に動かしたいのはA・B・Cの組立品です。複数選択を利用者に毎回やり直してもらうのではなく、接続データから範囲を求めます。

部材をグラフの頂点、金物やジョイントによる接続を辺と考えます。この配置操作ではどちら側から選んでも同じ組立品を動かしたいため、辺を双方向に登録します。

探索の要点は、訪問済みの部材を Set へ入れることです。

const seen = new Set([selectedId]);
const pending = [selectedId];

while (pending.length) {
  const id = pending.pop();
  for (const next of adjacency.get(id)) {
    if (seen.has(next)) continue;
    seen.add(next);
    pending.push(next);
  }
}

これならA→B→Cとつながっていても、A→B→C→Aという輪があっても、同じ部材を何度も処理せずに済みます。複数の金物で同じ部材どうしが接続されている場合も、変換する対象は一度だけ集まります。

5. 座標を同じ値にするのではなく、同じ変換をかける

対象を集めても、すべての部材に同じ座標を設定すると一か所へ重なってしまいます。選択部材がどれだけ移動・回転したかを求め、その差分を各部材へ適用します。

本体の計算の中心は次の式です。読みやすいように改行しています。

const delta = composeRigid(
  rigidFromTransform(target),
  invertRigid(rigidFromTransform(source.transform)),
);

source.transform は操作前、target は操作後の選択部材の姿勢です。式で書くと、次のようになります。

差分 Δ = 操作後の姿勢 × 操作前の姿勢の逆変換
各部材の新しい姿勢 = Δ × 各部材の元の姿勢

全体に同じ差分をかければ、部材どうしの位置関係を保ったまま移動・回転できます。

回転では、中心も共通にする

後半のサンプルでは、操作を「共通の中心cで回す」「その後tだけ移動する」という形で渡します。部材の位置pに対する計算は次のとおりです。

p' = c + R(p - c) + t
向き' = R × 向き

R は回転行列です。このサンプルは列ベクトル・ワールド座標で計算し、行列の配列は行優先で並べています。

各部材をそれぞれの中心で回すと、個々の向きだけが変わります。組立品の配置を保つには、位置も含めて共通の中心・共通の回転で変換する必要があります。

6. ロックの判定は、最初の部材を動かす前に行う

A自体がロックされていなくても、接続先のCがロックされていたら、この組立品は動かしません。

探索しながら順に座標を書き換えると、Cのロックに気づくまでにA・Bだけが動いてしまいます。そこで、処理順を固定します。

  1. 接続された部材をすべて集める。
  2. その範囲にロックがないか確認する。
  3. 全部材の変換結果を計算する。
  4. 計算に成功した結果を反映する。

本体では、操作前の確認で警告を出し、ロックされた部材を含む操作を拒否します。接続されていないDのロックは、A・B・Cの操作には影響しません。

7. 描画なしの小さなサンプルで確かめる

ここからは、上の仕組みを追いやすくするために作ったJavaScriptのサンプルです。アプリ本体のコードをそのまま取り出したものではありません。 本体の「接続範囲を求める」「共通の変換をかける」「ロックで拒否する」に対応する処理だけを、配列で試せるようにしています。

Windows・Node.js v24.15.0で確認しました。追加パッケージは不要です。次の2ファイルを同じフォルダに保存します。

assembly_graph.mjs(接続探索・剛体変換の全文)
assembly_graph.mjs
// Anonymous teaching model. World-space rigid transforms, not a physics solver.
export const IDENTITY = Object.freeze([1, 0, 0, 0, 1, 0, 0, 0, 1]);
const KINDS = new Set(['fixed', 'hinge', 'rail']);
const EPS = 1e-8;
const finite = (a, size) => Array.isArray(a) && a.length === size && a.every(Number.isFinite);
export const add = (a, b) => a.map((v, i) => v + b[i]);
export const subtract = (a, b) => a.map((v, i) => v - b[i]);
export const multiplyVector = (m, v) => [0, 1, 2].map(r =>
  m[3 * r] * v[0] + m[3 * r + 1] * v[1] + m[3 * r + 2] * v[2]);
export const multiplyMatrix = (a, b) => Array.from({ length: 9 }, (_, i) => {
  const r = Math.floor(i / 3), c = i % 3;
  return a[3 * r] * b[c] + a[3 * r + 1] * b[c + 3] + a[3 * r + 2] * b[c + 6];
});
export const transpose = m => [0, 1, 2].flatMap(r => [0, 1, 2].map(c => m[3 * c + r]));
function validRotation(m) {
  if (!finite(m, 9)) return false;
  const orthogonal = multiplyMatrix(transpose(m), m);
  const determinant = m[0] * (m[4] * m[8] - m[5] * m[7])
    - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]);
  return Math.abs(determinant - 1) < EPS
    && orthogonal.every((v, i) => Math.abs(v - IDENTITY[i]) < EPS);
}
export function axisAngle(axis, radians) {
  if (!finite(axis, 3) || !Number.isFinite(radians)) throw new Error('Invalid axis or angle');
  const length = Math.hypot(...axis);
  if (!length || !Number.isFinite(length)) throw new Error('Axis must have finite nonzero length');
  const [x, y, z] = axis.map(v => v / length);
  const c = Math.cos(radians), s = Math.sin(radians), d = 1 - c;
  return [c + x * x * d, x * y * d - z * s, x * z * d + y * s,
    y * x * d + z * s, c + y * y * d, y * z * d - x * s,
    z * x * d - y * s, z * y * d + x * s, c + z * z * d];
}
export function connectedIds(parts, connections, selectedId) {
  const adjacency = new Map();
  for (const part of parts) {
    if (typeof part.id !== 'string' || !part.id || adjacency.has(part.id)) throw new Error('Invalid or duplicate part ID');
    if (!finite(part.position, 3) || !validRotation(part.orientation)
      || typeof part.locked !== 'boolean') throw new Error('Invalid part pose or lock');
    adjacency.set(part.id, new Set());
  }
  if (!adjacency.has(selectedId)) throw new Error('Selected part does not exist');
  for (const edge of connections) {
    if (!KINDS.has(edge.kind) || !adjacency.has(edge.a) || !adjacency.has(edge.b)) throw new Error('Invalid connection');
    adjacency.get(edge.a).add(edge.b);
    adjacency.get(edge.b).add(edge.a);
  }
  const seen = new Set([selectedId]), pending = [selectedId];
  while (pending.length) {
    const id = pending.pop();
    for (const next of adjacency.get(id)) {
      if (!seen.has(next)) { seen.add(next); pending.push(next); }
    }
  }
  return [...seen].sort();
}
export class LockedAssemblyError extends Error {
  constructor(ids) { super('Assembly contains locked parts: ' + ids.join(', ')); this.name = 'LockedAssemblyError'; }
}
export function transformAssembly(parts, connections, selectedId, {
  translation = [0, 0, 0], rotation = IDENTITY, pivot = [0, 0, 0],
} = {}) {
  if (!finite(translation, 3) || !finite(pivot, 3) || !validRotation(rotation)) throw new Error('Invalid rigid transform');
  const movedIds = connectedIds(parts, connections, selectedId), group = new Set(movedIds);
  const locked = parts.filter(p => group.has(p.id) && p.locked).map(p => p.id).sort();
  if (locked.length) throw new LockedAssemblyError(locked);
  // Compute a complete result first. The caller commits only if this succeeds.
  const result = parts.map(part => {
    if (!group.has(part.id)) return part;
    const position = add(add(pivot, multiplyVector(rotation, subtract(part.position, pivot))), translation);
    const orientation = multiplyMatrix(rotation, part.orientation);
    if (!finite(position, 3) || !validRotation(orientation)) throw new Error('Transform overflow');
    return { ...part, position, orientation };
  });
  return { parts: result, movedIds };
}
export function sampleScene() {
  return {
    parts: [
      { id: 'A', position: [0, 0, 0], locked: false },
      { id: 'H', position: [2, 0, 0], locked: false },
      { id: 'B', position: [2, 2, 0], locked: false },
      { id: 'C', position: [4, 2, 0], locked: false },
      { id: 'D', position: [5, -1, 0], locked: true },
    ].map(p => ({ ...p, orientation: [...IDENTITY] })),
    connections: [{ a: 'A', b: 'H', kind: 'fixed' }, { a: 'H', b: 'B', kind: 'hinge' }, { a: 'B', b: 'C', kind: 'rail' }],
  };
}
try_assembly.mjs(移動・回転・ロックの動作確認)
try_assembly.mjs
import assert from 'node:assert/strict';
import { IDENTITY, axisAngle, transformAssembly, LockedAssemblyError } from './assembly_graph.mjs';

const parts = [
  { id: 'A', position: [0, 0, 0], locked: false },
  { id: 'B', position: [2, 0, 0], locked: false },
  { id: 'C', position: [2, 2, 0], locked: false },
  { id: 'D', position: [5, -1, 0], locked: true },
].map(part => ({ ...part, orientation: [...IDENTITY] }));
const connections = [
  { a: 'A', b: 'B', kind: 'fixed' },
  { a: 'B', b: 'C', kind: 'hinge' },
];
const before = JSON.stringify(parts);
const operation = {
  rotation: axisAngle([0, 0, 1], Math.PI / 2),
  pivot: [0, 0, 0],
  translation: [1, 1, 0],
};
const result = transformAssembly(parts, connections, 'A', operation);
assert.deepEqual(result.movedIds, ['A', 'B', 'C']);
console.log('moved: ' + result.movedIds.join(', '));
const round = vector => vector.map(value => Math.round(value * 1e6) / 1e6);
const expected = [[1, 1, 0], [1, 3, 0], [-1, 3, 0], [5, -1, 0]];
for (const [index, part] of result.parts.entries()) {
  assert.deepEqual(round(part.position), expected[index]);
  console.log(`${part.id}: ${JSON.stringify(parts[index].position)} -> ${JSON.stringify(round(part.position))}`);
}
const distance = (a, b) => Math.hypot(...a.map((value, index) => value - b[index]));
for (const [a, b] of [[0, 1], [1, 2], [0, 2]]) {
  assert.ok(Math.abs(distance(parts[a].position, parts[b].position)
    - distance(result.parts[a].position, result.parts[b].position)) < 1e-8);
}
const locked = parts.map(part => part.id === 'C' ? { ...part, locked: true } : part);
const lockedBefore = JSON.stringify(locked);
assert.throws(() => transformAssembly(locked, connections, 'A', operation), LockedAssemblyError);
assert.equal(JSON.stringify(locked), lockedBefore);
console.log('locked C: operation rejected');
assert.equal(JSON.stringify(parts), before);
console.log('input unchanged: true');
node try_assembly.mjs

この例ではA・B・Cを、原点を中心にZ軸回りに90度回し、その後 (1, 1, 0) だけ移動します。接続していないDは移動しません。2026-10-18の実行結果は次のとおりです。

moved: A, B, C
A: [0,0,0] -> [1,1,0]
B: [2,0,0] -> [1,3,0]
C: [2,2,0] -> [-1,3,0]
D: [5,-1,0] -> [5,-1,0]
locked C: operation rejected
input unchanged: true

A・B・Cの座標は変わりましたが、部材間の距離は変わっていません。サンプル内の assert で、期待した座標と距離が保たれることを確認しています。

続いてCをロックすると、Aを選んだ操作も拒否されます。失敗時に途中まで書き換わらないことと、成功時も入力配列そのものを変更しないことを確認しています。

この共通モジュールには別途19件の試験を実施しています。循環や複数経路、関係のないロック、逆変換などを確認し、うち1件では4部材のグラフ・選択・ロックの組み合わせ1,280条件を調べました。元アプリ全体の試験結果とは別の、この記事用モジュールの検証です。

8. 「組立品の配置」と「関節の動作」は分ける

今回の接続探索では、固定金具だけでなく丁番やレールもたどります。配置を変える操作では、接続されたまとまりを一つの組立品として扱いたいからです。

扉を開ける、引き出しをスライドさせる、といった操作まで同じ範囲を動かすと、固定側まで動いてしまいます。その場合は、関節の軸、可動側、可動範囲、閉じた接続の拘束を別に扱う必要があります。

また、サンプルはすべての座標を同じワールド空間に置いています。既存のシーングラフへ組み込む場合は、親と子の両方に同じ変換を適用して二重に動かさないよう、ローカル座標との変換も整理する必要があります。

おわりに

Let’s DIYの出発点は、狭い部屋のスペースを節約するために考えた、昇降引き出しでした。既製品は予算に合わず、自作できるかを仮想環境で試したい。そのためのアプリを作り始めました。

可動部品を足す過程では、基準姿勢と表示姿勢の分離や、jointの変更をUndo履歴へ残す処理が必要になりました。設計書の式をレビューで訂正した例もありました。

その土台の一つとして、つながった部材を組立品として扱う処理を紹介しました。接続データから対象を集め、ロックを先に確認し、全体へ同じ変換をかけることで、部材どうしの配置を保ったまま置き直せます。

これで昇降引き出しの実現性まで確かめられたわけではありません。組立品の配置編集と、関節の動きや力を扱う検証は分けて考える必要があります。作りたい家具を試すという当初の目的に向けて、まずは部材を組み合わせて扱う部分を整えた、というのが今回の範囲です。

※ 本体の関数との対応は現存ソースで確認しています。画面で今回操作したのは木材の生成・配置・丸穴加工です。後半のコード実行結果は記事用サンプルのものであり、金物による一括移動を含むアプリ全体・本番環境・関節や物理の動作を再検証した結果ではありません。

1
0
5

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
1
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?