・ネットでたまたま目にした"3Dマウス"という言葉が気になって調べてみたら、
https://www.3dconnexion-japan.com/%E8%A3%BD%E5%93%81%E7%B4%B9%E4%BB%8B/
世の中には3D-CAD等でモデルの回転やズームを行える左手用のデバイスがあるという事がわかりました。直感的な操作ができて良さげですが、お試しで使うにはちょっと手が出せない価格ではあります。
・そこで"3Dマウス 自作"で検索するとこんな方もいらっしゃる。↓すごい!
https://shk-maker.hatenablog.jp/entry/2019/10/12/154328
・ここで改めて考えてみるとFusion360でのモデルの操作方法は、
ズーム:マウスホイールを回転
平行移動:マウスホイールを押したままマウスを移動
回転:シフトキーとマウスホイールを押したままマウスを移動
なので、左手が本当に必要となるのは回転だけです。そこでジョイスティックを1つだけ使って回転に特化した(というか回転しかできない)左手用のデバイスが作れるのでは、と考えました。本物の1/3しか機能がないので名付けて"3/3Dマウス"です。
・入手したジョイスティックには押しボタンスイッチが付いているので、これを短く押すと回転の中心(ピボット)を設定、長押しするとモデルを画面全体にフィットする機能を割り当てました。
●使用した部品
・ジョイスティック Amazonで\150/個
https://www.amazon.co.jp/gp/product/B082M4V5S9
・マイコンボード Digispark micro-Bコネクタ版 \435/個(以前は\250くらいでしたが)
https://www.amazon.co.jp/KKHMF-Digispark-Kickstarter-Attiny85-Arduino/dp/B082M58JR2
・アナログ入力2chとデジタル入力1ch、それとHIDに対応したUSBインターフェースがあるものということでDigisparkのmicro-Bタイプを選択しました。
・ケースはダイソーのクリーム入れ \100
・15kΩの抵抗1本とUSBケーブルは手持ちの物
総額で\700未満ですね。
●回路説明
・DigisparkのP0はデジタル入出力として使えます。今回はスイッチ入力に割り当てます。
・P1はボード上にLEDが付けられているので無改造ではデジタル出力としてしか使えません。これは動作確認用にそのまま使うことにします。
・P2はアナログ入力として使えます。X軸のアナログ入力に割り当てます。
・P3, P4はUSB用に割り当てられているので今回のようにユーザープログラムがUSBを利用するときには使えません。
・P5はリセット端子を兼ねているのですが、電圧をLに落とさなければアナログ入力として使えます。Y軸のアナログ入力に割り当てます。
・ただしそのままだとジョイスティックを倒したときにP5の電圧がL電位まで落ちてリセットがかかってしまうので、ジョイスティックを改造してポテンショメーターの1番端子とGND間に15kΩの抵抗を直列に挿入します。これでP5の電圧はジョイスティックを最大に動かしても3~5Vの範囲に収まります。(ATtiny85のスペックだとリセットピンのLレベル電圧Maxは0.2Vccなので5V使用時に1.0Vとなり、それに比べたら十分余裕があります。)
・Digisparkで使われているATtiny85のヒューズと呼ばれている内部のフラッシュ領域を焼き直すとリセット機能を簡単に無効にできるのですが、今回はDigispark側を無改造で使うことにしてハードウェア側で対応しています。
●ジョイスティックの改造・Y軸用のポテンショのGNDに落ちているピンの周囲のパターンをカットし、ピンとGNDとの間に15kΩの抵抗を入れます。パターンを観察するためレジストを剥いでしまったので最後にマニキュアを塗っておきました。
●ケースに組み込み・ケースに穴をあけて基板をグルーガンで固定しました。ジョイスティックには6mmの足を付けてDigisparkとは2階建て構造です。LEDが隠れてしまうので透明の樹脂棒で導光を試みましたがあまりうまくいきませんでした。
●ソフトウェア説明・Digisparkにはキーボードライブラリ<DigiKeyboard.h>とマウスライブラリ<DigiMouse.h>が用意されているのですが、両者を同時に組み込むことができませんでした。
・検索してマウスとキーを同時にエミュレートできるAdafruit-Trinket-USBライブラリを見つけました。↓
https://github.com/adafruit/Adafruit-Trinket-USB
これのTrinketHidComboフォルダをArduinoIDEを展開したフォルダの下のlibrariesフォルダにコピーしておきます。
・ジョイスティックの中点がきっちり抵抗値の50%になるとは限らないのと、Y軸側はそもそも中点が50%ではないので最初にキャリブレーションデータの取得を行います。//#define DEBUGのコメントを外してビルドするとマウスのあるウインドウにX,YのA/D値とそれらを疑似マウスの移動量に変換した値が次々に表示されるので、X,YそれぞれのA/D最小/中点/最大の値をメモします(エディタ等で空白のファイルを開いてその上にマウスカーソルを置いた後で書き込みを開始する必要があります)。メモした値でソースを修正し、再度ビルドして疑似マウス移動量が設定どおりに変化することを確認します。
・#define DEBUGを再度コメントアウトしてビルドして焼きこめば完成です。
・アップしたソースは1号機と2号機それぞれをビルドできるようになっています。1号機が今回の物で2号機はスライド式のジョイスティックを使った小型タイプなのですが、そちらはまた別途。
・マウスをエミュレートしているだけなので、従来のマウスとシフトキーによる回転操作をした時と同様にマウスカーソルも移動します。//#define CURSORRECOVERのコメントを外してビルドすると一定移動距離ごとにカーソルを戻す機能が組み込まれるのですが、どうも元の位置にきっちり戻らないのと、マウスカーソルがちらちらするのと、従来の操作方とのカーソル位置の違いに違和感があるのと、たまにピボット点が変わってしまうという問題があり今は使っていません。
・スイッチをクリックすると、シフトを押しながらホイールを押すコードだけを送っています。これでカーソル位置にピボットが設定されます。
・Fusion360側の機能としてホイールをダブルクリックするとモデルを画面全体にフィットする動きになります。これにより短時間に2回ジョイスティックを動かすとダブルクリックと判定されて予期せずフィットになってしまうことがありました。そこでジョイスティックやスイッチを戻した時に、カーソル位置を少しだけ"行って来い"させてダブルクリック判定に入らないようにしています。
・そのためスイッチのダブルクリックによる画面フィットは不可能になったので、長押ししたらF6キーを送ることで画面フィットを実現しています。
●使ってみて
・確かに直感的にモデルを回転することができる気がします。
・回転しながらのズームはできません。これはマウスによる従来の操作でもできないので当然ではありますが。
・ジョイスチックを倒した方向にマウスを動かすとモデルを急峻に回転させることができます。
・常時キーボードの前に置いておくのは邪魔くさいし、使うたびに引っ張り出すというのも面倒です→2号機に続く
★以下は備忘録です
●Digispark用開発環境の構築
・こちらを参考に↓
http://digistump.com/wiki/digispark/tutorials/connecting
IDE1.6.5がお勧めのようなので、こちらの↓Previous Releasesから1.6.5のZIP版を落としてフォルダごと解凍。
https://www.arduino.cc/en/software
・普段使っているArduinoIDEとは別環境にしたかったので、展開したarduino.exeと同じ階層にportableという名前のフォルダを追加。
・arduino.exeのショートカットをデスクトップに作成し、Digispark専用のIDEはそこから起動。
・IDEを起動したら環境設定でボードマネージャのURLとして以下を追加。
http://digistump.com/package_digistump_index.json
・ツールのボードマネージャでDigistump AVR Board by Digistumpをインストール。
(私の場合バージョン1.6.7じゃないとその後うまくビルドできませんでした)
・この際ドライバーのインストールで一番上の1つがエラーになったので、↓ここの
https://github.com/digistump/DigistumpArduino
toolsフォルダの下のwindows用ドライバを解凍してインストールしたら全部組み込めた。(このドライバが必須かどうかはわからないですが)
・IDEのツールからボードを"Digispark (Default - 16.5mhz)"を選択。
・ビルド&書き込みボタンを押してPlug in device now...(will timeout in 60 seconds)と表示されたらDigisparkを接続。(抜き差しが面倒なので私はUSB延長ケーブルの途中にスイッチを入れて+5Vラインをオンオフできるようにしています)
[↓ソースはここから]
/*****************************************************************************
*
* JoystickMouse.ino -- Joystickでマウスをエミュレート[Digispark]
*
* Adafruit-Trinket-USBのTrinketHidComboフォルダをlibrariesフォルダ下にコピー
* https://github.com/adafruit/Adafruit-Trinket-USB
*
* JoystickのSW→P0
* JoystickのX軸→P2(AD1)
* JoystickのY軸→P5(AD0)
* PB5はリセット入力兼用なのでY軸のポテンショのGND側に15kΩを
* 直列に挿入して電圧がLレベルにならないようにしている。
*
* rev1.0 2021/12/22 initial revision by Toshi
*
*****************************************************************************/
#include <TrinketHidCombo.h> // マウス&キーボードライブラリ
//#define DEBUG // A/D読み取り値出力時にはコメントを外してビルド
//#define CURSORRECOVER // カーソル位置を戻すならコメントを外す
//#define _2X // 2号機ならコメントを外す
#define SW PB0 // スイッチポート
#define LED PB1 // LEDポート
#define LED_ON digitalWrite(LED, 1);
#define LED_OFF digitalWrite(LED, 0);
#ifndef _2X // 1号機(ポテンショメータータイプのJoystick))
#define ADMAXX 1023 // X軸の最大A/D実測値
#define ADCENTERX 516 // X軸のセンターA/D実測値
#define ADMINX 0 // X軸の最小A/D実測値
#define ADMAXY 1023 // Y軸の最大A/D実測値
#define ADCENTERY 835 // Y軸のセンターA/D実測値
#define ADMINY 667 // Y軸の最小A/D実測値+2
#define MAXMOUSE 8 // マウス最大移動量
#define DEADZONE 0 // ジョイスティックの不感帯(マウス移動量)
#else // 2号機(スライドタイプのJoystick)
#define ADMAXX 1023 // X軸の最大A/D実測値
#define ADCENTERX 476 // X軸のセンターA/D実測値
#define ADMINX 0 // X軸の最小A/D実測値
#define ADMAXY 1023 // Y軸の最大A/D実測値
#define ADCENTERY 828 // Y軸のセンターA/D実測値
#define ADMINY 677 // Y軸の最小A/D実測値+2
#define MAXMOUSE 6 // マウス最大移動量
#define DEADZONE 1 // ジョイスティックの不感帯(マウス移動量)
#endif // _2X
#define LOOPTIME 20 // ループごとの待ち時間
#define MINMOVE 15 // ホイールダブルクリック判定回避の移動量
#define RESETMOVE 50 // マウスの位置を戻すカウント
#define RECOVERGAIN 54 // 行きに対する戻り側カウント量[%]
int AdData0, AdData1; // A/D読み取り値
char MoveX, MoveY; // マウス移動量
int TotalX, TotalY; // マウス総移動量
bool fPush; // シフトキー押されているかどうか
void setup()
{
pinMode(SW, INPUT_PULLUP);
pinMode(LED, OUTPUT);
TrinketHidCombo.begin(); // USBデバイスエンジン起動
}
void loop()
{
static bool fonshort, fonlong;
static int buf0, buf1;
// A/D読み取り(0~1023)
AdData0 = AdRead(0, &buf0); // PB5はAD0(Y軸)
AdData1 = AdRead(1, &buf1); // PB2はAD1(X軸)
// A/D読み取り値からマウス移動量への変換
MoveY = Ad2Mouse(AdData0, ADMINY, ADCENTERY, ADMAXY);
MoveX = Ad2Mouse(AdData1, ADMINX, ADCENTERX, ADMAXX);
// SWの短押し/長押しを判定
JudgeButton(digitalRead(SW) == 0, &fonshort, &fonlong);
#ifdef DEBUG
DebugPrint(); // A/D読み取り値とマウス移動量をカーソル位置に出力
delay(500);
#else
/** 画面Fit **/
if (fonlong) // 長押し?
{
// F6キーを押す
TrinketHidCombo.pressKey(0, KEYCODE_F6);
TrinketHidCombo.pressKey(0, 0);
}
/** カーソル位置にピボットを設定 **/
else if (fonshort) // 短押し?
{
// シフトキーを押す
TrinketHidCombo.pressKey(KEYCODE_MOD_LEFT_SHIFT,
KEYCODE_LEFT_SHIFT);
fPush = true; // シフトキー状態オンを記憶
// ホイールボタンをオン
TrinketHidCombo.mouseMove(0, 0, MOUSEBTN_MIDDLE_MASK);
// ホイールボタンをオフ
TrinketHidCombo.mouseMove(0, 0, 0);
}
/** オービット **/
else if (MoveX != 0 || MoveY != 0) // スティックが倒されている?
{
if (!fPush) // まだ押されていないなら
{
LED_ON
// シフトキーを押す
TrinketHidCombo.pressKey(KEYCODE_MOD_LEFT_SHIFT,
KEYCODE_LEFT_SHIFT);
fPush = true; // シフトキー状態オンを記憶
}
if (fPush) // シフトキーが押されているなら
{
// ホイールを押しながらマウスを移動させる
TrinketHidCombo.mouseMove(MoveX, MoveY, MOUSEBTN_MIDDLE_MASK);
TotalX += MoveX; // 移動量の累積
TotalY += MoveY;
// トータル移動量が規定に達した?
if (abs(TotalX) >= RESETMOVE || abs(TotalY) >= RESETMOVE)
{
// カーソル位置を戻す
RecoverMouse(&TotalX, &TotalY);
}
}
}
/** スティック&ボタン操作なし **/
else
{
if (fPush) // シフトキーが押されていたら
{
LED_OFF
TrinketHidCombo.mouseMove(0, 0, 0); // ホイールボタンをオフ
// 短時間で2回来た時ダブルクリックと判定されるのを回避する
TrinketHidCombo.mouseMove(MINMOVE, 0, 0); // 少し動かして
TrinketHidCombo.poll();
TrinketHidCombo.mouseMove(-MINMOVE, 0, 0); // 元に戻す
TrinketHidCombo.pressKey(0, 0); // シフトキーをオフ
fPush = false; // シフトキー状態オフを記憶
// カーソル位置を戻す
RecoverMouse(&TotalX, &TotalY);
}
}
delay(LOOPTIME); // 少し待つ
TrinketHidCombo.poll(); // USBに何かする必要があるかどうかを確認
#endif // DEBUG
}
// デバッグビルド時にA/D読み取り値とマウス移動値を出力
void DebugPrint()
{
TrinketHidCombo.print(AdData1); // X軸A/D値
TrinketHidCombo.print(",");
TrinketHidCombo.print(AdData0); // Y軸A/D値
TrinketHidCombo.print(" ");
TrinketHidCombo.print((int)MoveX); // X軸移動量
TrinketHidCombo.print(",");
TrinketHidCombo.println((int)MoveY);// Y軸移動量
}
/*----------------------------------------------------------------------------
カーソル位置を戻す
書式 void RecoverMouse(int* totalx, int* totaly)
int* totalx; 移動した量x
int* totaly; 移動した量y
----------------------------------------------------------------------------*/
void RecoverMouse(int* totalx, int* totaly)
{
#ifdef CURSORRECOVER // カーソル位置を戻すモードなら
int x, y;
TrinketHidCombo.mouseMove(0, 0, 0); // ホイールボタンをオフ
// mouseMoveの引数はsigned char型なので範囲内で繰り返す
while (!(*totalx == 0 && *totaly == 0))
{
x = min(*totalx, 127);
x = max(x, -127); // ライブラリ側で-127までのようなので
y = min(*totaly, 127);
y = max(y, -127);
TrinketHidCombo.poll();
// マウスカーソルを移動
// (移動したカウントと戻り量が異なるのでRECOVERGAINは要調整)
TrinketHidCombo.mouseMove(-x * RECOVERGAIN / 100,
-y * RECOVERGAIN / 100, 0);
*totalx -= x;
*totaly -= y;
}
#else
*totalx = *totaly = 0; // 移動量の累積をクリア
#endif // CURSORRECOVER
}
/*----------------------------------------------------------------------------
A/D読み取りと平均
書式 ret = AdRead(int ch, int* buf)
int ret; A/D値(0~1023)
int ch; A/Dチャンネル
int* buf; sumバッファ
----------------------------------------------------------------------------*/
#define AVENUM 10 // 平均数。最大で31
int AdRead(int ch, int* buf)
{
*buf = 0; // 合計をクリア
for (int i = 0; i < AVENUM; i++)
{
*buf += analogRead(ch); // A/Dデータを累積
}
return (*buf + AVENUM / 2) / AVENUM; // 四捨五入
}
/*----------------------------------------------------------------------------
A/D読み取り値からマウス移動量への変換
書式 ret = Ad2Mouse(int data, int dmin, int dcenter, int dmax)
char ret; マウス移動量
int data; A/Dデータ
int dmin; A/D最小値
int dcenter; A/Dセンター値
int dmax; A/D最大値
----------------------------------------------------------------------------*/
char Ad2Mouse(int data, int dmin, int dcenter, int dmax)
{
char x, ret = 0;
if (data >= dcenter) // センターよりプラス側
{
x = (char)map(data, dcenter, dmax, 0, MAXMOUSE + DEADZONE);
}
else // センターよりマイナス側
{
x = (char)map(data, dcenter, dmin, 0, -MAXMOUSE - DEADZONE);
}
// 不感帯処理
if (abs(x) > DEADZONE) // 不感帯より値が大きいなら
{
ret = (char)(sign(x) * (abs(x) - DEADZONE)); // 不感帯分を引く
}
return ret;
}
/*----------------------------------------------------------------------------
ボタンの短押し/長押し判定
書式 void JudgeButton(bool flag, bool* fonshort, bool* fonlong)
bool flag; スイッチのon/off状態
bool* fonshort; 短押し判定
bool* fonlong; 長押し判定
----------------------------------------------------------------------------*/
#define ONTIMELONG 20 // 長押し判定時間
#define ONTIMESHORT 2 // 短押し判定時間
void JudgeButton(bool flag, bool* fonshort, bool* fonlong)
{
static int ontime, offtime = 30000;
static bool flong;
*fonshort = *fonlong = false; // いったんクリア
AddOnOffTime(flag, &ontime, &offtime); // オンオフ時間の累積
// オン時間が長押し時間に達した
if (ontime == ONTIMELONG)
{
*fonlong = true; // 長押しと判定
flong = true;
}
// オフ時間が短押し時間に達して長押しではない
if (offtime == ONTIMESHORT && !flong)
{
*fonshort = true; // 短押しと判定
}
// 長押し後にオフ時間が短押し時間+1に達した
if (flong && offtime > ONTIMESHORT)
{
flong = false; // 長押し状態解除
}
}
/*----------------------------------------------------------------------------
符号を返す
書式 ret = sign(int x)
int ret; 負なら-1, さもなければ1
int x; データ
----------------------------------------------------------------------------*/
int sign(int x)
{
if (x < 0) return -1;
return 1;
}
/*----------------------------------------------------------------------------
フラグのオン/オフ時間の累積
書式 void AddOnOffTime(bool flag, int* ontime, int* offtime)
bool flag; フラグ
int* ontime; オン時間
int* offtime; オフ時間
----------------------------------------------------------------------------*/
#define TIMEMAX 30000
void AddOnOffTime(bool flag, int* ontime, int* offtime)
{
if (flag) /* オンしてるなら */
{
*offtime = 0; /* オフ時間を0に */
if (*ontime < TIMEMAX) /* 規定に達するまでタイマ++ */
{
(*ontime)++;
}
}
else /* オフなら */
{
*ontime = 0; /* オン時間を0に */
if (*offtime < TIMEMAX) /* 規定に達するまでタイマ++ */
{
(*offtime)++;
}
}
}
/*** end of JoystickMouse.ino ***/
[↑ここまで]