core_geometry_GeometryRenderer.js - hiro-nyon/cesium-heatbox GitHub Wiki
日本語 | English
See also: Class: GeometryRenderer
import * as Cesium from 'cesium';
import { Logger } from '../../utils/logger.js';
import { escapeHtml } from '../../utils/escapeHtml.js';
/**
* GeometryRenderer - Creates Cesium entities consumed by VoxelRenderer.
* ジオメトリレンダラー - VoxelRenderer が利用する Cesium エンティティを生成・管理
*
* Responsibilities:
* - ボクセルボックス描画 (Voxel box rendering)
* - インセット枠線描画 (Inset outline rendering)
* - エッジポリライン描画 (Edge polyline rendering for emulation)
* - エンティティライフサイクル管理 (Entity lifecycle management)
*
* ADR-0009 Phase 4
* @version 0.1.15
*/
export class GeometryRenderer {
/**
* GeometryRenderer constructor
* @param {Cesium.Viewer} viewer - CesiumJS Viewer instance / CesiumJS Viewerインスタンス
* @param {Object} options - Rendering options / 描画オプション
*/
constructor(viewer, options = {}) {
this.viewer = viewer;
this.options = {
// Geometry rendering defaults
wireframeOnly: false,
showOutline: true,
outlineWidth: 2,
outlineInset: 0,
outlineInsetMode: 'all',
outlineRenderMode: 'standard',
enableThickFrames: false,
...options
};
// Entity management
this.entities = [];
Logger.debug('GeometryRenderer initialized with viewer and options:', this.options);
}
/**
* Create a voxel box entity
* ボクセルボックスエンティティを作成
*
* @param {Object} config - Voxel configuration / ボクセル設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `cellSizeX`, `cellSizeY` (number) / フットプリント寸法、
* `boxHeight` (number) / ボックス高さ、
* `color` (Cesium.Color) / ボックス色、
* `opacity` (number) / ボックス透明度、
* `shouldShowOutline` (boolean) / 枠線表示、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `voxelInfo` (Object) / ボクセルデータ、
* `voxelKey` (string) / ボクセルキー、
* `emulateThick` (boolean, optional) / 太線エミュレーション使用
* @returns {Cesium.Entity} Created voxel entity / 作成されたボクセルエンティティ
*/
createVoxelBox(config) {
const {
centerLon, centerLat, centerAlt,
cellSizeX, cellSizeY, boxHeight,
color, opacity,
shouldShowOutline, outlineColor, outlineWidth,
voxelInfo, voxelKey,
emulateThick = false
} = config;
// Validate coordinate inputs to prevent Cesium RangeError
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-90, Math.min(90, centerLat)) : 0;
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
// Validate dimension inputs to prevent Cesium geometry errors
const safeCellSizeX = Number.isFinite(cellSizeX) && cellSizeX > 0 ? Math.min(cellSizeX, 1e6) : 1;
const safeCellSizeY = Number.isFinite(cellSizeY) && cellSizeY > 0 ? Math.min(cellSizeY, 1e6) : 1;
const safeBoxHeight = Number.isFinite(boxHeight) && boxHeight > 0 ? Math.min(boxHeight, 1e6) : 1;
// Log warning if values were clamped
if (safeCenterLon !== centerLon || safeCenterLat !== centerLat || safeCenterAlt !== centerAlt ||
safeCellSizeX !== cellSizeX || safeCellSizeY !== cellSizeY || safeBoxHeight !== boxHeight) {
Logger.warn(`Clamped invalid geometry values for voxel ${voxelKey}:`, {
original: { centerLon, centerLat, centerAlt, cellSizeX, cellSizeY, boxHeight },
clamped: { safeCenterLon, safeCenterLat, safeCenterAlt, safeCellSizeX, safeCellSizeY, safeBoxHeight }
});
}
// Entity configuration with safe values
const showOutline = Boolean(shouldShowOutline && !emulateThick);
const boxConfig = {
dimensions: new Cesium.Cartesian3(safeCellSizeX, safeCellSizeY, safeBoxHeight),
outline: showOutline
};
if (showOutline) {
boxConfig.outlineColor = outlineColor;
boxConfig.outlineWidth = Math.max(outlineWidth || 1, 1);
}
const entityConfig = {
position: Cesium.Cartesian3.fromDegrees(safeCenterLon, safeCenterLat, safeCenterAlt),
box: boxConfig,
properties: {
type: 'voxel',
key: voxelKey,
count: voxelInfo.count,
x: voxelInfo.x,
y: voxelInfo.y,
z: voxelInfo.z
},
description: this.createVoxelDescription(voxelInfo, voxelKey)
};
// v0.1.17: Add spatial ID to properties if available / 空間IDがあればプロパティに追加
if (voxelInfo.spatialId) {
entityConfig.properties.spatialId = voxelInfo.spatialId;
}
// v0.1.18: Add layer aggregation info to properties (ADR-0014)
if (voxelInfo.layerTop) {
entityConfig.properties.layerTop = voxelInfo.layerTop;
}
if (voxelInfo.layerStats) {
// Convert Map to plain object for Cesium properties
// MapをCesiumプロパティ用のプレーンオブジェクトに変換
const layerStatsObj = {};
for (const [key, count] of voxelInfo.layerStats) {
layerStatsObj[key] = count;
}
entityConfig.properties.layerStats = layerStatsObj;
}
// Material configuration based on wireframe mode and render mode
const hideBox = this.options.wireframeOnly || this.options.outlineRenderMode === 'emulation-only';
if (hideBox) {
entityConfig.box.material = Cesium.Color.TRANSPARENT;
entityConfig.box.fill = false;
} else {
entityConfig.box.material = color.withAlpha(opacity);
entityConfig.box.fill = true;
}
// Create and track entity
const entity = this.viewer.entities.add(entityConfig);
this.entities.push(entity);
return entity;
}
/**
* Create inset outline for a voxel
* ボクセルのインセット枠線を作成
*
* @param {Object} config - Inset outline configuration / インセット枠線設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `baseSizeX`, `baseSizeY`, `baseSizeZ` (number) / ベース寸法、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `voxelKey` (string) / ボクセルキー、
* `insetAmount` (number, optional) / カスタムインセット量
* @returns {Cesium.Entity} Created inset outline entity / 作成されたインセット枠線エンティティ
*/
createInsetOutline(config) {
const {
centerLon, centerLat, centerAlt,
baseSizeX, baseSizeY, baseSizeZ,
outlineColor, outlineWidth, voxelKey,
insetAmount = null
} = config;
// Validate inputs for inset outline to prevent Cesium errors
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-90, Math.min(90, centerLat)) : 0;
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
const safeBaseSizeX = Number.isFinite(baseSizeX) && baseSizeX > 0 ? Math.min(baseSizeX, 1e6) : 1;
const safeBaseSizeY = Number.isFinite(baseSizeY) && baseSizeY > 0 ? Math.min(baseSizeY, 1e6) : 1;
const safeBaseSizeZ = Number.isFinite(baseSizeZ) && baseSizeZ > 0 ? Math.min(baseSizeZ, 1e6) : 1;
// インセット距離の適用(ADR-0004の境界条件:両側合計で各軸寸法の最大40%まで=片側20%)
// 片側20%までに制限することで、最終寸法は元の60%以上を保証する
const maxInsetX = safeBaseSizeX * 0.2;
const maxInsetY = safeBaseSizeY * 0.2;
const maxInsetZ = safeBaseSizeZ * 0.2;
const baseInset = insetAmount !== null ? insetAmount : this.options.outlineInset;
const effectiveInsetX = Math.min(baseInset, maxInsetX);
const effectiveInsetY = Math.min(baseInset, maxInsetY);
const effectiveInsetZ = Math.min(baseInset, maxInsetZ);
// インセット後の寸法計算(各軸から2倍のインセットを引く)
const insetSizeX = Math.max(safeBaseSizeX - (effectiveInsetX * 2), safeBaseSizeX * 0.1);
const insetSizeY = Math.max(safeBaseSizeY - (effectiveInsetY * 2), safeBaseSizeY * 0.1);
const insetSizeZ = Math.max(safeBaseSizeZ - (effectiveInsetZ * 2), safeBaseSizeZ * 0.1);
// セカンダリBoxエンティティの設定(枠線のみ、塗りなし)
const insetEntity = this.viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(safeCenterLon, safeCenterLat, safeCenterAlt),
box: {
dimensions: new Cesium.Cartesian3(insetSizeX, insetSizeY, insetSizeZ),
fill: false,
outline: true,
outlineColor: outlineColor,
// CesiumのBox outlineWidthは0や負値で不安定になるため最低1にクランプ
outlineWidth: Math.max(outlineWidth || 1, 1)
},
properties: {
type: 'voxel-inset-outline',
parentKey: voxelKey,
insetSize: { x: insetSizeX, y: insetSizeY, z: insetSizeZ }
}
});
this.entities.push(insetEntity);
// 枠線の厚み部分を視覚化(WebGL 1px制限の回避)
if (this.options.enableThickFrames && (effectiveInsetX > 0.1 || effectiveInsetY > 0.1 || effectiveInsetZ > 0.1)) {
this.createThickOutlineFrames({
centerLon: safeCenterLon, centerLat: safeCenterLat, centerAlt: safeCenterAlt,
outerX: safeBaseSizeX, outerY: safeBaseSizeY, outerZ: safeBaseSizeZ,
innerX: insetSizeX, innerY: insetSizeY, innerZ: insetSizeZ,
frameColor: outlineColor, voxelKey
});
}
Logger.debug(`Inset outline created for voxel ${voxelKey}:`, {
originalSize: { x: baseSizeX, y: baseSizeY, z: baseSizeZ },
insetSize: { x: insetSizeX, y: insetSizeY, z: insetSizeZ },
effectiveInset: { x: effectiveInsetX, y: effectiveInsetY, z: effectiveInsetZ }
});
return insetEntity;
}
/**
* Create thick outline frame structures
* 枠線の厚み部分を視覚化するフレーム構造を作成
*
* @param {Object} config - Frame configuration / フレーム設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `outerX`, `outerY`, `outerZ` (number) / 外枠寸法、
* `innerX`, `innerY`, `innerZ` (number) / 内枠寸法、
* `frameColor` (Cesium.Color) / フレーム色、
* `voxelKey` (string) / ボクセルキー
* @returns {Array<Cesium.Entity>} Created frame entities / 作成されたフレームエンティティ配列
*/
createThickOutlineFrames(config) {
const {
centerLon, centerLat, centerAlt,
outerX, outerY, outerZ,
innerX, innerY, innerZ,
frameColor, voxelKey
} = config;
// フレーム厚み計算(外側と内側の差を2で割ったもの)
let frameThickX = (outerX - innerX) / 2;
let frameThickY = (outerY - innerY) / 2;
let frameThickZ = (outerZ - innerZ) / 2;
// 厚みが0や負になるケースを防止(ゼロ寸法BoxはCesiumで不安定)
const minFrame = 0.05;
if (frameThickX <= 0 || frameThickY <= 0 || frameThickZ <= 0) {
Logger.warn(`Invalid frame thickness for voxel ${voxelKey}, skipping thick frames`);
return [];
}
frameThickX = Math.max(frameThickX, minFrame);
frameThickY = Math.max(frameThickY, minFrame);
frameThickZ = Math.max(frameThickZ, minFrame);
// 境界計算:各軸での内側・外側の境界
const outerBoundX = outerX / 2; // 外側境界(中心からの距離)
const outerBoundY = outerY / 2;
const outerBoundZ = outerZ / 2;
// 内側境界は以降の処理では直接使用しないため計算を省略
const frameEntities = [];
// 12個のフレームボックスを作成
// X軸に平行なフレーム(4本)
const xFrames = [
{ y: outerBoundY, z: outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: -outerBoundY, z: outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: outerBoundY, z: -outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: -outerBoundY, z: -outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ }
];
// Y軸に平行なフレーム(4本)
const yFrames = [
{ x: outerBoundX, z: outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: -outerBoundX, z: outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: outerBoundX, z: -outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: -outerBoundX, z: -outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ }
];
// Z軸に平行なフレーム(4本)
const zFrames = [
{ x: outerBoundX, y: outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: -outerBoundX, y: outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: outerBoundX, y: -outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: -outerBoundX, y: -outerBoundY, sizeX: frameThickX, sizeY: frameThickY }
];
// X軸フレームを作成
xFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon,
centerLat + (frame.y / 111320), // 緯度変換(概算)
centerAlt + frame.z
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(outerX, frame.sizeY, frame.sizeZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-x',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
// Y軸フレームを作成
yFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon + (frame.x / (111320 * Math.cos(centerLat * Math.PI / 180))), // 経度変換(概算)
centerLat,
centerAlt + frame.z
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(frame.sizeX, outerY, frame.sizeZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-y',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
// Z軸フレームを作成
zFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon + (frame.x / (111320 * Math.cos(centerLat * Math.PI / 180))), // 経度変換(概算)
centerLat + (frame.y / 111320), // 緯度変換(概算)
centerAlt
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(frame.sizeX, frame.sizeY, outerZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-z',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
Logger.debug(`Created ${frameEntities.length} thick frame entities for voxel ${voxelKey}`);
return frameEntities;
}
/**
* Create edge polylines for thick outline emulation
* 太線エミュレーション用のエッジポリライン作成
*
* @param {Object} config - Edge polyline configuration / エッジポリライン設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `cellSizeX`, `cellSizeY` (number) / フットプリント寸法、
* `boxHeight` (number) / ボックス高さ、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `outlineOpacity` (number) / 枠線透明度、
* `voxelKey` (string) / ボクセルキー
* @returns {Array<Cesium.Entity>} Created polyline entities / 作成されたポリラインエンティティ配列
*/
createEdgePolylines(config) {
const {
centerLon, centerLat, centerAlt,
cellSizeX, cellSizeY, boxHeight,
outlineColor, outlineWidth, outlineOpacity = null, voxelKey
} = config;
const polylineEntities = [];
// Validate inputs for edge polylines to prevent coordinate calculation errors
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-85, Math.min(85, centerLat)) : 0; // Avoid poles more strictly
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
const safeCellSizeX = Number.isFinite(cellSizeX) && cellSizeX > 0 ? Math.min(cellSizeX, 1e5) : 1; // More conservative limits
const safeCellSizeY = Number.isFinite(cellSizeY) && cellSizeY > 0 ? Math.min(cellSizeY, 1e5) : 1;
const safeBoxHeight = Number.isFinite(boxHeight) && boxHeight > 0 ? Math.min(boxHeight, 1e5) : 1;
// Early validation: check if dimensions are reasonable
if (safeCellSizeX < 0.001 || safeCellSizeY < 0.001 || safeBoxHeight < 0.001) {
Logger.warn(`Dimensions too small for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// ボックスの8つの頂点を計算 - 安全な値を使用
const halfX = safeCellSizeX / 2;
const halfY = safeCellSizeY / 2;
const halfZ = safeBoxHeight / 2;
// 座標変換係数の安全な計算 - より保守的なアプローチ
const cosLat = Math.cos(safeCenterLat * Math.PI / 180);
const safeCosFactor = Math.max(0.1, Math.abs(cosLat)); // より保守的な最小値
// 計算された座標オフセットの事前検証
const lonOffset = halfX / (111320 * safeCosFactor);
const latOffset = halfY / 111320;
if (!Number.isFinite(lonOffset) || !Number.isFinite(latOffset) ||
Math.abs(lonOffset) > 0.1 || Math.abs(latOffset) > 0.1) {
Logger.warn(`Coordinate offsets out of range for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// 頂点座標の事前計算とバリデーション
const vertexCoords = [
// 下面の4頂点
[safeCenterLon - lonOffset, safeCenterLat - latOffset, safeCenterAlt - halfZ],
[safeCenterLon + lonOffset, safeCenterLat - latOffset, safeCenterAlt - halfZ],
[safeCenterLon + lonOffset, safeCenterLat + latOffset, safeCenterAlt - halfZ],
[safeCenterLon - lonOffset, safeCenterLat + latOffset, safeCenterAlt - halfZ],
// 上面の4頂点
[safeCenterLon - lonOffset, safeCenterLat - latOffset, safeCenterAlt + halfZ],
[safeCenterLon + lonOffset, safeCenterLat - latOffset, safeCenterAlt + halfZ],
[safeCenterLon + lonOffset, safeCenterLat + latOffset, safeCenterAlt + halfZ],
[safeCenterLon - lonOffset, safeCenterLat + latOffset, safeCenterAlt + halfZ]
];
// 全ての座標が有効範囲内かチェック
const allCoordsValid = vertexCoords.every(([lon, lat, alt]) =>
Number.isFinite(lon) && Number.isFinite(lat) && Number.isFinite(alt) &&
lon >= -180 && lon <= 180 && lat >= -85 && lat <= 85 &&
alt >= -50000 && alt <= 500000
);
if (!allCoordsValid) {
Logger.warn(`Invalid vertex coordinates for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// Cartesian3頂点の安全な作成
let vertices;
try {
vertices = vertexCoords.map(([lon, lat, alt]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, alt)
);
} catch (error) {
Logger.warn(`Failed to create Cartesian3 vertices for voxel ${voxelKey}:`, error);
return polylineEntities;
}
// 作成された頂点の最終検証
const validVertices = vertices.every(vertex =>
vertex && Number.isFinite(vertex.x) && Number.isFinite(vertex.y) && Number.isFinite(vertex.z)
);
if (!validVertices) {
Logger.warn(`Generated vertices contain invalid values for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// ボックスの12エッジを定義
const edges = [
// 下面の4エッジ
[0, 1], [1, 2], [2, 3], [3, 0],
// 上面の4エッジ
[4, 5], [5, 6], [6, 7], [7, 4],
// 垂直の4エッジ
[0, 4], [1, 5], [2, 6], [3, 7]
];
// 各エッジをポリラインとして作成
edges.forEach((edge, index) => {
try {
const vertex0 = vertices[edge[0]];
const vertex1 = vertices[edge[1]];
// 追加の安全性チェック
if (!vertex0 || !vertex1) {
Logger.warn(`Missing vertices for edge ${index} in voxel ${voxelKey}`);
return;
}
const positions = [vertex0, vertex1];
// positions配列の最終検証
if (positions.length !== 2) {
Logger.warn(`Invalid positions array length for edge ${index} in voxel ${voxelKey}`);
return;
}
let effectiveMaterial = outlineColor;
if (outlineOpacity !== null && outlineOpacity !== undefined && typeof outlineColor?.withAlpha === 'function') {
const clampedOpacity = Math.max(0, Math.min(1, outlineOpacity));
effectiveMaterial = outlineColor.withAlpha(clampedOpacity);
}
const safeOutlineWidth = Number.isFinite(outlineWidth) ? outlineWidth : 1;
const polylineEntity = this.viewer.entities.add({
polyline: {
positions: positions,
width: Math.max(Math.min(safeOutlineWidth, 20), 1), // width制限も追加
material: effectiveMaterial,
clampToGround: false
},
properties: {
type: 'voxel-edge-polyline',
parentKey: voxelKey,
edgeIndex: index
}
});
this.entities.push(polylineEntity);
polylineEntities.push(polylineEntity);
} catch (error) {
Logger.warn(`Failed to create polyline for edge ${index} in voxel ${voxelKey}:`, error);
// エラーが発生しても処理を継続(他のエッジは正常に作成される可能性がある)
}
});
Logger.debug(`Created ${polylineEntities.length} edge polylines for voxel ${voxelKey}`);
return polylineEntities;
}
/**
* Create voxel description HTML
* ボクセルの説明HTMLを生成
*
* @param {Object} voxelInfo - Voxel information / ボクセル情報
* @param {string} voxelKey - Voxel key / ボクセルキー
* @returns {string} HTML description / HTML形式の説明文
*/
createVoxelDescription(voxelInfo, voxelKey) {
// v0.1.17: Include spatial ID in description if available / 空間IDがあればdescriptionに含める
const spatialIdInfo = voxelInfo.spatialId ? `
<tr><td><b>空間ID:</b></td><td>${escapeHtml(voxelInfo.spatialId.id)}</td></tr>
<tr><td><b>ズームレベル:</b></td><td>Z=${escapeHtml(String(voxelInfo.spatialId.z))}, F=${escapeHtml(String(voxelInfo.spatialId.f))}</td></tr>
<tr><td><b>タイル座標:</b></td><td>X=${escapeHtml(String(voxelInfo.spatialId.x))}, Y=${escapeHtml(String(voxelInfo.spatialId.y))}</td></tr>
` : '';
// v0.1.18: Include layer breakdown if aggregation enabled and showInDescription is true (ADR-0014)
let layerInfo = '';
const showLayerInfo = this.options.aggregation?.enabled &&
this.options.aggregation?.showInDescription !== false &&
voxelInfo.layerStats &&
voxelInfo.layerStats.size > 0;
if (showLayerInfo) {
// Escape layer keys to prevent XSS / XSS防止のためレイヤキーをエスケープ
const layerRows = Array.from(voxelInfo.layerStats.entries())
.sort((a, b) => b[1] - a[1]) // Sort by count descending / カウント降順でソート
.map(([key, count]) => {
const pct = voxelInfo.count > 0 ? ((count / voxelInfo.count) * 100).toFixed(1) : '0.0';
return `
<tr><td style="padding-left: 10px;">${escapeHtml(key)}</td><td>${count} (${pct}%)</td></tr>
`;
}).join('');
const topLayerInfo = voxelInfo.layerTop ? `
<tr><td><b>支配的レイヤ:</b></td><td>${escapeHtml(voxelInfo.layerTop)}</td></tr>
` : '';
layerInfo = `
${topLayerInfo}
<tr><td colspan="2"><b>レイヤ内訳:</b></td></tr>
${layerRows}
`;
}
return `
<div style="padding: 10px; font-family: Arial, sans-serif;">
<h3 style="margin-top: 0;">ボクセル [${voxelInfo.x}, ${voxelInfo.y}, ${voxelInfo.z}]</h3>
<table style="width: 100%;">
<tr><td><b>エンティティ数:</b></td><td>${voxelInfo.count}</td></tr>
<tr><td><b>ボクセルキー:</b></td><td>${escapeHtml(voxelKey)}</td></tr>
<tr><td><b>座標:</b></td><td>X=${voxelInfo.x}, Y=${voxelInfo.y}, Z=${voxelInfo.z}</td></tr>${spatialIdInfo}${layerInfo}
</table>
<p style="margin-bottom: 0;">
<small>v0.1.18 GeometryRenderer (Layer aggregation support)</small>
</p>
</div>
`;
}
/**
* Check if inset outline should be applied
* インセット枠線を適用すべきかどうかを判定
*
* @param {boolean} isTopN - Is TopN voxel / TopNボクセルかどうか
* @returns {boolean} Should apply inset outline / インセット枠線を適用する場合はtrue
*/
shouldApplyInsetOutline(isTopN) {
const mode = this.options.outlineInsetMode || 'all';
switch (mode) {
case 'topn':
return isTopN;
case 'all':
default:
return true;
case 'none':
return false;
}
}
/**
* Clear all managed entities
* 管理対象の全エンティティをクリア
*/
clear() {
Logger.debug('GeometryRenderer.clear - Removing', this.entities.length, 'entities');
this.entities.forEach(entity => {
try {
// entityとisDestroyedのチェックを安全に行う
const isDestroyed = entity && typeof entity.isDestroyed === 'function' ? entity.isDestroyed() : false;
if (entity && !isDestroyed) {
this.viewer.entities.remove(entity);
}
} catch (error) {
Logger.warn('Entity removal error:', error);
}
});
this.entities = [];
}
/**
* Render a debug bounding box for given bounds
* 指定された境界のデバッグ用バウンディングボックスを描画
* @param {Object} bounds - {minLon, maxLon, minLat, maxLat, minAlt, maxAlt}
*/
renderBoundingBox(bounds) {
if (!bounds) return;
try {
const centerLon = (bounds.minLon + bounds.maxLon) / 2;
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
const centerAlt = (bounds.minAlt + bounds.maxAlt) / 2;
const widthMeters = (bounds.maxLon - bounds.minLon) * 111000 * Math.cos(centerLat * Math.PI / 180);
const depthMeters = (bounds.maxLat - bounds.minLat) * 111000;
const heightMeters = bounds.maxAlt - bounds.minAlt;
const boundingBox = this.viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, centerAlt),
box: {
dimensions: new Cesium.Cartesian3(widthMeters, depthMeters, heightMeters),
material: Cesium.Color.YELLOW.withAlpha(0.1),
outline: true,
outlineColor: Cesium.Color.YELLOW.withAlpha(0.3),
outlineWidth: 2
},
description: `Bounding Box<br>Size: ${widthMeters.toFixed(1)} x ${depthMeters.toFixed(1)} x ${heightMeters.toFixed(1)} m`
});
this.entities.push(boundingBox);
Logger.debug('Debug bounding box added:', {
center: { lon: centerLon, lat: centerLat, alt: centerAlt },
size: { width: widthMeters, depth: depthMeters, height: heightMeters }
});
} catch (error) {
Logger.warn('Failed to render bounding box:', error);
}
}
/**
* Get entity count
* エンティティ数を取得
*
* @returns {number} Number of managed entities / 管理対象エンティティ数
*/
getEntityCount() {
return this.entities.length;
}
/**
* Update rendering options
* 描画オプションを更新
*
* @param {Object} newOptions - New options to merge / マージする新オプション
*/
updateOptions(newOptions) {
this.options = {
...this.options,
...newOptions
};
Logger.debug('GeometryRenderer options updated:', this.options);
}
/**
* Get current configuration
* 現在の設定を取得
*
* @returns {Object} Current configuration / 現在の設定
*/
getConfiguration() {
return {
...this.options,
entityCount: this.entities.length,
version: '0.1.11',
phase: 'ADR-0009 Phase 4'
};
}
}import * as Cesium from 'cesium';
import { Logger } from '../../utils/logger.js';
import { escapeHtml } from '../../utils/escapeHtml.js';
/**
* GeometryRenderer - Creates Cesium entities consumed by VoxelRenderer.
* ジオメトリレンダラー - VoxelRenderer が利用する Cesium エンティティを生成・管理
*
* Responsibilities:
* - ボクセルボックス描画 (Voxel box rendering)
* - インセット枠線描画 (Inset outline rendering)
* - エッジポリライン描画 (Edge polyline rendering for emulation)
* - エンティティライフサイクル管理 (Entity lifecycle management)
*
* ADR-0009 Phase 4
* @version 0.1.15
*/
export class GeometryRenderer {
/**
* GeometryRenderer constructor
* @param {Cesium.Viewer} viewer - CesiumJS Viewer instance / CesiumJS Viewerインスタンス
* @param {Object} options - Rendering options / 描画オプション
*/
constructor(viewer, options = {}) {
this.viewer = viewer;
this.options = {
// Geometry rendering defaults
wireframeOnly: false,
showOutline: true,
outlineWidth: 2,
outlineInset: 0,
outlineInsetMode: 'all',
outlineRenderMode: 'standard',
enableThickFrames: false,
...options
};
// Entity management
this.entities = [];
Logger.debug('GeometryRenderer initialized with viewer and options:', this.options);
}
/**
* Create a voxel box entity
* ボクセルボックスエンティティを作成
*
* @param {Object} config - Voxel configuration / ボクセル設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `cellSizeX`, `cellSizeY` (number) / フットプリント寸法、
* `boxHeight` (number) / ボックス高さ、
* `color` (Cesium.Color) / ボックス色、
* `opacity` (number) / ボックス透明度、
* `shouldShowOutline` (boolean) / 枠線表示、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `voxelInfo` (Object) / ボクセルデータ、
* `voxelKey` (string) / ボクセルキー、
* `emulateThick` (boolean, optional) / 太線エミュレーション使用
* @returns {Cesium.Entity} Created voxel entity / 作成されたボクセルエンティティ
*/
createVoxelBox(config) {
const {
centerLon, centerLat, centerAlt,
cellSizeX, cellSizeY, boxHeight,
color, opacity,
shouldShowOutline, outlineColor, outlineWidth,
voxelInfo, voxelKey,
emulateThick = false
} = config;
// Validate coordinate inputs to prevent Cesium RangeError
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-90, Math.min(90, centerLat)) : 0;
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
// Validate dimension inputs to prevent Cesium geometry errors
const safeCellSizeX = Number.isFinite(cellSizeX) && cellSizeX > 0 ? Math.min(cellSizeX, 1e6) : 1;
const safeCellSizeY = Number.isFinite(cellSizeY) && cellSizeY > 0 ? Math.min(cellSizeY, 1e6) : 1;
const safeBoxHeight = Number.isFinite(boxHeight) && boxHeight > 0 ? Math.min(boxHeight, 1e6) : 1;
// Log warning if values were clamped
if (safeCenterLon !== centerLon || safeCenterLat !== centerLat || safeCenterAlt !== centerAlt ||
safeCellSizeX !== cellSizeX || safeCellSizeY !== cellSizeY || safeBoxHeight !== boxHeight) {
Logger.warn(`Clamped invalid geometry values for voxel ${voxelKey}:`, {
original: { centerLon, centerLat, centerAlt, cellSizeX, cellSizeY, boxHeight },
clamped: { safeCenterLon, safeCenterLat, safeCenterAlt, safeCellSizeX, safeCellSizeY, safeBoxHeight }
});
}
// Entity configuration with safe values
const showOutline = Boolean(shouldShowOutline && !emulateThick);
const boxConfig = {
dimensions: new Cesium.Cartesian3(safeCellSizeX, safeCellSizeY, safeBoxHeight),
outline: showOutline
};
if (showOutline) {
boxConfig.outlineColor = outlineColor;
boxConfig.outlineWidth = Math.max(outlineWidth || 1, 1);
}
const entityConfig = {
position: Cesium.Cartesian3.fromDegrees(safeCenterLon, safeCenterLat, safeCenterAlt),
box: boxConfig,
properties: {
type: 'voxel',
key: voxelKey,
count: voxelInfo.count,
x: voxelInfo.x,
y: voxelInfo.y,
z: voxelInfo.z
},
description: this.createVoxelDescription(voxelInfo, voxelKey)
};
// v0.1.17: Add spatial ID to properties if available / 空間IDがあればプロパティに追加
if (voxelInfo.spatialId) {
entityConfig.properties.spatialId = voxelInfo.spatialId;
}
// v0.1.18: Add layer aggregation info to properties (ADR-0014)
if (voxelInfo.layerTop) {
entityConfig.properties.layerTop = voxelInfo.layerTop;
}
if (voxelInfo.layerStats) {
// Convert Map to plain object for Cesium properties
// MapをCesiumプロパティ用のプレーンオブジェクトに変換
const layerStatsObj = {};
for (const [key, count] of voxelInfo.layerStats) {
layerStatsObj[key] = count;
}
entityConfig.properties.layerStats = layerStatsObj;
}
// Material configuration based on wireframe mode and render mode
const hideBox = this.options.wireframeOnly || this.options.outlineRenderMode === 'emulation-only';
if (hideBox) {
entityConfig.box.material = Cesium.Color.TRANSPARENT;
entityConfig.box.fill = false;
} else {
entityConfig.box.material = color.withAlpha(opacity);
entityConfig.box.fill = true;
}
// Create and track entity
const entity = this.viewer.entities.add(entityConfig);
this.entities.push(entity);
return entity;
}
/**
* Create inset outline for a voxel
* ボクセルのインセット枠線を作成
*
* @param {Object} config - Inset outline configuration / インセット枠線設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `baseSizeX`, `baseSizeY`, `baseSizeZ` (number) / ベース寸法、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `voxelKey` (string) / ボクセルキー、
* `insetAmount` (number, optional) / カスタムインセット量
* @returns {Cesium.Entity} Created inset outline entity / 作成されたインセット枠線エンティティ
*/
createInsetOutline(config) {
const {
centerLon, centerLat, centerAlt,
baseSizeX, baseSizeY, baseSizeZ,
outlineColor, outlineWidth, voxelKey,
insetAmount = null
} = config;
// Validate inputs for inset outline to prevent Cesium errors
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-90, Math.min(90, centerLat)) : 0;
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
const safeBaseSizeX = Number.isFinite(baseSizeX) && baseSizeX > 0 ? Math.min(baseSizeX, 1e6) : 1;
const safeBaseSizeY = Number.isFinite(baseSizeY) && baseSizeY > 0 ? Math.min(baseSizeY, 1e6) : 1;
const safeBaseSizeZ = Number.isFinite(baseSizeZ) && baseSizeZ > 0 ? Math.min(baseSizeZ, 1e6) : 1;
// インセット距離の適用(ADR-0004の境界条件:両側合計で各軸寸法の最大40%まで=片側20%)
// 片側20%までに制限することで、最終寸法は元の60%以上を保証する
const maxInsetX = safeBaseSizeX * 0.2;
const maxInsetY = safeBaseSizeY * 0.2;
const maxInsetZ = safeBaseSizeZ * 0.2;
const baseInset = insetAmount !== null ? insetAmount : this.options.outlineInset;
const effectiveInsetX = Math.min(baseInset, maxInsetX);
const effectiveInsetY = Math.min(baseInset, maxInsetY);
const effectiveInsetZ = Math.min(baseInset, maxInsetZ);
// インセット後の寸法計算(各軸から2倍のインセットを引く)
const insetSizeX = Math.max(safeBaseSizeX - (effectiveInsetX * 2), safeBaseSizeX * 0.1);
const insetSizeY = Math.max(safeBaseSizeY - (effectiveInsetY * 2), safeBaseSizeY * 0.1);
const insetSizeZ = Math.max(safeBaseSizeZ - (effectiveInsetZ * 2), safeBaseSizeZ * 0.1);
// セカンダリBoxエンティティの設定(枠線のみ、塗りなし)
const insetEntity = this.viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(safeCenterLon, safeCenterLat, safeCenterAlt),
box: {
dimensions: new Cesium.Cartesian3(insetSizeX, insetSizeY, insetSizeZ),
fill: false,
outline: true,
outlineColor: outlineColor,
// CesiumのBox outlineWidthは0や負値で不安定になるため最低1にクランプ
outlineWidth: Math.max(outlineWidth || 1, 1)
},
properties: {
type: 'voxel-inset-outline',
parentKey: voxelKey,
insetSize: { x: insetSizeX, y: insetSizeY, z: insetSizeZ }
}
});
this.entities.push(insetEntity);
// 枠線の厚み部分を視覚化(WebGL 1px制限の回避)
if (this.options.enableThickFrames && (effectiveInsetX > 0.1 || effectiveInsetY > 0.1 || effectiveInsetZ > 0.1)) {
this.createThickOutlineFrames({
centerLon: safeCenterLon, centerLat: safeCenterLat, centerAlt: safeCenterAlt,
outerX: safeBaseSizeX, outerY: safeBaseSizeY, outerZ: safeBaseSizeZ,
innerX: insetSizeX, innerY: insetSizeY, innerZ: insetSizeZ,
frameColor: outlineColor, voxelKey
});
}
Logger.debug(`Inset outline created for voxel ${voxelKey}:`, {
originalSize: { x: baseSizeX, y: baseSizeY, z: baseSizeZ },
insetSize: { x: insetSizeX, y: insetSizeY, z: insetSizeZ },
effectiveInset: { x: effectiveInsetX, y: effectiveInsetY, z: effectiveInsetZ }
});
return insetEntity;
}
/**
* Create thick outline frame structures
* 枠線の厚み部分を視覚化するフレーム構造を作成
*
* @param {Object} config - Frame configuration / フレーム設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `outerX`, `outerY`, `outerZ` (number) / 外枠寸法、
* `innerX`, `innerY`, `innerZ` (number) / 内枠寸法、
* `frameColor` (Cesium.Color) / フレーム色、
* `voxelKey` (string) / ボクセルキー
* @returns {Array<Cesium.Entity>} Created frame entities / 作成されたフレームエンティティ配列
*/
createThickOutlineFrames(config) {
const {
centerLon, centerLat, centerAlt,
outerX, outerY, outerZ,
innerX, innerY, innerZ,
frameColor, voxelKey
} = config;
// フレーム厚み計算(外側と内側の差を2で割ったもの)
let frameThickX = (outerX - innerX) / 2;
let frameThickY = (outerY - innerY) / 2;
let frameThickZ = (outerZ - innerZ) / 2;
// 厚みが0や負になるケースを防止(ゼロ寸法BoxはCesiumで不安定)
const minFrame = 0.05;
if (frameThickX <= 0 || frameThickY <= 0 || frameThickZ <= 0) {
Logger.warn(`Invalid frame thickness for voxel ${voxelKey}, skipping thick frames`);
return [];
}
frameThickX = Math.max(frameThickX, minFrame);
frameThickY = Math.max(frameThickY, minFrame);
frameThickZ = Math.max(frameThickZ, minFrame);
// 境界計算:各軸での内側・外側の境界
const outerBoundX = outerX / 2; // 外側境界(中心からの距離)
const outerBoundY = outerY / 2;
const outerBoundZ = outerZ / 2;
// 内側境界は以降の処理では直接使用しないため計算を省略
const frameEntities = [];
// 12個のフレームボックスを作成
// X軸に平行なフレーム(4本)
const xFrames = [
{ y: outerBoundY, z: outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: -outerBoundY, z: outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: outerBoundY, z: -outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ },
{ y: -outerBoundY, z: -outerBoundZ, sizeY: frameThickY, sizeZ: frameThickZ }
];
// Y軸に平行なフレーム(4本)
const yFrames = [
{ x: outerBoundX, z: outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: -outerBoundX, z: outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: outerBoundX, z: -outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ },
{ x: -outerBoundX, z: -outerBoundZ, sizeX: frameThickX, sizeZ: frameThickZ }
];
// Z軸に平行なフレーム(4本)
const zFrames = [
{ x: outerBoundX, y: outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: -outerBoundX, y: outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: outerBoundX, y: -outerBoundY, sizeX: frameThickX, sizeY: frameThickY },
{ x: -outerBoundX, y: -outerBoundY, sizeX: frameThickX, sizeY: frameThickY }
];
// X軸フレームを作成
xFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon,
centerLat + (frame.y / 111320), // 緯度変換(概算)
centerAlt + frame.z
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(outerX, frame.sizeY, frame.sizeZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-x',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
// Y軸フレームを作成
yFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon + (frame.x / (111320 * Math.cos(centerLat * Math.PI / 180))), // 経度変換(概算)
centerLat,
centerAlt + frame.z
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(frame.sizeX, outerY, frame.sizeZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-y',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
// Z軸フレームを作成
zFrames.forEach((frame, index) => {
const position = Cesium.Cartesian3.fromDegrees(
centerLon + (frame.x / (111320 * Math.cos(centerLat * Math.PI / 180))), // 経度変換(概算)
centerLat + (frame.y / 111320), // 緯度変換(概算)
centerAlt
);
const frameEntity = this.viewer.entities.add({
position: position,
box: {
dimensions: new Cesium.Cartesian3(frame.sizeX, frame.sizeY, outerZ),
fill: true,
material: frameColor.withAlpha(0.3),
outline: false
},
properties: {
type: 'voxel-thick-frame-z',
parentKey: voxelKey,
frameIndex: index
}
});
this.entities.push(frameEntity);
frameEntities.push(frameEntity);
});
Logger.debug(`Created ${frameEntities.length} thick frame entities for voxel ${voxelKey}`);
return frameEntities;
}
/**
* Create edge polylines for thick outline emulation
* 太線エミュレーション用のエッジポリライン作成
*
* @param {Object} config - Edge polyline configuration / エッジポリライン設定
* Properties: `centerLon`, `centerLat`, `centerAlt` (number) / 中心座標、
* `cellSizeX`, `cellSizeY` (number) / フットプリント寸法、
* `boxHeight` (number) / ボックス高さ、
* `outlineColor` (Cesium.Color) / 枠線色、
* `outlineWidth` (number) / 枠線太さ、
* `outlineOpacity` (number) / 枠線透明度、
* `voxelKey` (string) / ボクセルキー
* @returns {Array<Cesium.Entity>} Created polyline entities / 作成されたポリラインエンティティ配列
*/
createEdgePolylines(config) {
const {
centerLon, centerLat, centerAlt,
cellSizeX, cellSizeY, boxHeight,
outlineColor, outlineWidth, outlineOpacity = null, voxelKey
} = config;
const polylineEntities = [];
// Validate inputs for edge polylines to prevent coordinate calculation errors
const safeCenterLon = Number.isFinite(centerLon) ? Math.max(-180, Math.min(180, centerLon)) : 0;
const safeCenterLat = Number.isFinite(centerLat) ? Math.max(-85, Math.min(85, centerLat)) : 0; // Avoid poles more strictly
const safeCenterAlt = Number.isFinite(centerAlt) ? Math.max(-10000, Math.min(100000, centerAlt)) : 0;
const safeCellSizeX = Number.isFinite(cellSizeX) && cellSizeX > 0 ? Math.min(cellSizeX, 1e5) : 1; // More conservative limits
const safeCellSizeY = Number.isFinite(cellSizeY) && cellSizeY > 0 ? Math.min(cellSizeY, 1e5) : 1;
const safeBoxHeight = Number.isFinite(boxHeight) && boxHeight > 0 ? Math.min(boxHeight, 1e5) : 1;
// Early validation: check if dimensions are reasonable
if (safeCellSizeX < 0.001 || safeCellSizeY < 0.001 || safeBoxHeight < 0.001) {
Logger.warn(`Dimensions too small for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// ボックスの8つの頂点を計算 - 安全な値を使用
const halfX = safeCellSizeX / 2;
const halfY = safeCellSizeY / 2;
const halfZ = safeBoxHeight / 2;
// 座標変換係数の安全な計算 - より保守的なアプローチ
const cosLat = Math.cos(safeCenterLat * Math.PI / 180);
const safeCosFactor = Math.max(0.1, Math.abs(cosLat)); // より保守的な最小値
// 計算された座標オフセットの事前検証
const lonOffset = halfX / (111320 * safeCosFactor);
const latOffset = halfY / 111320;
if (!Number.isFinite(lonOffset) || !Number.isFinite(latOffset) ||
Math.abs(lonOffset) > 0.1 || Math.abs(latOffset) > 0.1) {
Logger.warn(`Coordinate offsets out of range for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// 頂点座標の事前計算とバリデーション
const vertexCoords = [
// 下面の4頂点
[safeCenterLon - lonOffset, safeCenterLat - latOffset, safeCenterAlt - halfZ],
[safeCenterLon + lonOffset, safeCenterLat - latOffset, safeCenterAlt - halfZ],
[safeCenterLon + lonOffset, safeCenterLat + latOffset, safeCenterAlt - halfZ],
[safeCenterLon - lonOffset, safeCenterLat + latOffset, safeCenterAlt - halfZ],
// 上面の4頂点
[safeCenterLon - lonOffset, safeCenterLat - latOffset, safeCenterAlt + halfZ],
[safeCenterLon + lonOffset, safeCenterLat - latOffset, safeCenterAlt + halfZ],
[safeCenterLon + lonOffset, safeCenterLat + latOffset, safeCenterAlt + halfZ],
[safeCenterLon - lonOffset, safeCenterLat + latOffset, safeCenterAlt + halfZ]
];
// 全ての座標が有効範囲内かチェック
const allCoordsValid = vertexCoords.every(([lon, lat, alt]) =>
Number.isFinite(lon) && Number.isFinite(lat) && Number.isFinite(alt) &&
lon >= -180 && lon <= 180 && lat >= -85 && lat <= 85 &&
alt >= -50000 && alt <= 500000
);
if (!allCoordsValid) {
Logger.warn(`Invalid vertex coordinates for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// Cartesian3頂点の安全な作成
let vertices;
try {
vertices = vertexCoords.map(([lon, lat, alt]) =>
Cesium.Cartesian3.fromDegrees(lon, lat, alt)
);
} catch (error) {
Logger.warn(`Failed to create Cartesian3 vertices for voxel ${voxelKey}:`, error);
return polylineEntities;
}
// 作成された頂点の最終検証
const validVertices = vertices.every(vertex =>
vertex && Number.isFinite(vertex.x) && Number.isFinite(vertex.y) && Number.isFinite(vertex.z)
);
if (!validVertices) {
Logger.warn(`Generated vertices contain invalid values for voxel ${voxelKey}, skipping edge polylines`);
return polylineEntities;
}
// ボックスの12エッジを定義
const edges = [
// 下面の4エッジ
[0, 1], [1, 2], [2, 3], [3, 0],
// 上面の4エッジ
[4, 5], [5, 6], [6, 7], [7, 4],
// 垂直の4エッジ
[0, 4], [1, 5], [2, 6], [3, 7]
];
// 各エッジをポリラインとして作成
edges.forEach((edge, index) => {
try {
const vertex0 = vertices[edge[0]];
const vertex1 = vertices[edge[1]];
// 追加の安全性チェック
if (!vertex0 || !vertex1) {
Logger.warn(`Missing vertices for edge ${index} in voxel ${voxelKey}`);
return;
}
const positions = [vertex0, vertex1];
// positions配列の最終検証
if (positions.length !== 2) {
Logger.warn(`Invalid positions array length for edge ${index} in voxel ${voxelKey}`);
return;
}
let effectiveMaterial = outlineColor;
if (outlineOpacity !== null && outlineOpacity !== undefined && typeof outlineColor?.withAlpha === 'function') {
const clampedOpacity = Math.max(0, Math.min(1, outlineOpacity));
effectiveMaterial = outlineColor.withAlpha(clampedOpacity);
}
const safeOutlineWidth = Number.isFinite(outlineWidth) ? outlineWidth : 1;
const polylineEntity = this.viewer.entities.add({
polyline: {
positions: positions,
width: Math.max(Math.min(safeOutlineWidth, 20), 1), // width制限も追加
material: effectiveMaterial,
clampToGround: false
},
properties: {
type: 'voxel-edge-polyline',
parentKey: voxelKey,
edgeIndex: index
}
});
this.entities.push(polylineEntity);
polylineEntities.push(polylineEntity);
} catch (error) {
Logger.warn(`Failed to create polyline for edge ${index} in voxel ${voxelKey}:`, error);
// エラーが発生しても処理を継続(他のエッジは正常に作成される可能性がある)
}
});
Logger.debug(`Created ${polylineEntities.length} edge polylines for voxel ${voxelKey}`);
return polylineEntities;
}
/**
* Create voxel description HTML
* ボクセルの説明HTMLを生成
*
* @param {Object} voxelInfo - Voxel information / ボクセル情報
* @param {string} voxelKey - Voxel key / ボクセルキー
* @returns {string} HTML description / HTML形式の説明文
*/
createVoxelDescription(voxelInfo, voxelKey) {
// v0.1.17: Include spatial ID in description if available / 空間IDがあればdescriptionに含める
const spatialIdInfo = voxelInfo.spatialId ? `
<tr><td><b>空間ID:</b></td><td>${escapeHtml(voxelInfo.spatialId.id)}</td></tr>
<tr><td><b>ズームレベル:</b></td><td>Z=${escapeHtml(String(voxelInfo.spatialId.z))}, F=${escapeHtml(String(voxelInfo.spatialId.f))}</td></tr>
<tr><td><b>タイル座標:</b></td><td>X=${escapeHtml(String(voxelInfo.spatialId.x))}, Y=${escapeHtml(String(voxelInfo.spatialId.y))}</td></tr>
` : '';
// v0.1.18: Include layer breakdown if aggregation enabled and showInDescription is true (ADR-0014)
let layerInfo = '';
const showLayerInfo = this.options.aggregation?.enabled &&
this.options.aggregation?.showInDescription !== false &&
voxelInfo.layerStats &&
voxelInfo.layerStats.size > 0;
if (showLayerInfo) {
// Escape layer keys to prevent XSS / XSS防止のためレイヤキーをエスケープ
const layerRows = Array.from(voxelInfo.layerStats.entries())
.sort((a, b) => b[1] - a[1]) // Sort by count descending / カウント降順でソート
.map(([key, count]) => {
const pct = voxelInfo.count > 0 ? ((count / voxelInfo.count) * 100).toFixed(1) : '0.0';
return `
<tr><td style="padding-left: 10px;">${escapeHtml(key)}</td><td>${count} (${pct}%)</td></tr>
`;
}).join('');
const topLayerInfo = voxelInfo.layerTop ? `
<tr><td><b>支配的レイヤ:</b></td><td>${escapeHtml(voxelInfo.layerTop)}</td></tr>
` : '';
layerInfo = `
${topLayerInfo}
<tr><td colspan="2"><b>レイヤ内訳:</b></td></tr>
${layerRows}
`;
}
return `
<div style="padding: 10px; font-family: Arial, sans-serif;">
<h3 style="margin-top: 0;">ボクセル [${voxelInfo.x}, ${voxelInfo.y}, ${voxelInfo.z}]</h3>
<table style="width: 100%;">
<tr><td><b>エンティティ数:</b></td><td>${voxelInfo.count}</td></tr>
<tr><td><b>ボクセルキー:</b></td><td>${escapeHtml(voxelKey)}</td></tr>
<tr><td><b>座標:</b></td><td>X=${voxelInfo.x}, Y=${voxelInfo.y}, Z=${voxelInfo.z}</td></tr>${spatialIdInfo}${layerInfo}
</table>
<p style="margin-bottom: 0;">
<small>v0.1.18 GeometryRenderer (Layer aggregation support)</small>
</p>
</div>
`;
}
/**
* Check if inset outline should be applied
* インセット枠線を適用すべきかどうかを判定
*
* @param {boolean} isTopN - Is TopN voxel / TopNボクセルかどうか
* @returns {boolean} Should apply inset outline / インセット枠線を適用する場合はtrue
*/
shouldApplyInsetOutline(isTopN) {
const mode = this.options.outlineInsetMode || 'all';
switch (mode) {
case 'topn':
return isTopN;
case 'all':
default:
return true;
case 'none':
return false;
}
}
/**
* Clear all managed entities
* 管理対象の全エンティティをクリア
*/
clear() {
Logger.debug('GeometryRenderer.clear - Removing', this.entities.length, 'entities');
this.entities.forEach(entity => {
try {
// entityとisDestroyedのチェックを安全に行う
const isDestroyed = entity && typeof entity.isDestroyed === 'function' ? entity.isDestroyed() : false;
if (entity && !isDestroyed) {
this.viewer.entities.remove(entity);
}
} catch (error) {
Logger.warn('Entity removal error:', error);
}
});
this.entities = [];
}
/**
* Render a debug bounding box for given bounds
* 指定された境界のデバッグ用バウンディングボックスを描画
* @param {Object} bounds - {minLon, maxLon, minLat, maxLat, minAlt, maxAlt}
*/
renderBoundingBox(bounds) {
if (!bounds) return;
try {
const centerLon = (bounds.minLon + bounds.maxLon) / 2;
const centerLat = (bounds.minLat + bounds.maxLat) / 2;
const centerAlt = (bounds.minAlt + bounds.maxAlt) / 2;
const widthMeters = (bounds.maxLon - bounds.minLon) * 111000 * Math.cos(centerLat * Math.PI / 180);
const depthMeters = (bounds.maxLat - bounds.minLat) * 111000;
const heightMeters = bounds.maxAlt - bounds.minAlt;
const boundingBox = this.viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, centerAlt),
box: {
dimensions: new Cesium.Cartesian3(widthMeters, depthMeters, heightMeters),
material: Cesium.Color.YELLOW.withAlpha(0.1),
outline: true,
outlineColor: Cesium.Color.YELLOW.withAlpha(0.3),
outlineWidth: 2
},
description: `Bounding Box<br>Size: ${widthMeters.toFixed(1)} x ${depthMeters.toFixed(1)} x ${heightMeters.toFixed(1)} m`
});
this.entities.push(boundingBox);
Logger.debug('Debug bounding box added:', {
center: { lon: centerLon, lat: centerLat, alt: centerAlt },
size: { width: widthMeters, depth: depthMeters, height: heightMeters }
});
} catch (error) {
Logger.warn('Failed to render bounding box:', error);
}
}
/**
* Get entity count
* エンティティ数を取得
*
* @returns {number} Number of managed entities / 管理対象エンティティ数
*/
getEntityCount() {
return this.entities.length;
}
/**
* Update rendering options
* 描画オプションを更新
*
* @param {Object} newOptions - New options to merge / マージする新オプション
*/
updateOptions(newOptions) {
this.options = {
...this.options,
...newOptions
};
Logger.debug('GeometryRenderer options updated:', this.options);
}
/**
* Get current configuration
* 現在の設定を取得
*
* @returns {Object} Current configuration / 現在の設定
*/
getConfiguration() {
return {
...this.options,
entityCount: this.entities.length,
version: '0.1.11',
phase: 'ADR-0009 Phase 4'
};
}
}