STLファイルを読み込んで投影を印刷する

注：何か役に立つような話は書いてないです。

***

既存の部品に組み合わせる部品をFusion 360でモデリングをしていて、ふと「本当にこの大きさでいいんだろうか?」と不安になりました(相手が謎の寸法の曲面を含むので)。で、いざ実寸で印刷してみようと思ったところ、どうやらFusion 360には印刷機能はないようです。
ググると「PDFで出力して印刷しろ」という説明が出てきますが、どうやらPDFの出力には有料ライセンスである必要があるようです。
DXFで出力することもできますが、あいにく僕のマシンにはDXFを表示できるソフトウェアが入っていません(以前使用していたソフトはライセンス形態が変わって使えなくなった)。AutodeskはDXFビューアも提供していますが、オンライン版では実寸での印刷ができず、オフライン版はただのビュアーのくせしてインストーラが850MBもあります。
ADSLすらも通っていないド田舎の深夜、800MBもダウンロードしていてはいったいいつになったら終わるのか検討も付きません。

で、待ってる間にボーッとしていると、とある記憶が思い浮かんできました。
「前にC#でSTLファイルを読み込んだことあったな?」
「前にC#から正しい寸法で印刷できることを確認したな?」
こうなったら作るしかない!!

ということで、FusionからSTLデータをエクスポートし、それを実寸で印刷するソフトを作りました。
表示結果が以下の画面です。

![2021-07-22_02-08-19 - コピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264452/9489221f-a16f-2ce2-01b4-13a9af951ad2.png)

