Fusion360で配線長を算出したい

# はじめに
はじめまして。Qiita初投稿となります。

10年ほど前からFusion360を仕事や趣味（どちらも主に電気系）で使い始めました。
今回はその中で作ったスクリプトについてご紹介できればと思います。既出な手法だったらごめんなさいです。

# 目的
目的は電気配線の配線長を算出することです。配線長を計算して、実際にケーブルを作成するあるいは2Dの電気図面に起こす際の参考にしたい。

電気配線をFusion360でモデリングするには、
- 断面の2Dスケッチを描いて押し出す
- 断面のスケッチとパスの3Dスケッチを描いてスイープする
- 断面のスケッチを2つ描いてその間をロフトで繋ぐ

…などといった方法があるかと思います。これらの方法で作った配線の長さをできるだけ簡単に取得できるようにしたいと考えました。

# 作ったもの
以下の図のような感じで動きます。
- スクリプトを実行するとまず、サーフェスを選択するモードに入る
- サーフェスを選択していくと、中心線のスケッチが逐次計算されルート配下に追加される
- 最後にESCキーでサーフェス選択モードを抜ける　すると、ルート配下にある中心線スケッチの合計長が計算され画面に表示される
- 合計長はデスクトップにcsvファイルの形でも出力される

![選択中.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3781638/1ef7f559-faec-55f6-434d-ecb2c9896561.png)

![選択後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3781638/68da2fc2-4932-faaa-e96b-edbf2ee91358.png)

# 動作原理
各円筒状サーフェスの中心線スケッチは、SurfaceEvaluatorを使用して作成しています。

具体的には、SurfaceEvaluatorを用いて円筒状サーフェスの
- UVパラメータの最大値と最小値（parametricRange）
- UVどちらの方向にループが閉じているか（isClosedInU / isClosedInV）

…の2種類の情報から、中心線スケッチが通る座標群（plと表記）を計算で求めています。

ループが閉じている方向が円周方向なので、閉じていない方向に頂点を複数個作成し、それを3D座標系上の頂点に変換（getPointAtParameter）すれば、円筒面上に円筒の高さ方向に沿って並んだ点群が得られます（pnと表記）。

また、ループが閉じている方向のパラメータの最大値と最小値から平均値を取ると、その値が示す3D座標系での場所は円筒の中心線を挟んで真正面の位置となります（pmと表記）。

とっても分かりにくいと思うので、肝となる部分のコードを載せておきます。
下のコードはV方向がループの場合ですが、U方向についても同様です。

```python:calc_wire_model_length.py
# V方向にループとなっているとき
elif isLoopV:
    pnUvList.append(p1Uv)
    # U方向（x方向）にuvSplitNum個だけ点を生成する
    uvSplitInterval = (p1Uv.x - p2Uv.x) / uvSplitNum
    for i in range(uvSplitNum):
        pnUv = adsk.core.Point2D.create(p1Uv.x - i*uvSplitInterval, p1Uv.y)
        pnUvList.append(pnUv)
        # plotConstructionPoint(root, surfEva, pnUv)
    pnUvList.append(p2Uv)

    p3Uv = adsk.core.Point2D.create(p1Uv.x, (p1Uv.y + p2Uv.y)/2)
    p4Uv = adsk.core.Point2D.create(p2Uv.x, (p1Uv.y + p2Uv.y)/2)
    pmUvList.append(p3Uv)
    for i in range(uvSplitNum):
        pmUv = adsk.core.Point2D.create(p1Uv.x - i*uvSplitInterval, (p1Uv.y + p2Uv.y)/2)
        pmUvList.append(pmUv)
        # plotConstructionPoint(root, surfEva, pmUv)
    pmUvList.append(p4Uv)

# ボディ面上の頂点座標をUV座標系から3D座標系に変換
pnList = []  # Point3D型
pmList = []  # Point3D型
for (pnUv, pmUv) in zip(pnUvList, pmUvList):
    (retN, pn) = surfEva.getPointAtParameter(pnUv)
    (retM, pm) = surfEva.getPointAtParameter(pmUv)
    pnList.append(pn)
    pmList.append(pm)

# pnとpmの頂点座標を平均し中心線を通る頂点座標群を生成
plList = []
for (pn, pm) in zip(pnList, pmList):
    pl = adsk.core.Point3D.create((pn.x + pm.x)/2, (pn.y + pm.y)/2, (pn.z + pm.z)/2)
    plList.append(pl)
```
# ソースコード
ソースコードの全体像は以下の通りです。
run関数の中にいろいろべた書きしちゃってますがご容赦ください。

