はじめに
建築・設備・電気工事の現場では、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ファイルの座標
設計方針はシンプルです。
- タップ位置は 即座に
screenToWorld()でワールド座標に変換 - マーカー位置は 常にワールド座標で保持
- 距離は ワールド座標の2点間 で計算
- 描画時に
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種類のエクスポートに対応しています。
| 形式 | 実装 | 用途 |
|---|---|---|
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秒以内に読み込めています。
現場で図面を確認する場面がある方の参考になれば幸いです。