3割ほどしかダウンロードしきらないうちにSTLの読み込みから印刷まで実装できてしまいました。
やっぱゴリ押しは強いね!! そりゃ[伝書鳩がインターネットより早い](https://ja.wikipedia.org/wiki/%E9%B3%A5%E9%A1%9E%E3%82%AD%E3%83%A3%E3%83%AA%E3%82%A2%E3%81%AB%E3%82%88%E3%82%8BIP)とか言われるよ。

***

とりあえず表示はできるようになりましたが、もう少し機能を追加しました。

![2021-07-22_03-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264452/a415e273-ce8e-d647-798e-7664e47e63b1.png)

といっても、視点の変更とかプリンタの設定画面を開くとか、その程度ですが。

ソースコードは以下のとおりです。

```C#
string stlFilePath = null;
Vector3[][] polygons = null;

public Form1()
{
    InitializeComponent();

    printPreviewControl1.AutoZoom = true;
    printDialog1.Document = printDocument1;
}

private void openButton_Click(object sender, EventArgs e)
{
    if (openFileDialog1.ShowDialog() != DialogResult.OK)
    {
        return;
    }

    Vector3[][] polygons;

    try
    {
        polygons = LoadStlFile(openFileDialog1.FileName);
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message, "STL file open ERROR", MessageBoxButtons.OK, MessageBoxIcon.Error);
        return;
    }

    stlFilePath = openFileDialog1.FileName;
    this.polygons = polygons;

    printPreviewControl1.InvalidatePreview();
}

static Vector3[][] LoadStlFile(string path)
{
    using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read))
    {
        var buff = new byte[84];

        stream.Read(buff, 0, 84);
        //var anyText = Encoding.ASCII.GetString(buff, 0, 80);
        var polygons = new Vector3[BitConverter.ToInt32(buff, 80)][];

        for (int i = 0; i < polygons.Length; i++)
        {
            if (stream.Read(buff, 0, 50) != 50) { throw new Exception("ファイルが異常です"); }

            polygons[i] = new Vector3[3]
            {
                new Vector3(
                    BitConverter.ToSingle(buff, 4 * 3),
                    BitConverter.ToSingle(buff, 4 * 4),
                    BitConverter.ToSingle(buff, 4 * 5)),
                new Vector3(
                    BitConverter.ToSingle(buff, 4 * 6),
                    BitConverter.ToSingle(buff, 4 * 7),
                    BitConverter.ToSingle(buff, 4 * 8)),
                new Vector3(
                    BitConverter.ToSingle(buff, 4 * 9),
                    BitConverter.ToSingle(buff, 4 * 10),
                    BitConverter.ToSingle(buff, 4 * 11)),
            };
        }

        return polygons;
    }
}

private void printDocument1_PrintPage(object sender, System.Drawing.Printing.PrintPageEventArgs e)
{
    Text =
        "STL: " +
        Path.GetFileNameWithoutExtension(stlFilePath) + " - " +
        e.PageSettings.PrinterSettings.PrinterName + " / " +
        e.PageSettings.PaperSize.PaperName;

    e.HasMorePages = false;

    var polygons = this.polygons;
    var g = e.Graphics;

    if (polygons == null)
    {
        g.Clear(Color.White);
        g.TranslateTransform(e.PageBounds.X + e.PageBounds.Width * 0.5f, e.PageBounds.Y + e.PageBounds.Height * 0.5f);

        using (var font = new Font("", 24, GraphicsUnit.Pixel))
        using (var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center })
        {
            g.DrawString("*** NO STL DATA ***", font, Brushes.Black, 0, 0, sf);
        }
    }
    else
    {
        Func<Vector3, Vector2> projection = vertex => new Vector2(vertex.X, vertex.Y);

        foreach (var v in groupBox1.Controls)
        {
            if (v is RadioButton rb && rb.Checked)
            {
                switch (rb.Tag)
                {
                case "XY": projection = vertex => new Vector2(vertex.X, vertex.Y); break;
                case "XZ": projection = vertex => new Vector2(vertex.X, vertex.Z); break;
                case "YZ": projection = vertex => new Vector2(vertex.Y, vertex.Z); break;
                }
            }
        }

        var list = new List<Vector2[]>(polygons.Length);

        foreach (var v in polygons)
        {
            list.Add(v.Select(a => projection(a)).ToArray());
        }

        var min = new Vector2(float.MaxValue);
        var max = new Vector2(float.MinValue);

        foreach (var v in list)
        {
            foreach (var w in v)
            {
                min = Vector2.Min(min, w);
                max = Vector2.Max(max, w);
            }
        }

        {
            g.PageUnit = GraphicsUnit.Millimeter;

            g.TranslateTransform(
                (e.PageBounds.X + e.PageBounds.Width * 0.5f) * 0.01f * 25.4f,
                (e.PageBounds.Y + e.PageBounds.Height * 0.5f) * 0.01f * 25.4f);

            var b = (max - min) / 2 + min;
            g.TranslateTransform(-b.X, -b.Y);
        }

        using (var pen = new Pen(Color.Black, 0.1f))
        {
            foreach (var v in polygons.Select(a => a.Select(b => projection(b)).ToArray()))
            {
                g.DrawLines(pen, v.Select(a => new PointF(a.X, a.Y)).ToArray());
            }
        }
    }
}

private void printerConfigButton_Click(object sender, EventArgs e)
{
    if (printDialog1.ShowDialog() == DialogResult.OK)
    {
        printPreviewControl1.InvalidatePreview();
    }
}

private void printButton_Click(object sender, EventArgs e)
{
    printDocument1.Print();
}

private void radioButton3_CheckedChanged(object sender, EventArgs e)
{
    if (sender is RadioButton rb && rb.Checked)
    {
        printPreviewControl1.InvalidatePreview();
    }
}
```

3次元の頂点を2次元に変換してからPointFに変換する処理が冗長ですが、このあたりは各自好きなように実装すればいいかと思います。Vector2に適当な定数をかければ2倍や5倍、10分の1や48分の1のような拡大・縮小機能も実装できます。
さすがに斜めの視点からの投影は面倒ですが、それでもVector3.Transformを使えば実装できるはずです。
3次元空間で回転方向を調べて裏表を判定し、先に裏面をグレー等でレンダリングするような機能とかも有ればいいかもしれませんが、さすがに機能が増えてくると既存のソフトを探すほうが楽かと思います。