<details open><summary>ソースコード全体</summary>

```python:calc_wire_model_length.py
import traceback
import adsk.fusion
import adsk.core
import os
import csv

uvSplitNum = 50  # UV座標系における長手方向の分割頂点数

# コンストラクション頂点を生成する補助関数
def plotConstructionPoint(root, surfEva, uvPoint2d):
    # rootコンポーネント直下にConstructionPointとしてuvPoint2dの頂点をプロット
    (ret, point3d) = surfEva.getPointAtParameter(uvPoint2d)
    constructionPoints = root.constructionPoints
    constructionPointInput = constructionPoints.createInput()
    constructionPointInput.setByPoint(point3d)
    constructionPoint = constructionPoints.add(constructionPointInput)
    return constructionPoint

# エンティティ選択関数
def selectEnt(msg, filterStr):
    try:
        app = adsk.core.Application.get()
        ui = app.userInterface
        sel = ui.selectEntity(msg, filterStr)
        return sel
    except:
        return None

# メイン処理
def run(context):
    ui = adsk.core.UserInterface.cast(None)
    try:
        app = adsk.core.Application.get()
        ui = app.userInterface
        design = app.activeProduct
        root = design.rootComponent

        # 無限ループ（ESCキーでループから抜ける）
        while True:
            # 選択
            msg = 'Select'
            selFilter = 'Faces'
            sel = selectEnt(msg, selFilter)
            if not sel:
                break

            # 既存の中心線スケッチを探索
            # スケッチ名がwire-center-で始まるスケッチオブジェクトを探索
            wireCenterSketches = adsk.core.ObjectCollection.create()
            wireCenterSketchNums = []
            for sketch in root.sketches:
                if sketch.name.startswith('wire-center-'):
                    wireCenterSketches.add(sketch)
                    wireCenterSketchNums.append(int(sketch.name.replace('wire-center-', "")))

            # 得たオブジェクト一覧から末尾数字が最大のものを探す
            maxWireCenterSketchIndex = 0
            if wireCenterSketchNums:
                maxWireCenterSketchIndex = max(wireCenterSketchNums)

            # 選択面を取得
            face = sel.entity

            # surface evaluatorを取得
            surfEva = face.evaluator

            # UVパラメータの最大最小を取得
            surfRange = surfEva.parametricRange()
            p1Uv = surfRange.maxPoint
            p2Uv = surfRange.minPoint

            # ループになっている方向があるか調べる
            isLoopU = surfEva.isClosedInU
            isLoopV = surfEva.isClosedInV

            # ループになっている方向があれば
            pnUvList = []
            pmUvList = []
            if isLoopU or isLoopV:
                # U方向にループとなっているとき
                if isLoopU:
                    # V方向（y方向）にuvSplitNum個だけ点を生成する
                    uvSplitInterval = (p1Uv.y - p2Uv.y) / uvSplitNum
                    pnUvList.append(p1Uv)
                    for i in range(uvSplitNum):
                        pnUv = adsk.core.Point2D.create(p1Uv.x, p1Uv.y - i*uvSplitInterval)
                        pnUvList.append(pnUv)
                        # plotConstructionPoint(root, surfEva, pnUv)
                    pnUvList.append(p2Uv)

                    p3Uv = adsk.core.Point2D.create((p1Uv.x + p2Uv.x)/2, p1Uv.y)
                    p4Uv = adsk.core.Point2D.create((p1Uv.x + p2Uv.x)/2, p2Uv.y)
                    pmUvList.append(p3Uv)
                    for i in range(uvSplitNum):
                        pmUv = adsk.core.Point2D.create((p1Uv.x + p2Uv.x)/2, p1Uv.y - i*uvSplitInterval)
                        pmUvList.append(pmUv)
                        # plotConstructionPoint(root, surfEva, pmUv)
                    pmUvList.append(p4Uv)

                # V方向にループとなっているとき
                elif isLoopV:
                    pnUvList.append(p1Uv)
                    # U方向（x方向）にuvSplitNum個だけ点を生成する
                    uvSplitInterval = (p1Uv.x - p2Uv.x) / uvSplitNum
                    for i in range(uvSplitNum):
                        pnUv = adsk.core.Point2D.create(p1Uv.x - i*uvSplitInterval, p1Uv.y)
                        pnUvList.append(pnUv)
                        # plotConstructionPoint(root, surfEva, pnUv)
                    pnUvList.append(p2Uv)

                    p3Uv = adsk.core.Point2D.create(p1Uv.x, (p1Uv.y + p2Uv.y)/2)
                    p4Uv = adsk.core.Point2D.create(p2Uv.x, (p1Uv.y + p2Uv.y)/2)
                    pmUvList.append(p3Uv)
                    for i in range(uvSplitNum):
                        pmUv = adsk.core.Point2D.create(p1Uv.x - i*uvSplitInterval, (p1Uv.y + p2Uv.y)/2)
                        pmUvList.append(pmUv)
                        # plotConstructionPoint(root, surfEva, pmUv)
                    pmUvList.append(p4Uv)

                # ボディ面上の頂点座標をUV座標系から3D座標系に変換
                pnList = []  # Point3D型
                pmList = []  # Point3D型
                for (pnUv, pmUv) in zip(pnUvList, pmUvList):
                    (retN, pn) = surfEva.getPointAtParameter(pnUv)
                    (retM, pm) = surfEva.getPointAtParameter(pmUv)
                    pnList.append(pn)
                    pmList.append(pm)

                # pnとpmの頂点座標を平均し中心線を通る頂点座標群を生成
                plList = []
                for (pn, pm) in zip(pnList, pmList):
                    pl = adsk.core.Point3D.create((pn.x + pm.x)/2, (pn.y + pm.y)/2, (pn.z + pm.z)/2)
                    plList.append(pl)

                # plListを通る線分をsketch線分として作成
                xyPlane = root.xYConstructionPlane
                centerLineSketch = root.sketches.add(xyPlane)
                sketchLines = centerLineSketch.sketchCurves.sketchLines
                for (pl1, pl2) in zip(plList[:-1], plList[1:]):
                    centerLine = sketchLines.addByTwoPoints(pl1, pl2)

                # スケッチ名を変更
                newSketchName = 'wire-center-' + str(maxWireCenterSketchIndex + 1).zfill(3)
                centerLineSketch.name = newSketchName

        # すべてのwire-centerスケッチを探索し、その総線長を算出
        totalLength = 0
        for sketch in root.sketches:
            if sketch.name.startswith('wire-center-'):
                sketchCurves = sketch.sketchCurves
                for sketchCurve in sketchCurves:
                    sketchLength = sketchCurve.length * 10.0  # cm->mm
                    totalLength += sketchLength

        # 総線長を表示
        ui.messageBox('Total wire length: {:.1f} [mm]'.format(totalLength))

        # 現在開いているFusion360のプロジェクト名を取得
        projectName = app.activeDocument.name

        # CSVファイルのパスを設定
        csvPath = os.path.expanduser('~/Desktop/wire_length_list.csv')

        # CSVファイルが存在するかチェック
        if not os.path.exists(csvPath):
            # 存在しない場合は新規作成
            with open(csvPath, mode='w', newline='') as file:
                writer = csv.writer(file)
                writer.writerow(['Project Name', 'Total Wire Length [mm]'])

        # CSVファイルにデータを追加
        with open(csvPath, mode='a', newline='') as file:
            writer = csv.writer(file)
            writer.writerow([projectName, '{:.1f}'.format(totalLength)])
    except:
        if ui:
            ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))
```
</details>

# 参考文献など
スクリプト作成にあたり、以下を参考にさせていただきました。

- [Fusion360 APIの公式リファレンス](https://help.autodesk.com/view/fusion360/ENU/)
-- 特に [SurfaceEvaluatorの解説](https://help.autodesk.com/view/fusion360/ENU/?guid=GUID-31dad9b2-ee1d-4216-8100-09ea44f9967a)
- [C#ATIA](https://kantoku.hatenablog.com/about)
-- 特に [3D曲線のパイプの中心線を求める](https://kantoku.hatenablog.com/entry/2022/06/08/120310)

# 最後に
同様の作業で困っている方の助けに少しでもなれていたら幸いです。
最後まで読んで下さりありがとうございました。