本記事は生成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
@contexthttp://iiif.io/api/presentation/3/context.jsonhttp://iiif.io/api/presentation/4/context.json
トップ階層ManifestitemsCanvasManifestitemsScene
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 が配列、targetSpecificResourcesourceselector がそれぞれ配列、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 として独立させ、bodyPerspectiveCamera を、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 といったプロパティが書け、これを包む SpecificResourcebody)の側に 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 内の PerspectiveCamera Annotation を id → [x,y,z] で索引にし、各コメントの cameraAnnotation(または ${id}/camera)でカメラ座標を引く

cameraAnnotation は v4 仕様で定義された語彙ではなく、このビューワが採用した独自プロパティです。仕様の進展に応じて、より標準的な紐付け方法(例えば targetsource 配列で双方向参照する、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.tsconvertToV4(unknown): ManifestV4
  • src/lib/services/manifestParser.tsparseManifestV4(ManifestV4){ modelUrl, annotations, geoFeatures } を返す

書き換えた箇所:

  • ViewerContent.tsx / GeoRefContent.tsx — 旧パース処理を convertToV4 → parseManifestV4 の 2 行に置換
  • Annotations.tsx — マーカー切り替えの判定 '3DSelector''PointSelector'
  • manifestAtomManifest@iiif/presentation-3)→ ManifestV4
  • public/manifests/sample-manifest*.json — v4 形式に書き換え

Converter の中身

旧形式のマニフェストを受け取って v4 形式の Plain Object を返す関数です。@context を見て v4 ならパススルー、そうでなければ Canvas を Scene に詰め替え、selector を変換、camPos から PerspectiveCamera Annotation を生成します。

主要部のみ抜粋すると、コメントアノテーションの変換はこんな実装です(実装では bodyValueseeAlso の 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 は配列に正規化、targetSpecificResource + 配列 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 をキーに引けるようにしました。WKTSelectorPOLYGON 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 標準でカバーされていないため、現状は独自拡張を続けるしかない

参考