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?

AndroidでJw_cadの図面を読む — JWWバイナリパーサーをKotlinで実装した話

0
Posted at

はじめに

建築・設備・電気工事の現場では、Jw_cad(JWW)形式の図面がまだまだ現役です。PCでは問題なく開けますが、現場に図面を持ち出してスマホで確認したい、ついでに2点間の距離をさっと測りたい、という場面がよくあります。

そこで JwwMeasure というAndroidアプリを作りました。JWWファイルを開いて、表示して、距離を測るだけのシンプルなアプリです。

本記事では、このアプリの技術的に面白い部分を紹介します。

  • JWWバイナリフォーマットのパース(MFCシリアライゼーションとの格闘)
  • OpenGL ES 3.0による図面描画
  • ズームに依存しない距離測定の座標系設計
  • 端点・中点・交点へのスナップ

技術スタック

項目 選定
言語 Kotlin 2.1
描画 OpenGL ES 3.0
UI Jetpack Compose + GLSurfaceView
ファイル選択 Storage Access Framework (SAF)
最低API Android 8.0 (API 26)

外部ライブラリは使っていません。パーサーもレンダラーもフルスクラッチです。

1. JWWバイナリフォーマットの解析

1.1 JWWファイルの正体

JWWファイルは、Jw_cadが独自に定義したバイナリフォーマットです。公式な仕様書は存在しません。ファイルの中身は MFC(Microsoft Foundation Class)のシリアライゼーション形式 がベースになっています。

構造を大まかに示すとこうなります。

[8バイト シグネチャ "JwwData."]
[1168バイト ヘッダー(用紙サイズ・縮尺・レイヤー状態など)]
[タグ付きデータブロックの繰り返し(線・円・弧・テキスト・寸法線...)]

また、JWWバージョンによってはファイル全体がZIP圧縮されている場合があります。先頭2バイトが 0x50 0x4B(PKマジック)なら展開してから処理します。

// ZIP判定 → 展開 → パース
if (data.size >= 4 && data[0] == 0x50.toByte() && data[1] == 0x4B.toByte()) {
    val unzipped = unzip(data)
    return parse(unzipped)
}

1.2 MfcReader — ByteBufferでMFCシリアライゼーションを読む

JWWのバイナリデータはリトルエンディアンで格納されています。Kotlinでは ByteBuffer を使って読み出します。

class MfcReader(data: ByteArray) {
    val buf: ByteBuffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
    
    private val shiftJis: Charset = Charset.forName("MS932")

    fun readByte(): Int = buf.get().toInt() and 0xFF
    fun readWord(): Int = buf.getShort().toInt() and 0xFFFF
    fun readDword(): Int = buf.getInt()
    fun readDouble(): Double = buf.getDouble()
}

ここで厄介なのが MFCの可変長文字列 です。MFCの CArchive は文字列長を独自のエンコーディングで格納します。

fun readCString(): String {
    val b = readByte()
    if (b < 0xFF) {
        // 1バイト長: 0〜254文字
        val bytes = readBytes(b)
        return String(bytes, shiftJis)
    } else {
        val w = readWord()
        when (w) {
            0xFFFE -> {
                // UTF-16LEエンコーディング
                val charCount = readSmallCount()
                val bytes = readBytes(charCount * 2)
                return String(bytes, Charsets.UTF_16LE)
            }
            0xFFFF -> {
                // 4バイト長: 巨大文字列
                val length = readDword()
                val bytes = readBytes(length)
                return String(bytes, shiftJis)
            }
            else -> {
                // 2バイト長: 255〜65534文字
                val bytes = readBytes(w)
                return String(bytes, shiftJis)
            }
        }
    }
}

JWWの古いバージョンではShift-JIS、新しいバージョンではUTF-16LEが使われます。0xFFFE というマーカーでUTF-16を判定するのがポイントです。

1.3 ヘッダーの読み取り

ヘッダーには用紙サイズ、16グループ分の縮尺、256レイヤー分の表示状態などが格納されています。

data class JwwHeader(
    val version: Int = 0,
    val paperSize: Int = 4,           // 0:A0 〜 4:A4
    val scales: DoubleArray = DoubleArray(16) { 1.0 },  // グループ毎の縮尺分母
    val layerStates: IntArray = IntArray(256) { 1 },     // 0:非表示 1:表示
    val penColors: IntArray = IntArray(10),
)

JWWの縮尺は グループ(0〜F)ごとに異なる値 を持てるのが特徴です。例えば、グループ0が1:100、グループ1が1:50、といった使い方をされます。これが距離測定で重要になってきます。

1.4 図面要素のパース

タグ付きデータブロックには線・円・弧・テキスト・寸法線・塗潰し・ポリライン・ハッチ・ブロック参照が含まれます。各要素はsealed classで表現しています。

sealed class JwwElement {
    abstract val layer: Int
    abstract val group: Int
    abstract val color: Int
    abstract val lineType: Int
    abstract val lineWidth: Float
}

