本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
何をしたか
IIIF 3D Viewer は IIIF Manifest で配信される glTF/GLB の 3D モデルとアノテーションをブラウザで閲覧するための Next.js アプリです。これまでは IIIF Presentation API 3.0 のマニフェストに、3D 用の独自セレクタ (3DSelector) や camPos を載せた拡張形式を使っていました。
このたび、ビューワ側を IIIF 3D Technical Specification Group のドラフト (Presentation API 4 / temp-draft-4) に合わせて整理しました。具体的には、
- IIIF 3D TSG の例で使われている
Scene/PointSelector/WKTSelector/PerspectiveCameraを v4 形式の正として処理系全体で扱う - 既存の Presentation 3 + 独自拡張のマニフェストはランタイムで v4 へ変換する converter を通す
- サンプル
manifests/*.jsonも v4 形式に書き換える
という構成にしました。仕様自体はまだドラフトですが、TSG が公開している例 (9_commenting_annotations など) の構造に合わせておくことで、将来の標準化や他ビューワとの相互運用に備える狙いです。
既存形式 (Presentation 3 + 独自拡張) と v4 ドラフトの違い
整理のため両者を並べておきます。
マニフェスト全体
| 観点 | 旧 (P3 + 独自) | v4 / 3D TSG |
|---|---|---|
@context | http://iiif.io/api/presentation/3/context.json | http://iiif.io/api/presentation/4/context.json |
| トップ階層 | Manifest → items → Canvas | Manifest → items → Scene |
| 3D モデルの配置 | Canvas に painting で紐付け(位置情報なし) | Scene に painting + PointSelector で位置指定 |
| カメラ | annotation 内の selector.camPos | 独立した PerspectiveCamera Annotation |
| ポリゴン | 独自 area: [x,y,z,...] | WKTSelector + POLYGON Z((...)) |
| Georef | 独自 GeoJSON-T (motivation: "georeferencing") | v4 標準にはなし(独自拡張継続) |
アノテーション (点コメント) の例
旧形式:
{
"motivation": "commenting",
"body": { "value": "<p></p>", "label": "北海", "type": "TextualBody" },
"target": {
"source": ".../canvas-p1",
"selector": {
"type": "3DSelector",
"value": [-0.2557, 0.7615, -0.5854],
"camPos": [-0.378, 0.874, -0.911]
}
}
}
v4 形式:
{
"motivation": ["commenting"],
"bodyValue": "Right pterygoid hamulus",
"target": {
"type": "SpecificResource",
"source": [{ "id": ".../scene1", "type": "Scene" }],
"selector": [{ "type": "PointSelector", "x": 0.040, "y": 0.063, "z": -0.066 }]
}
}
motivation が配列、target が SpecificResource、source と selector がそれぞれ配列、PointSelector の座標が value: [x,y,z] ではなく {x, y, z} のオブジェクトプロパティ、というのが目立つ違いです。なお motivation を配列で書くことは v4 の規範文では明示要求されておらず、TSG の公式例で一貫して採用されている慣例です。
ポリゴン
旧形式は独自の area: [x,y,z,...] で頂点座標をフラットに並べていました。v4 ではポリゴンを Well-Known Text 文字列で表現します。仕様本文 (temp-draft-4) では PolygonZSelector という型名で定義されている一方、TSG が公開している whale_comment_point_polygon.json などの例では WKTSelector が使われており、現時点では命名が安定していません。本記事のビューワは例に揃えて WKTSelector を採用しています。
"selector": [
{
"type": "WKTSelector",
"value": "POLYGON Z ((0 0.18 -0.23, -0.03 0.16 -0.23, -0.015 0.12 -0.23, 0.006 0.12 -0.23, 0.027 0.16 -0.23))"
}
]
カメラ
旧形式では「このアノテーションを見るときに使う推奨カメラ位置」を selector.camPos に持たせていました。v4 では別の Annotation として独立させ、body に PerspectiveCamera を、target には Scene 上の PointSelector を置く形になります。
{
"motivation": ["painting"],
"body": {
"type": "SpecificResource",
"source": [{ "type": "PerspectiveCamera" }]
},
"target": {
"type": "SpecificResource",
"source": [{ "id": ".../scene1", "type": "Scene" }],
"selector": [{ "type": "PointSelector", "x": -0.378, "y": 0.874, "z": -0.911 }]
}
}
PerspectiveCamera 自体には fieldOfView / near / far といったプロパティが書け、これを包む SpecificResource(body)の側に transform(例: RotateTransform)を source と並列で置ける、というのがドラフト例の構造です。今回のビューワでは、まず最小構成の PerspectiveCamera だけ持たせ、コメントアノテーションとは id で紐付ける運用にしました。
アノテーションとカメラの紐付け
旧形式は 1 つの selector に value(モデル表面上の点)と camPos(推奨カメラ位置)が同居していたので、対応関係は自明でした。v4 ではこの 2 つが別々の Annotation に分かれるため、明示的な紐付けが必要になります。
このビューワでは以下の運用で紐付けています:
Scene "scene-p1"
│
├─ AnnotationPage "page/comments"
│ └─ Annotation id: "anno-1" motivation: ["commenting"]
│ body: TextualBody { label: "北海" }
│ target: PointSelector { x, y, z } ← モデル表面上の点
│ cameraAnnotation: "anno-1/camera" ─┐
│ │ id で参照
└─ AnnotationPage "page/cameras" │
└─ Annotation id: "anno-1/camera" ←─────┘ motivation: ["painting"]
body: SpecificResource → { type: "PerspectiveCamera" }
target: PointSelector { x, y, z } ← 推奨カメラ位置
- コメントアノテーション側は モデル表面の点(マーカーが立つ位置)を
target.selectorに持つ - 対応する カメラ位置 は別 Annotation の
target.selectorに持つ - 2 つの Annotation を結ぶのは
cameraAnnotationプロパティ(独自拡張)と${commentingId}/cameraの命名規約 - パーサ側はまず Scene 内の
PerspectiveCameraAnnotation をid → [x,y,z]で索引にし、各コメントのcameraAnnotation(または${id}/camera)でカメラ座標を引く
cameraAnnotation は v4 仕様で定義された語彙ではなく、このビューワが採用した独自プロパティです。仕様の進展に応じて、より標準的な紐付け方法(例えば target の source 配列で双方向参照する、refinedBy を使う、など)に差し替える余地があります。
移行の方針
ビューワ側を v4 単一構造に揃えつつ、旧形式のマニフェスト(既存エディタが書き出すもの)も読めるようにしたかったので、入口で形式変換する構成にしました。
fetchManifest(url)
→ convertToV4(raw) // P3 + 独自 → v4 (in-memory)
→ parseManifestV4(v4) // v4 → 内部 Annotation[] / GeoFeature[]
→ 各 React コンポーネント
内部表現 (Annotation 型) は今までどおりで、UI 側は触りません。IIIF 形式 ↔ 内部表現の境界だけを v4 に揃え直す形です。
ファイル構成
追加した主なファイル:
src/types/iiif.d.ts— v4 用の TypeScript 型 (ManifestV4,SceneV4,PointSelectorV4,WKTSelectorV4,SpecificResourceV4,AnnotationV4など)src/lib/services/manifestConverter.ts—convertToV4(unknown): ManifestV4src/lib/services/manifestParser.ts—parseManifestV4(ManifestV4)で{ modelUrl, annotations, geoFeatures }を返す
書き換えた箇所:
ViewerContent.tsx/GeoRefContent.tsx— 旧パース処理をconvertToV4 → parseManifestV4の 2 行に置換Annotations.tsx— マーカー切り替えの判定'3DSelector'→'PointSelector'manifestAtom—Manifest(@iiif/presentation-3)→ManifestV4public/manifests/sample-manifest*.json— v4 形式に書き換え
Converter の中身
旧形式のマニフェストを受け取って v4 形式の Plain Object を返す関数です。@context を見て v4 ならパススルー、そうでなければ Canvas を Scene に詰め替え、selector を変換、camPos から PerspectiveCamera Annotation を生成します。
主要部のみ抜粋すると、コメントアノテーションの変換はこんな実装です(実装では bodyValue や seeAlso の passthrough も行いますが、ここでは省いています)。
const convertCommentingAnnotation = (
anno: LegacyAnnotation,
sceneId: string,
): { annotation: AnnotationV4; camera: AnnotationV4 | null } => {
const target = (anno.target ?? {}) as LegacyTargetObject;
const selector: LegacySelector = (typeof target === 'object' && target.selector) || {};
const value = asTriple(selector.value);
const camPos = asTriple(selector.camPos);
const area = selector.area && selector.area.length >= 9 ? selector.area : null;
const v4Selector = area
? { type: 'WKTSelector' as const, value: buildWktPolygon(area) }
: value
? buildPointSelector(value)
: null;
const annotationId =
anno.id ?? `${sceneId}/anno/${Math.random().toString(36).slice(2, 10)}`;
const v4Target: SpecificResourceV4 = {
type: 'SpecificResource',
source: buildSceneSource(sceneId),
...(v4Selector ? { selector: [v4Selector] } : {}),
};
const annotation: AnnotationV4 = {
id: annotationId,
type: 'Annotation',
motivation: normalizeMotivation(anno.motivation).length
? normalizeMotivation(anno.motivation)
: ['commenting'],
target: v4Target,
...(anno.body !== undefined ? { body: anno.body as AnnotationV4['body'] } : {}),
...(camPos ? { cameraAnnotation: `${annotationId}/camera` } : {}),
};
if (!camPos) return { annotation, camera: null };
const camera: AnnotationV4 = {
id: `${annotationId}/camera`,
type: 'Annotation',
motivation: ['painting'],
body: {
type: 'SpecificResource',
source: [{ type: 'PerspectiveCamera' }],
},
target: {
type: 'SpecificResource',
source: buildSceneSource(sceneId),
selector: [buildPointSelector(camPos)],
},
};
return { annotation, camera };
};
ポイントは:
- 旧
areaは 3D 頂点を flat に並べた配列だったので、POLYGON Z((x y z, x y z, ...))の WKT 文字列に組み直す - 旧
camPosは別 Annotation に切り出す。元のアノテーションにはcameraAnnotationという独自プロパティで対応する camera Annotation のidを入れて参照を残す(${annotationId}/cameraという命名規約) motivationは配列に正規化、targetはSpecificResource+ 配列source/selectorに変換
georeferencing の Annotation(GeoJSON-T body を持つもの)は v4 標準にないので、旧形式の body をそのままにしつつ target だけ v4 の SpecificResource 形に書き換えてパススルーしています。
スモークテストでは旧形式の最小マニフェストを通してみて、
{
"@context": "http://iiif.io/api/presentation/4/context.json",
"items": [{
"type": "Scene",
"items": [{ "type": "AnnotationPage", "items": [/* model painting */] }],
"annotations": [
{ "type": "AnnotationPage", "items": [/* commenting */] },
{ "type": "AnnotationPage", "items": [/* PerspectiveCamera */] }
]
}]
}
のように、Canvas → Scene、3DSelector → PointSelector、camPos → 別ページに切り出した PerspectiveCamera、と意図どおり置換されることを確認しました。
Parser の中身
v4 マニフェストを内部表現に落とす側です。Scene.items から painting / Model body を見つけて modelUrl に、Scene.annotations を平坦化して commenting / georeferencing / PerspectiveCamera の 3 種類に振り分けます。
PerspectiveCamera は単独で Map<string, [x,y,z]> のインデックスを作っておき、各コメントアノテーションが cameraAnnotation で参照している(または ${id}/camera の規約で書かれている)id をキーに引けるようにしました。WKTSelector は POLYGON Z((...)) を緩めにパースして、内部表現の area フラット配列に戻し、position は重心にしています(マーカーの代表点として使う)。
const parseWktPolygonZ = (wkt: string): number[] => {
const open = wkt.indexOf('((');
const close = wkt.indexOf('))', open);
if (open < 0 || close < 0) return [];
const ring = wkt.slice(open + 2, close);
const out: number[] = [];
for (const raw of ring.split(',')) {
const [x, y, z] = raw.trim().split(/\s+/).map(Number);
if ([x, y, z].every((n) => Number.isFinite(n))) out.push(x, y, z);
}
return out;
};
WKT の本来のパーサとしてはかなり手抜きですが、自前の converter が出力する POLYGON Z((..)) を読み戻すには十分です。
サンプルマニフェスト
public/manifests/sample-manifest-with-annotations.json を v4 形式に書き換えました。GeoJSON-T の georeferencing は独自拡張のままですが、target は v4 の SpecificResource に揃えています。コメントアノテーションは motivation: ["commenting"]、PointSelector { x, y, z }、cameraAnnotation で対応する PerspectiveCamera を参照、というスタイルです。
なお body の書き方には、本記事の冒頭で取り上げた TSG 例のように bodyValue: "..." プレーン文字列を使うやり方と、body: { type: "TextualBody", value, label } のように Web Annotation の TextualBody を使うやり方の両方があります。サンプルマニフェストは既存 UI(HTML 値とラベルを別フィールドで扱う)に合わせて TextualBody を採用していますが、parser は両方の形を受け付けるようにしてあります。
残課題
- IIIF 3D TSG はまだ
temp-draft-4のドラフト段階で、TSG 内の commenting annotations 例 (9_commenting_annotations) もbodyValueプレーンテキストの利用が中心。今後の確定でbodyの構造や camera annotation の表現が変わる可能性がある - Linked Data による意味付け(Wikidata 等の URI を
bodyに持たせる方向)や Georeference Extension の 3D 対応は v4 標準でカバーされていないため、現状は独自拡張を続けるしかない