data class JwwLine(
    override val layer: Int, override val group: Int,
    override val color: Int, override val lineType: Int,
    override val lineWidth: Float,
    val x1: Double, val y1: Double,
    val x2: Double, val y2: Double,
) : JwwElement()

data class JwwCircle(
    // ... 中心座標、半径、開始角度、終了角度
) : JwwElement()

data class JwwText(
    // ... 位置、文字列(Shift-JIS→UTF-8変換済み)、フォントサイズ、角度
) : JwwElement()

パース処理は約4,600行のKotlinコードで、31ファイルに分割しています。JWWフォーマットに公式仕様がないため、実際のJWWファイルを大量に読み込んで動作を確認しながら進めました。

2. OpenGL ES 3.0による図面描画

2.1 なぜOpenGLか

Android CanvasやJetpack ComposeのCanvasでも2D描画は可能ですが、建築図面は要素数が数万〜数十万に達します。Canvas描画では60fpsを維持できません。OpenGL ES 3.0ならVBO(頂点バッファオブジェクト)にまとめてGPUに一括送信できるため、パン・ズーム操作が滑らかになります。

2.2 座標変換: worldToScreen / screenToWorld

図面の座標系(ワールド座標、単位mm)と画面の座標系(ピクセル)の変換は、MVP行列を使います。

fun worldToScreen(wx: Double, wy: Double): Pair<Float, Float> {
    val input = floatArrayOf(wx.toFloat(), wy.toFloat(), 0f, 1f)
    val output = FloatArray(4)
    Matrix.multiplyMV(output, 0, mvpMatrix, 0, input, 0)
    
    val sx = (output[0] / output[3] + 1f) * 0.5f * viewportWidth
    val sy = (1f - output[1] / output[3]) * 0.5f * viewportHeight
    return Pair(sx, sy)
}

fun screenToWorld(sx: Float, sy: Float): Pair<Double, Double> {
    val ndcX = sx / viewportWidth * 2f - 1f
    val ndcY = 1f - sy / viewportHeight * 2f
    val invMvp = FloatArray(16)
    Matrix.invertM(invMvp, 0, mvpMatrix, 0)
    
    val input = floatArrayOf(ndcX, ndcY, 0f, 1f)
    val output = FloatArray(4)
    Matrix.multiplyMV(output, 0, invMvp, 0, input, 0)
    return Pair(
        (output[0] / output[3]).toDouble(),
        (output[1] / output[3]).toDouble()
    )
}

NDC(正規化デバイス座標)を経由する定番のパターンです。逆行列を求めるのは Matrix.invertM で一発ですが、行列が特異にならないようにゼロ除算チェックを入れています。

2.3 テキスト描画

OpenGLにはテキスト描画の機能がありません。AndroidのCanvas Paint で文字列をBitmapに描画し、それをテクスチャとしてGLに送っています。

JWWの文字列はShift-JISで格納されているため、パース時にUTF-8に変換しておく必要があります。

2.4 線種対応

JWWには10種類の線種(実線・破線・一点鎖線・二点鎖線・点線など)があります。OpenGL ESには破線描画の機能がないため、線を分割して描画しています。

3. ズームに依存しない距離測定

3.1 座標系の設計

距離測定はこのアプリの生命線です。ピンチズームやパンの操作中でも、測定値が変わってはいけません。

[画面座標 (px)]  ←→  [ビュー座標]  ←→  [ワールド座標 (mm)]
   タッチ入力        MVP行列          JWWファイルの座標

設計方針はシンプルです。

  1. タップ位置は 即座に screenToWorld() でワールド座標に変換
  2. マーカー位置は 常にワールド座標で保持
  3. 距離は ワールド座標の2点間 で計算
  4. 描画時に worldToScreen() で画面座標に逆変換
data class MeasureMarker(
    val worldX: Double,    // JWWワールド座標(mm) — ズームで変わらない
    val worldY: Double,
    val snapped: Boolean,
    val snapType: SnapType?,
    val groupIndex: Int,   // 縮尺計算に使うグループ番号
)

こうすることで、ズーム・パンを変えてもマーカーは図面上の同じ点に留まり、距離値は変化しません。

3.2 JWW縮尺の扱い

JWWの座標は「用紙上のmm」です。実際の寸法を得るには縮尺を掛けます。

実寸距離 = 用紙上の距離(mm) × 縮尺分母

例: 1:100の図面で用紙上25.0mm → 実寸 = 25.0 × 100 = 2,500mm

始点と終点が異なるグループに属する場合、縮尺が違うので実寸換算が意味を持ちません。そのケースでは警告を表示します。

object MeasureCalc {
    fun calculate(p1: MeasureMarker, p2: MeasureMarker, scales: DoubleArray): MeasureResult {
        val dx = p2.worldX - p1.worldX
        val dy = p2.worldY - p1.worldY
        val paperDist = hypot(dx, dy)

        val crossGroup = p1.groupIndex != p2.groupIndex
        val scaleGroup = if (!crossGroup) p1.groupIndex else 0
        val scaleFactor = scales.getOrElse(scaleGroup) { 1.0 }

        return MeasureResult(
            paperDistance = paperDist,
            realDistance = paperDist * scaleFactor,
            deltaX = abs(dx) * scaleFactor,
            deltaY = abs(dy) * scaleFactor,
            angleDeg = Math.toDegrees(atan2(dy, dx)),
            crossGroupWarning = crossGroup,
        )
    }
}

4. スナップ機能

4.1 4種類のスナップ

現場でラフにタップしても正確な測定ができるように、スナップ(自動吸着)機能を実装しています。

種別 対象
端点 線分・ポリラインの始点/終点
中点 線分の中央
中心点 円・弧の中心
交点 線-線、線-円の交差点

スナップ判定はワールド座標ベースで行います。画面上15dp以内にある候補点のうち最も近いものに吸着します。

fun findSnap(wx: Double, wy: Double, drawing: JwwDrawing): SnapResult {
    val allCandidates = mutableListOf<SnapResult>()
    for (type in enabledTypes) {
        allCandidates.addAll(findByType(type, wx, wy, drawing.elements))
    }
    return if (allCandidates.isEmpty()) {
        SnapResult(wx, wy, SnapType.FREE, 0.0)
    } else {
        allCandidates.minBy { it.distance }
    }
}

4.2 交点スナップ

交点スナップは少し面白い問題です。線分-線分の交点は連立方程式で求まりますが、両方の線分の範囲内に交点があるかどうかの判定が必要です。

線分-円弧の交差は、円の方程式と直線の方程式を連立して二次方程式を解きます。実装はかなり泥臭いコードになりますが、現場で「ここからここまで何mm?」を正確に知りたい人にとっては大事な機能です。

5. エクスポート機能

測定結果つきの図面を持ち帰れるように、3種類のエクスポートに対応しています。

形式 実装 用途
PDF Android PdfDocument API 印刷・共有用。用紙サイズはJWWヘッダから取得
PNG Bitmap + Canvas描画 → 画面解像度×2 メール・チャットで図面を共有
DXF テキスト出力 (R12 AC1009形式) AutoCAD等への変換

エクスポート時に測定マーカー・距離線を含めるかどうかを選択できます。

6. パッケージ構成

最終的なソース構成は以下の通りです。Kotlin 31ファイル、約4,700行。

com.sst.jwwmeasure/
├── parser/         JWWバイナリパーサー (JwwParser, MfcReader, JwwDrawing, JwwElements, JwwHeader)
├── renderer/       OpenGL描画 (JwwGLRenderer, TextOverlayView, TouchHandler, JwwColorMap)
├── measure/        距離測定 (MeasureState, MeasureMarker, MeasureTouchHandler,
│                    MeasureOverlay, MeasureCalc, MeasureResult, MeasureHistory)
├── snap/           スナップ (SnapManager)
├── layer/          レイヤー管理 (LayerManager)
├── exporter/       出力 (PdfExporter, ImageExporter, DxfExporter)
├── ui/             画面 (HomeActivity, ViewerActivity, SettingsActivity, 各View)
└── data/           設定・履歴 (AppSettings, RecentFiles)

7. 開発で得た知見

SAFとpersistable URI

Android 10以降、MANAGE_EXTERNAL_STORAGE は審査が厳しくなりました。SAF(Storage Access Framework)を使えばストレージ権限なしでファイルを開けます。

ただし、SAFで取得したURIは一時的なもの。「最近開いたファイル」から再度開けるようにするには、永続アクセス許可が必要です。

contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)

アプリ起動時に persistedUriPermissions で有効なURIを確認し、無効になったものは履歴から除外します。

Shift-JIS → UTF-8の落とし穴

JWWの古いバージョンはShift-JIS、新しいバージョンはUTF-16LEが混在します。Kotlinの Charset.forName("MS932") を使えばWindows-31J(Shift-JISの拡張)を正しくデコードできます。Shift_JIS ではなく MS932 を指定するのがポイントで、丸数字やローマ数字などの機種依存文字にも対応できます。

OpenGL ES + Compose の共存

Jetpack ComposeとOpenGL ESのGLSurfaceViewを同じ画面で使う場合、ComposeのレイアウトにGLSurfaceViewを AndroidView でラップして配置します。ツールバーやステータスバーはCompose、図面描画部分だけOpenGLという構成です。

おわりに

JWWファイルは公式仕様がないため、パーサーの実装は手探りの部分が多く、そこが一番大変でした。一方で、パーサーさえ書ければ描画・測定・エクスポートは定番の技術の組み合わせで実現できます。

完成したAPKは約19MBで、10MBのJWWファイルを3秒以内に読み込めています。

現場で図面を確認する場面がある方の参考になれば幸いです。

0
0
2

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?