feat(All):Initial

This commit is contained in:
2025-10-04 23:36:07 +08:00
commit 2b4e5d2668
1321 changed files with 415958 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# port
VITE_PORT=8091
# 作者
VITE_GLOB_AUTHOR='ErSan'
+2
View File
@@ -0,0 +1,2 @@
# 发布路径
VITE_PUBLIC_PATH=/
+16
View File
@@ -0,0 +1,16 @@
# 发布路径
VITE_PUBLIC_PATH=./
# 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔
VITE_BUILD_COMPRESS='gzip'
# 使用压缩时是否删除原始文件,默认为false
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE=false
# 是否启用构建包用时分析
VITE_ENABLE_ANALYZE=false
# 是否生成app.config.js
VITE_ENABLE_CONFIG_GENERATE=false
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
/**
* 位置
*/
export const POSITION = {
BOTTOM_LEFT: "bottom-left",
BOTTOM: "bottom",
BOTTOM_CENTER: "bottom",
BOTTOM_RIGHT: "bottom-right",
TOP_LEFT: "top-left",
TOP: "top",
TOP_CENTER: "top",
TOP_RIGHT: "top-right",
LEFT: "left",
RIGHT: "right",
CENTER: "center"
}
/**
* FPS
*/
export const FPS_OPTIONS = {
// 不设限
NO_UPPER_LIMIT:0,
// 低帧率
LOW: 24,
// 中帧率
MEDIUM: 30,
// 高帧率
HIGH:60,
// 超高帧率
ULTRA_HIGH: 120,
}
/**
* 可选漫游角色
*/
export const ROAMING_CHARACTERS = {
JACKIE: "Jackie",
WORK_MAN: "Workman",
X_BOT: "X_Bot",
Y_BOT: "Y_Bot",
}
+2
View File
@@ -0,0 +1,2 @@
export * from './type';
export * from './enum';
+58
View File
@@ -0,0 +1,58 @@
import * as THREE from 'three';
export const TYPED_ARRAYS = {
Int8Array: Int8Array,
Uint8Array: Uint8Array,
Uint8ClampedArray: Uint8ClampedArray,
Int16Array: Int16Array,
Uint16Array: Uint16Array,
Int32Array: Int32Array,
Uint32Array: Uint32Array,
Float32Array: Float32Array,
Float64Array: Float64Array
};
// base64对应的类型
export const BASE64_TYPES = {
"data:image/png;base64": "png",
"data:image/jpeg;base64": "jpg",
"data:image/gif;base64": "gif",
"data:image/x-icon;base64": "ico",
"data:image/svg+xml;base64": "svg",
"data:image/webp;base64": "webp",
"data:audio/wav;base64": "wav",
"data:audio/mpeg;base64": "mp3",
"data:video/mp4;base64": "mp4",
"data:video/webm;base64": "webm",
"data:font/woff;base64": "woff",
"data:font/woff2;base64": "woff2",
"data:application/vnd.ms-fontobject;base64": "eot",
"data:application/x-font-ttf;base64": "ttf",
"data:application/octet-stream;base64": "ttf",
"data:application/font-woff;base64": "woff",
"data:application/font-woff2;base64": "woff2"
}
export const TEXTURE_MAPPING = {
UVMapping: THREE.UVMapping,
CubeReflectionMapping: THREE.CubeReflectionMapping,
CubeRefractionMapping: THREE.CubeRefractionMapping,
EquirectangularReflectionMapping: THREE.EquirectangularReflectionMapping,
EquirectangularRefractionMapping: THREE.EquirectangularRefractionMapping,
CubeUVReflectionMapping: THREE.CubeUVReflectionMapping
};
export const TEXTURE_WRAPPING = {
RepeatWrapping: THREE.RepeatWrapping,
ClampToEdgeWrapping: THREE.ClampToEdgeWrapping,
MirroredRepeatWrapping: THREE.MirroredRepeatWrapping
};
export const TEXTURE_FILTER = {
NearestFilter: THREE.NearestFilter,
NearestMipmapNearestFilter: THREE.NearestMipmapNearestFilter,
NearestMipmapLinearFilter: THREE.NearestMipmapLinearFilter,
LinearFilter: THREE.LinearFilter,
LinearMipmapNearestFilter: THREE.LinearMipmapNearestFilter,
LinearMipmapLinearFilter: THREE.LinearMipmapLinearFilter
};
@@ -0,0 +1,276 @@
import * as THREE from 'three';
import { useDispatchSignal } from '@/hooks';
import { escapeRegExp } from "@/utils";
import App from "@/core/app/App";
let prevActionsInUse = 0, needsUpdate = false;
export class AnimationManager {
// 场景中的动画混合器集合
public mixerMap: Map<string, THREE.AnimationMixer> = new Map();
// 场景中的动画action集合
public actionMap: Map<string, THREE.AnimationAction> = new Map();
constructor() {}
/**
* 检查动画剪辑上是否已存在当前object的相应轨道,存在则返回该轨道
* @param clip 动画剪辑
* @param prop 需要检查的属性名称
* @param object 需要检查的对象
*/
hasExistingTrack(clip: THREE.AnimationClip, prop: string, object: THREE.Object3D | null = null) {
if (!object) {
object = App.selected;
if (!object) return false;
}
const possiblePatterns = [
// 基础属性匹配
`${escapeRegExp(object.name)}\\.${prop}(\$$.*\$$)?$`,
`${escapeRegExp(object.uuid)}\\.${prop}(\$$.*\$$)?$`,
// 层级结构匹配
`([^/]+/)*${escapeRegExp(object.name)}\\.${prop}(\$$.*\$$)?$`,
// 材质属性匹配
`${escapeRegExp(object.name)}\\.material\\.${prop}(\$$.*\$$)?$`,
`${escapeRegExp(object.uuid)}\\.material\\.${prop}(\$$.*\$$)?$`,
// 骨骼动画匹配
`\\.bone\$$${escapeRegExp(object.name)}[^\$$]*\\]\\.${prop}$`,
// 带命名空间的场景对象匹配
`scene:[^:]+:${escapeRegExp(object.name)}\\.${prop}$`
];
const fullPattern = new RegExp(
`^(?:${possiblePatterns.join('|')})`,
'i'
);
return clip.tracks.find(track => fullPattern.test(track.name));
}
/**
* 创建空动画对象
* @param name 动画对象名称
* @param object 绑定动画的对象
*/
createEmptyAnimation(name: string, object: THREE.Object3D | null = null) {
if (!object) {
object = App.selected;
if (!object) return;
}
let mixer = this.mixerMap.get(object.uuid);
if (!mixer) {
mixer = new THREE.AnimationMixer(object);
this.mixerMap.set(object.uuid, mixer);
}
const clip = new THREE.AnimationClip(name, 0, []);
const clipAction = mixer.clipAction(clip);
// @ts-ignore
object.animations.push(clipAction);
this.actionMap.set(clip.uuid, clipAction);
return clipAction;
}
/**
* 重新剪辑action
* @param action 动画action
* @param currentTime 动画停住的时间点
* @returns action 重剪辑后的action
*/
reClipAction(action: THREE.AnimationAction, currentTime = 0) {
const currentClip = action.getClip();
const currentObject = action.getRoot();
const currentMixer = action.getMixer();
// 重剪辑前动画是否是激活的
const isScheduled = action.isScheduled();
const actionIndex = currentObject.animations.findIndex((a: THREE.AnimationAction | THREE.AnimationClip) => (a === action || a === currentClip));
const property = {
time: currentTime || action.time,
timeScale: action.timeScale,
clampWhenFinished: action.clampWhenFinished,
loop: action.loop,
weight: action.weight,
enabled: action.enabled,
paused: action.paused,
repetitions: action.repetitions,
zeroSlopeAtEnd: action.zeroSlopeAtEnd,
zeroSlopeAtStart: action.zeroSlopeAtStart,
};
action.stop();
currentMixer.uncacheClip(currentClip);
const newAction = currentMixer.clipAction(currentClip, currentObject);
// 同步属性
Object.assign(newAction, property);
// @ts-ignore
currentObject.animations.splice(actionIndex, 1, newAction);
action = newAction;
this.actionMap.set(currentClip.uuid, newAction);
// 如果动作没激活过则激活一次
if (isScheduled && !action.isScheduled()) {
action.play();
action.paused = true;
}
return action;
}
update(delta: number) {
needsUpdate = false;
this.mixerMap.forEach(mixer => {
// @ts-ignore
const actions = mixer.stats.actions;
if (actions.inUse > 0) {
prevActionsInUse = actions.inUse;
mixer.update(delta);
useDispatchSignal("animationMixerUpdate", mixer, delta)
needsUpdate = true;
}
})
if (!needsUpdate && prevActionsInUse > 0) {
prevActionsInUse = 0;
needsUpdate = true;
}
return needsUpdate;
}
}
/**
* 关键帧轨道创建工厂函数
* @param name 轨道名称
* @param times 关键帧时间点数组
* @param values 关键帧值数组
* @param interpolation 插值类型
*/
export const KeyframeTrackFactory = (name: string, times: number[], values: any[], interpolation: THREE.InterpolationModes = THREE.InterpolateLinear) => {
// 按 '.' 分割,取最后一段(如 'nodeName.property[accessor]'
const lastSegment = name.split('.').pop();
// 再按 '[' 分割,取第一部分(如 'property'
const attr = lastSegment?.split('[')[0];
if (!attr) {
return new THREE.KeyframeTrack(name, times, values, interpolation);
}
switch (attr) {
case 'position':
case 'rotation':
case 'scale':
return new THREE.VectorKeyframeTrack(name, times, values, interpolation);
case 'quaternion':
return new THREE.QuaternionKeyframeTrack(name, times, values, interpolation);
case 'visible':
// 启用 alpha 覆盖
case 'alphaToCoverage':
// 是否渲染材质的颜色
case 'colorWrite':
// 是否在渲染此材质时启用深度测试
case 'depthTest':
// 渲染此材质是否对深度缓冲区有任何影响
case 'depthWrite':
// 定义这个材质是否会被渲染器的toneMapping设置所影响
case 'toneMapped':
// 定义此材质是否透明
case 'transparent':
// 是否使用顶点着色
case 'vertexColors':
// 大小衰减
case 'sizeAttenuation':
// 平面着色
case 'flatShading':
// 线框模式
case 'wireframe':
return new THREE.BooleanKeyframeTrack(name, times, values);
case 'color':
// 高光
case 'specular':
// 自发光
case 'emissive':
// 光泽颜色
case 'sheenColor':
// 衰减色
case 'attenuationColor':
// 表示恒定混合颜色的 RGB 值
case 'blendColor':
// TODO: 待补充说明
case 'groundcolor':
return new THREE.ColorKeyframeTrack(name, times, values, interpolation);
// 在0.0 - 1.0的范围内的浮点数,表明材质的透明度。值0.0表示完全透明,1.0表示完全不透明
case 'opacity':
// 表示光源的强度
case 'intensity':
// 表示恒定混合颜色的 alpha 值
case 'blendAlpha':
// 设置运行alphaTest时要使用的alpha值
case 'alphaTest':
// 定义将要渲染哪一面 - 正面,背面或两者
case 'side':
// 摄像机视锥体垂直视野角度,从视图的底部到顶部,以角度来表示
case 'fov':
// 用于立体视觉和景深效果的物体的距离
case 'focus':
// 摄像机的远端面
case 'far':
// 摄像机的近端面
case 'near':
// 摄像机视锥体的长宽比
case 'aspect':
// 获取或者设置摄像机的缩放倍数
case 'zoom':
// TODO: 待补充说明
case 'distance':
// 渲染顺序
case 'renderOrder':
// 高光大小
case 'shininess':
// 反射率
case 'reflectivity':
// 粗糙度
case 'roughness':
// 金属度
case 'metalness':
// 清漆
case 'clearcoat':
// 清漆粗糙度
case 'clearcoatRoughness':
// 彩虹色
case 'iridescence':
// 彩虹色折射率
case 'iridescenceIOR':
// 光泽
case 'sheen':
// 光泽粗糙度
case 'sheenRoughness':
// 透光度
case 'transmission':
// 衰减距离
case 'attenuationDistance':
// 厚度
case 'thickness':
// 大小
case 'size':
return new THREE.NumberKeyframeTrack(name, times, values, interpolation);
// 此处仅为占位说明还有 StringKeyframeTrack
// case "uuid":
// return new THREE.StringKeyframeTrack(name, times, values);
default:
return new THREE.KeyframeTrack(name, times, values, interpolation);
}
}
@@ -0,0 +1,667 @@
import * as THREE from "three";
import {
Timeline,
TimelineRow,
TimelineModel,
TimelineOptions,
TimelineKeyframe,
TimelineInteractionMode,
TimelineKeyframeChangedEvent, TimelineClickEvent
} from "@/core/libs/astral-timeline/animation-timeline";
import {useAddSignal, useDispatchSignal} from "@/hooks";
import { getParentPath,debounce, deepAssign, getNestedProperty } from "@/utils";
import { KeyframeTrackFactory } from "@/core/animation/AnimationManager";
import App from "@/core/app/App";
export interface ITimelineKeyframe extends TimelineKeyframe {
data: number[] | boolean[]
}
export interface ITimelineRow extends TimelineRow {
id: string;
name: string;
keyframes?: ITimelineKeyframe[];
track?: THREE.KeyframeTrack;
}
export interface ITimelineModel extends TimelineModel {
rows: ITimelineRow[]
}
// 定义事件类型
type CustomEvents = {
'contextmenu': { args: TimelineClickEvent };
'mousedown': { args: TimelineClickEvent };
};
let _aniamtionMixerUpdateFn;
class TimelineTrack extends THREE.EventDispatcher<CustomEvents> {
container: HTMLDivElement;
outlineContainer: HTMLDivElement;
timeline: Timeline;
model: ITimelineModel;
options: TimelineOptions;
/**
* 动画编辑轨道当前正在处理的(绑定的)动画
*/
bindAction: THREE.AnimationAction | null = null;
private resizeObserver: ResizeObserver;
constructor(container: HTMLDivElement, outlineContainer: HTMLDivElement, _options: TimelineOptions) {
super();
this.container = container;
this.outlineContainer = outlineContainer;
this.model = {rows: []} as ITimelineModel;
this.options = {
id: container,
headerHeight: 40,
font: "0.7rem sans-serif",
leftMargin: 22,
headerFillColor: "#00000066",
fillColor: "#333333",
labelsColor: "#FFFFFFCC",
tickColor: "#FFFFFF4C",
// 选中矩形颜色
selectionColor: "blue",
zoom: 120,
zoomMin: 30,
zoomMax: 120,
// 一步的长度,默认一步一个像素代表1000ms
stepVal: 1000,
rowsStyle: {
height: 40,
fillColor: "#252526",
marginBottom: 2,
// 关键帧样式
keyframesStyle: {
fillColor: "#9A9A9A"
},
// 组的样式。关键帧组也可以单独设置样式。
groupsStyle: {
text: {
label: "",
isStroke: false,
font: "1.5rem sans-serif",
textAlign: "center",
textBaseline: "top",
direction: "inherit",
fillColor: "#fff"
}
}
},
// 时间轴指示器样式(竖线)
timelineStyle: {
marginTop: 0,
fillColor: "#00ff00",
strokeColor: "#00ff00",
cursor: "e-resize",
// 顶帽样式
capStyle: {
width: 8,
height: 12,
fillColor: "#00ff00",
capType: "rect"
}
},
// 关键帧组可拖动
groupsDraggable: true,
// 关键帧可拖动
keyframesDraggable: true,
// 用于确定要呈现的仪表“漂亮”数字的分母数组。
denominators: [1, 6]
} as TimelineOptions;
deepAssign(this.options, _options);
this.timeline = this.init();
this.updateTrackLength();
this.initEvent();
this.resizeObserver = new ResizeObserver(this.resize.bind(this));
this.resizeObserver.observe(container);
}
// 当前所有关键帧中的最大值,单位为ms
get _maxDuration() {
let max = 0;
this.model.rows.forEach((row) => {
if (!row.keyframes) return;
row.keyframes.forEach((kf) => {
if (kf.val > max) {
max = kf.val;
}
});
});
return max;
}
init() {
// const dpr = window.devicePixelRatio || 1;
// this.container.style.width = this.container.width / scale + 'px';
// this.container.style.height = this.container.height / scale + 'px';
const tl = new Timeline(this.options, this.model);
// 可横向拖动
tl.setInteractionMode(TimelineInteractionMode.Pan);
//重写方法来更改显示的单位文本,显示为 00:00
tl._formatUnitsText = (val) => {
const v = Math.floor(val / 1000);
const minutes = Math.floor(v / 60);
const seconds = v - minutes * 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
if (window.devicePixelRatio !== 1) {
tl._pixelRatio = window.devicePixelRatio;
const scale = 1 / tl._pixelRatio;
const translate = (1 - scale) * 100 / 2 * window.devicePixelRatio;
if (tl._canvas) {
tl._canvas.style.transform = `scale(${scale}) translate(-${translate}%, -${translate}%)`;
}
}
return tl;
}
initEvent() {
this.timeline.onScroll((args) => {
//滚动同步
if (this.outlineContainer) {
this.outlineContainer.style.minHeight = args.scrollHeight + "px";
if (this.outlineContainer.parentElement) {
this.outlineContainer.parentElement.scrollTop = args.scrollTop;
}
}
});
// this.timeline.onScrollFinished((args) => {});
// 关键帧被改变时触发(防抖)
const _keyframeChanged = debounce(this.onKeyframeChanged.bind(this), 100);
this.timeline.onKeyframeChanged(_keyframeChanged);
// this.timeline.onSelected((args) => {});
this.timeline.onContextMenu(async (args) => {
// 禁用默认右键菜单
(args.args as MouseEvent).preventDefault();
if (args.elements.length === 0) return;
this.dispatchEvent({type: "contextmenu", args: args});
});
this.timeline.onMouseDown((args) => {
const e = args.args as MouseEvent;
e.stopPropagation();
if (e.button === 2) return;
this.dispatchEvent({type: "mousedown", args: args});
});
this.timeline.onTimeChanged((args) => {
useDispatchSignal("timelineTimeChanged", args);
if (!this.bindAction) return;
this.bindAction.enabled = true;
const _second = args.val / 1000;
const duration = this.bindAction.getClip().duration;
if (_second > duration) {
this.bindAction.time = duration;
} else {
this.bindAction.time = _second;
}
// 如果动作没激活过则激活一次
if (!this.bindAction.isScheduled()) {
this.bindAction.play();
this.bindAction.paused = true;
}
this.bindAction.getMixer().update(0.016);
// this.bindAction.getRoot() 获取到的对象可能是editor.locked对象,需要获取正在操作的对象
if (App.selected){
useDispatchSignal("objectChanged", App.selected);
useDispatchSignal("materialChanged", App.selected.material);
}
});
// this.timeline.onDrag((args) => {});
_aniamtionMixerUpdateFn = this.handleMixerUpdate.bind(this);
useAddSignal("animationMixerUpdate", _aniamtionMixerUpdateFn)
}
/**
* 改变时间轴长度,可视区域默认一分钟
*/
updateTrackLength() {
this.options.stepVal = 60 * 1000 / (this.timeline._canvasClientWidth() - (this.options.leftMargin || 30));
this.timeline.setOptions(this.options);
}
/**
* 设置轨道行,this.model.rows 永远都只通过此方法变更
*/
setRows(rows: Array<ITimelineRow>) {
const newRows: Array<ITimelineRow> = [];
rows.forEach((row) => {
newRows.push(row);
});
this.model.rows = newRows;
this.timeline.setModel(this.model);
}
/**
* 设置节点是否可见
* @param keys 节点id数组
* @param visible 是否可见
*/
setRowIsVisible(keys: string[], visible: boolean) {
this.model.rows.forEach(row => {
if (keys.includes(row.id)) {
row.hidden = !visible;
}
})
this.timeline.redraw();
}
/**
* 动画混合器更新渲染
* @param mixer d
* @param delta
*/
handleMixerUpdate(mixer: THREE.AnimationMixer, delta: number) {
if (!this.bindAction || !mixer || !delta) return;
if (!this.bindAction.isRunning()) return;
if (this.bindAction.getMixer() !== mixer) return;
const fromPx = this.timeline.scrollLeft;
const toPx = this.timeline.scrollLeft + this.timeline.getClientWidth();
const positionInPixels =
this.timeline.valToPx(this.timeline.getTime()) + this.timeline._leftMargin();
// 如果时间轴超出界限,则滚动至时间轴位置:
if (positionInPixels <= fromPx || positionInPixels >= toPx) {
this.timeline.scrollLeft = positionInPixels;
}
this.timeline.setTime(this.bindAction.time * 1000);
}
/**
* 删除轨道行
* @param row 轨道行
*/
deleteRow(row: ITimelineRow) {
const track = row.track;
if (!this.bindAction || !track) return;
const clip = this.bindAction.getClip();
clip.tracks.splice(clip.tracks.indexOf(track), 1);
// 更新剪辑时间
clip.resetDuration();
// 重新剪辑action
this.bindAction = App.animationManager.reClipAction(this.bindAction,this.timeline.getTime() / 1000) as THREE.AnimationAction;
this.model.rows.splice(this.model.rows.indexOf(row), 1);
// 刷新
this.timeline.redraw();
this.bindAction.getMixer().update(0.016);
useDispatchSignal("sceneGraphChanged");
useDispatchSignal("timelineRowChanged", row, "remove");
}
/**
* 添加关键帧
* @param attr 动画属性名 ('position' | 'rotation' | 'quaternion' |'scale')
*/
addKeyframe(attr: string) {
if (!this.bindAction || !App.selected) return;
// 当前时间轴时间(秒)
const currentTime = this.timeline.getTime() / 1000;
const currentClip = this.bindAction.getClip();
// this.bindAction.getRoot() 获取到的对象可能是editor.locked对象,需要获取正在操作的对象
let val = getNestedProperty(App.selected,attr);
const insertValue = (valueTrack: number[] | boolean[], index: number, delLength: number = 0) => {
let keyData: any[];
switch (attr) {
case "position":
case "rotation":
case "scale":
keyData = [val.x, val.y, val.z];
valueTrack.splice(index, delLength, ...keyData);
break;
case "quaternion":
keyData = [val.x, val.y, val.z, val.w];
valueTrack.splice(index, delLength, ...keyData);
break;
case "visible":
case "fov":
case "near":
case "far":
case "intensity":
case "distance":
case "renderOrder":
case "material.shininess":
case "material.reflectivity":
case "material.roughness":
case "material.metalness":
case "material.clearcoat":
case "material.clearcoatRoughness":
case "material.iridescence":
case "material.iridescenceIOR":
case "material.sheen":
case "material.sheenRoughness":
case "material.transmission":
case "material.attenuationDistance":
case "material.thickness":
case "material.size":
case "material.opacity":
case "material.alphaTest":
// boolean
case "material.vertexColors":
case "material.sizeAttenuation":
case "material.flatShading":
case "material.transparent":
case "material.depthTest":
case "material.depthWrite":
case "material.wireframe":
keyData = [val];
valueTrack.splice(index, delLength, ...keyData);
break;
case "color":
case "groundcolor":
case "material.color":
case "material.specular":
case "material.emissive":
case "material.sheenColor":
case "material.attenuationColor":
if(!(val instanceof THREE.Color)){
val = new THREE.Color(val);
}
keyData = [val.r, val.g, val.b];
valueTrack.splice(index, delLength, ...keyData);
break;
default:
keyData = [val];
valueTrack.splice(index, delLength, ...keyData);
break;
}
return keyData;
}
// 获取当前添加关键帧的模型的属性轨道
let track = App.animationManager.hasExistingTrack(currentClip, attr) as THREE.KeyframeTrack;
// 如果不存在当前属性轨道,则新增轨道
if (!track) {
// 先获取锁定对象到选中对象路径
let path = App.selected?.name;
if (App.locked && App.selected && App.locked !== App.selected) {
path = getParentPath(App.locked, App.selected);
}
let _times = [currentTime], _values: any[] = [];
const keyData = insertValue(_values, 0);
const _row: ITimelineRow = {
id: `${path}.${attr}`,
name: `${path}.${attr}`,
keyframes: [
{
val: this.timeline.getTime(),
data: keyData,
selected: true
}
]
}
// 如果新建轨道默认关键帧不在0位则补0
if (currentTime !== 0) {
_times.unshift(0);
_values.unshift(...keyData);
_row.keyframes?.unshift({
val:0,
data: keyData,
selected: true
})
}
track = KeyframeTrackFactory(`${path}.${attr}`, _times, _values);
// 新增轨道
currentClip.tracks.push(track);
_row.track = track;
this.model.rows.push(_row)
useDispatchSignal("timelineRowChanged", _row, "add");
} else {
const _times: number[] = Array.from(track.times);
const _values: number[] = Array.from(track.values);
const dataLength = Math.floor(_values.length / _times.length);
const row = this.model.rows.find(row => row.track === track) as ITimelineRow;
// 判断当前时间是否已存在关键帧
let index = _times.findIndex(time => time === currentTime);
let keyData;
if (index !== -1) {
// 更新当前时间的关键帧数据
keyData = insertValue(_values, index, dataLength);
// 动画轨道UI修改关键帧值
if (row && row.keyframes) {
row.keyframes.splice(index, 1, {
val: this.timeline.getTime(),
data: keyData,
selected: true
});
}
} else {
// 获取关键帧数据插入位置
index = _times.length;
for (let i = 0; i < _times.length; i++) {
if (_times[i] > currentTime) {
index = i;
break;
}
}
// 插入关键帧时间
_times.splice(index, 0, currentTime);
// 插入关键帧数据
keyData = insertValue(_values, index * dataLength);
// 动画轨道UI添加关键帧
if (row && row.keyframes) {
row.keyframes.splice(index, 0, {
val: this.timeline.getTime(),
data: keyData,
selected: true
});
}
}
// 创建新的关键帧轨道替换
const newTrack = KeyframeTrackFactory(track.name, _times, _values, track.getInterpolation());
currentClip.tracks.splice(currentClip.tracks.indexOf(track), 1, newTrack);
row.track = newTrack;
}
// 更新剪辑时间
currentClip.resetDuration();
// 重新剪辑action
this.bindAction = App.animationManager.reClipAction(this.bindAction,currentTime) as THREE.AnimationAction;
// 刷新
this.timeline.redraw();
this.bindAction.getMixer().update(0.016);
useDispatchSignal("sceneGraphChanged");
}
/**
* 关键帧被改变时触发(关键帧被拖动)
*/
onKeyframeChanged(args:TimelineKeyframeChangedEvent) {
const row = args.target?.row as ITimelineRow;
const track = row.track;
if (!this.bindAction || !track || !row.keyframes?.length) return;
const clip = this.bindAction.getClip();
// 确保完整,直接重建轨道
const _times: number[] = [], _values: any = [];
row.keyframes.forEach((kf) => {
_times.push(kf.val / 1000);
_values.push(...kf.data);
})
// 创建新的关键帧轨道替换
const newTrack = KeyframeTrackFactory(track.name, _times, _values, track.getInterpolation());
clip.tracks.splice(clip.tracks.indexOf(track), 1, newTrack);
row.track = newTrack;
// 更新剪辑时间
clip.resetDuration();
// 重新剪辑action
this.bindAction = App.animationManager.reClipAction(this.bindAction,this.timeline.getTime() / 1000) as THREE.AnimationAction;
// 刷新
this.timeline.redraw();
this.bindAction.getMixer().update(0.016);
useDispatchSignal("sceneGraphChanged");
}
/**
* 删除选中的关键帧
*/
deleteSelectedKeyframes() {
if (!this.bindAction) return;
const selectedRows = this.model.rows.filter(row => row.keyframes?.some(kf => kf.selected));
selectedRows.forEach(row => {
if(!row.keyframes) return;
// 先删除关键帧
row.keyframes = row.keyframes.filter(kf => !kf.selected);
// 如果关键帧为空,则删除轨道
if (row.keyframes.length === 0) {
this.deleteRow(row);
return;
}
// @ts-ignore
this.onKeyframeChanged({target:{row: row}});
});
}
resize() {
if (!this.timeline) return;
this.timeline._handleWindowResizeEvent();
}
/**
* 播放action
*/
play() {
if (!this.bindAction) return;
// 不允许在播放过程中操纵时间轴(可选)。
this.timeline.setOptions({
timelineDraggable: false,
groupsDraggable: false,
keyframesDraggable: false,
zoom: this.timeline._currentZoom
});
this.bindAction.play();
this.bindAction.paused = false;
}
/**
* 暂停/继续播放action
*/
pause() {
if (!this.bindAction) return;
if (this.bindAction.paused) {
this.bindAction.paused = false;
this.timeline.setOptions({
timelineDraggable: false,
groupsDraggable: false,
keyframesDraggable: false,
zoom: this.timeline._currentZoom
});
} else {
this.bindAction.paused = true;
this.timeline.setOptions({
timelineDraggable: true,
groupsDraggable: true,
keyframesDraggable: true,
zoom: this.timeline._currentZoom
});
}
}
/**
* 停止播放action
*/
stop() {
if (!this.bindAction) return;
this.timeline.setOptions({
timelineDraggable: true,
groupsDraggable: true,
keyframesDraggable: true,
zoom: this.timeline._currentZoom
});
this.timeline.scrollLeft = 0;
this.timeline.setTime(0);
this.bindAction.stop();
}
/**
* 更新配置
*/
setOptions(_options: TimelineOptions) {
deepAssign(this.options, _options);
this.timeline.setOptions(this.options);
}
dispose() {
this.resizeObserver.disconnect();
this.timeline?.dispose();
}
}
export {TimelineTrack}
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
import * as THREE from 'three';
import { CSM as _CSM } from 'three/examples/jsm/csm/CSM.js';
import { useDispatchSignal } from '@/hooks';
import App from "@/core/app/App";
// Cascaded Shadow Maps(级联阴影映射,CSM
class CSM {
instance:_CSM | null = null;
constructor(options:IAppProject.CSM){
this.enabled = options.enabled;
}
get enabled(){
return !!this.instance;
}
set enabled(isEnabled:boolean){
if (!isEnabled){
if(!this.instance) return;
// 移除csm创建的对象
this.instance.remove();
// 销毁csm插入的shader
this.instance.dispose();
this.instance = null;
useDispatchSignal("sceneGraphChanged");
return;
}
/* 以下是启用csm的逻辑 */
if(this.instance){
this.reset();
return;
}
const _config = App.project.getKey("csm");
this.instance = new _CSM({
maxFar: _config.maxFar,
cascades: 4,
mode: _config.mode,
shadowMapSize: _config.shadowMapSize,
lightDirection: new THREE.Vector3(_config.lightDirectionX, _config.lightDirectionY, _config.lightDirectionZ).normalize(),
lightIntensity: _config.lightIntensity,
lightNear: 0.1,
lightFar: _config.maxFar * 2,
lightMargin: 200,
camera: App.viewportCamera,
parent: App.scene
});
this.instance.fade = true;
this.instance.lights.forEach(light => {
// 忽略对csm相关object的处理
light.ignore = true;
light.target.ignore = true;
// 设置的灯光颜色
light.color = new THREE.Color(_config.lightColor);
light.shadow.bias = -0.00001;
})
// 将场景中的全部材质添加到csm
Object.values(App.materials).forEach(material => {
this.setupMaterial(material);
})
this.instance.updateFrustums();
useDispatchSignal("sceneGraphChanged");
}
reset() {
if (!this.instance) return;
this.enabled = false;
this.enabled = true;
}
// 材质添加到csm
setupMaterial(material:THREE.Material){
if(!this.instance) return;
material.shadowSide = THREE.BackSide;
this.instance.setupMaterial(material);
}
updateProperty(key,value){
if (!this.instance) return;
this.instance[key] = value;
this.instance.updateFrustums();
useDispatchSignal("sceneGraphChanged");
}
updateLightColor(color: string){
if (!this.instance) return;
this.instance.lights.forEach(light => {
light.color = new THREE.Color(color);
})
useDispatchSignal("sceneGraphChanged");
}
updateLightIntensity(intensity: number){
if (!this.instance) return;
this.instance.lightIntensity = intensity;
this.instance.lights.forEach(light => {
light.intensity = intensity;
})
useDispatchSignal("sceneGraphChanged");
}
updateLightDirection(direction: "x" | "y" | "z", value: number){
if (!this.instance) return;
this.instance.lightDirection[direction] = value;
useDispatchSignal("sceneGraphChanged");
}
updateFrustums(){
if (!this.instance) return;
this.instance.updateFrustums();
useDispatchSignal("sceneGraphChanged");
}
update(){
if (!this.instance) return;
App.viewportCamera.updateMatrixWorld();
this.instance.update();
}
}
export {CSM}
+134
View File
@@ -0,0 +1,134 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/26 13:59
* @description 应用的全局配置,会存储在本地缓存
*/
import {Storage} from "./Storage";
import {deepAssign, getNestedProperty} from "@/utils";
import {ROAMING_CHARACTERS} from "@/constant";
class Config {
protected storage: Storage;
public config: IAppConfig.Config;
constructor(storage: Storage) {
this.storage = storage;
this.config = {
// UI相关配置
theme: 'os',
mainColor: '#7FE7C4',
// 历史记录功能是否启用
history: false,
// 快捷键相关配置
shortcuts: {
translate: 'w',
rotate: 'e',
scale: 'r',
undo: 'z',
focus: 'f',
},
//漫游角色
roamingCharacter: ROAMING_CHARACTERS.JACKIE
};
this.syncStorage();
}
/**
* 设置初始配置
*/
setConfig(_config:Record<string, any>){
deepAssign(this.config,_config);
this.syncStorage();
}
/**
* 和本地存储中的配置同步
*/
syncStorage(){
for (let key of Object.keys(this.config)) {
this.storage.getConfigItem(key).then(_value => {
if(_value === null){
this.storage.setConfigItem(key, this.config[key])
}else{
let newVal = _value;
// 有可能会在代码开发过程中增加新的配置项
if(this.config[key] && typeof this.config[key] === "object"){
newVal = Object.assign({},this.config[key],_value);
}
this.config[key] = newVal;
if(newVal !== _value){
this.storage.setConfigItem(key, newVal)
}
}
}).catch(() => {
this.storage.setConfigItem(key, this.config[key])
})
}
}
/**
* 获取配置
* @param {string} key 可以多层级,需用.分割,如a.b.c
*/
getKey(key:string): any {
return getNestedProperty(this.config,key);
}
/**
* 设置配置项
* @param {string} key 可以多层级,需用.分割,如a.b.c
* @param {unknown} value 配置项的值
*/
setKey(key:string,value:unknown) {
const keys = key.split(".");
if(keys.length === 1){
this.config[key] = value;
this.storage.setConfigItem(key,value);
return;
}
let obj = this.config;
for (let i = 0; i < keys.length; i++){
if(keys.length - i === 1){
obj[keys[i]] = value;
break;
}
obj = obj[keys[i]];
}
this.storage.setConfigItem(keys[0],this.config[keys[0]]);
}
/**
* 获取快捷键配置
* @param {string} key
*/
getShortcutItem(key: string) {
return this.config.shortcuts[key];
}
/**
* 设置快捷键
* @param {string} key
* @param {any} value
*/
setShortcutItem(key: string,value:any) {
this.config.shortcuts[key] = value;
return this.storage.setConfigItem("shortcuts", this.config.shortcuts)
}
clear() {
for (let key of Object.keys(this.config)) {
this.storage.removeConfigItem(key);
}
}
}
export {Config};
@@ -0,0 +1,224 @@
import type { Object3D } from 'three';
import * as Commands from '@/core/commands/Commands';
import {useSignal} from "@/hooks";
import App from "@/core/app/App";
const {dispatch:useDispathSignal,setActive} = useSignal();
interface Undos{
id:number,
name?:string,
updatable:boolean,
object:Object3D,
type:string,
script,
attributeName:string,
inMemory:boolean,
json:string,
update:(T)=>void,
toJSON:()=>string,
fromJSON:(string)=>void,
undo:()=>void,
execute:()=>void,
}
class History {
public undos:Array<Undos>;
public redos:Array<Undos>;
protected lastCmdTime:number;
protected idCounter:number;
constructor() {
this.undos = [];
this.redos = [];
this.lastCmdTime = Date.now();
this.idCounter = 0;
}
execute( cmd, optionalName ) {
const lastCmd = this.undos[this.undos.length - 1];
const timeDifference = Date.now() - this.lastCmdTime;
const isUpdatableCmd = lastCmd &&
lastCmd.updatable &&
cmd.updatable &&
lastCmd.object === cmd.object &&
lastCmd.type === cmd.type &&
lastCmd.script === cmd.script &&
lastCmd.attributeName === cmd.attributeName;
if ( isUpdatableCmd && cmd.type === 'SetScriptValueCommand' ) {
// 当cmd.type为“SetScriptValueCommand”时,将忽略时间差异
lastCmd.update( cmd );
cmd = lastCmd;
} else if ( isUpdatableCmd && timeDifference < 500 ) {
lastCmd.update( cmd );
cmd = lastCmd;
} else {
// 该命令不可更新,并作为历史记录的新部分添加
this.undos.push( cmd );
cmd.id = ++ this.idCounter;
}
cmd.name = ( optionalName !== undefined ) ? optionalName : cmd.name;
cmd.execute();
cmd.inMemory = true;
if (App.config.getKey('history')) {
//在执行后立即序列化cmd,并将json附加到cmd
cmd.json = cmd.toJSON();
}
this.lastCmdTime = Date.now();
// 清除所有redo命令
this.redos = [];
useDispathSignal("historyChanged",cmd);
}
undo() {
let cmd:Undos | undefined = undefined;
if (this.undos.length > 0) {
cmd = this.undos.pop() as Undos;
if ( cmd.inMemory === false ) {
cmd.fromJSON( cmd.json );
}
}
if ( cmd !== undefined ) {
cmd.undo();
this.redos.push( cmd );
useDispathSignal("historyChanged",cmd);
}
return cmd;
}
redo():Undos |undefined {
let cmd:Undos |undefined = undefined;
if ( this.redos.length > 0 ) {
cmd = this.redos.pop() as Undos;
if ( cmd.inMemory === false ) {
cmd.fromJSON( cmd.json );
}
}
if ( cmd !== undefined ) {
cmd.execute();
this.undos.push( cmd );
useDispathSignal( "historyChanged",cmd );
}
return cmd;
}
toJSON() {
const history:{undos?:Array<string>,redos?:Array<string>} = {};
history.undos = [];
history.redos = [];
if (!App.config.getKey('history')) return history;
//将Undos附加到历史记录
for ( let i = 0; i < this.undos.length; i ++ ) {
if (this.undos[ i ].hasOwnProperty( 'json' )) {
history.undos.push(this.undos[i].json);
}
}
//将Redos附加到历史记录
for ( let i = 0; i < this.redos.length; i ++ ) {
if (this.redos[ i ].hasOwnProperty( 'json' )) {
history.redos.push(this.redos[ i ].json);
}
}
return history;
}
fromJSON( json ) {
if ( json === undefined ) return;
for ( let i = 0; i < json.undos.length; i ++ ) {
const cmdJSON = json.undos[ i ];
//创建一个类型为"json.type"的新对象
const cmd = new Commands[cmdJSON.type](App);
cmd.json = cmdJSON;
cmd.id = cmdJSON.id;
cmd.name = cmdJSON.name;
this.undos.push( cmd );
//设置最后使用的idCounter
this.idCounter = ( cmdJSON.id > this.idCounter ) ? cmdJSON.id : this.idCounter;
}
for ( let i = 0; i < json.redos.length; i ++ ) {
const cmdJSON = json.redos[ i ];
const cmd = new Commands[cmdJSON.type](App);
cmd.json = cmdJSON;
cmd.id = cmdJSON.id;
cmd.name = cmdJSON.name;
this.redos.push( cmd );
this.idCounter = ( cmdJSON.id > this.idCounter ) ? cmdJSON.id : this.idCounter;
}
// 选择最后执行的undo命令
useDispathSignal( "historyChanged",this.undos[ this.undos.length - 1 ] );
}
clear() {
this.undos = [];
this.redos = [];
this.idCounter = 0;
useDispathSignal("historyChanged");
}
goToState( id:number ) {
setActive("sceneGraphChanged",false);
setActive("historyChanged",false);
//下一个弹出的CMD
let cmd:Undos |undefined = this.undos.length > 0 ? this.undos[ this.undos.length - 1 ] : undefined;
if ( cmd === undefined || id > cmd.id ) {
cmd = this.redo();
while ( cmd !== undefined && id > cmd.id ) {
cmd = this.redo();
}
} else {
while ( true ) {
cmd = this.undos[ this.undos.length - 1 ];
if ( cmd === undefined || id === cmd.id ) break;
this.undo();
}
}
setActive("sceneGraphChanged",true);
setActive("historyChanged",true);
useDispathSignal("sceneGraphChanged");
useDispathSignal("historyChanged",cmd);
}
enableSerialization( id ) {
/**
* 因为可能有命令在 this.undos && this.redos
* 没有被.toJSON()序列化的,我们返回
*/
this.goToState(-1);
setActive("sceneGraphChanged",false);
setActive("historyChanged",false);
let cmd:Undos |undefined = this.redo();
while ( cmd !== undefined ) {
if (!cmd.hasOwnProperty('json')) {
cmd.json = cmd.toJSON();
}
cmd = this.redo();
}
setActive("sceneGraphChanged",true);
setActive("historyChanged",true);
this.goToState( id );
}
}
export { History };
@@ -0,0 +1,402 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/26 13:59
* @description 当前项目相关信息
*/
import {getNestedProperty} from "@/utils";
import type {App} from "../App";
import {useRemoveSignal, useAddSignal, useDispatchSignal} from '@/hooks';
import {FPS_OPTIONS} from "@/constant";
export const defaultProjectInfo = (): IAppProject.Info => ({
// 项目运行是否启用xr
xr: false,
// 渲染器相关配置
renderer: {
// 渲染帧率上限,默认60
fps: FPS_OPTIONS.HIGH,
antialias: true,
toneMapping: 0, // NoToneMapping
toneMappingExposure: 1,
shadow: {
enabled: true,
type: 2, // PCF Soft
},
},
// 级联阴影映射
csm: {
enabled: false,
fade: false,
maxFar: 1000,
mode: "practical",
shadowMapSize: 2048,
lightDirectionX: -1,
lightDirectionY: -1,
lightDirectionZ: -1,
lightIntensity: 1,
lightColor: "#ffffff"
},
// 后处理
effect: {
enabled: false,
// 描边线
Outline: {
enabled: true,
// 边缘的强度,值越高边框范围越大
edgeStrength: Number(3.0),
// 发光强度
edgeGlow: Number(0.2),
// 边缘浓度
edgeThickness: Number(1.0),
// 闪烁频率,值越大频率越低
pulsePeriod: Number(0.0),
// 禁用纹理以获得纯线的效果
usePatternTexture: false,
// 可见边缘的颜色
visibleEdgeColor: "#ffee00",
// 不可见边缘的颜色
hiddenEdgeColor: "#ff6a00"
},
// 抗锯齿
FXAA: {
enabled: true,
},
// 辉光
UnrealBloom: {
enabled: false,
// 光晕阈值,值越小,效果越明显
threshold: 0,
// 光晕强度
strength: 1,
// 光晕半径
radius: 0
},
// 背景虚化
Bokeh: {
enabled: false,
// 焦距,调整远近,对焦时才会清晰
focus: 500.0,
// 孔径,类似相机孔径调节
aperture: 0.00005,
// 最大模糊程度
maxblur: 0.01
},
// 像素风
Pixelate: {
enabled: false,
// 像素大小
pixelSize: 6,
// 法向边缘强度
normalEdgeStrength: 0.3,
// 深度边缘强度
depthEdgeStrength: 0.4,
},
// 半色调
Halftone: {
enabled: false,
// 形状:点,椭圆,线,正方形
shape: 1,
// 半径
radius: 4,
// R色旋转
rotateR: Math.PI / 12,
// G色旋转
rotateG: Math.PI / 12 * 2,
// B色旋转
rotateB: Math.PI / 12 * 3,
// 分散度
scatter: 0,
// 混合度
blending: 1,
// 混合模式:线性,相乘,相加,明亮,昏暗
blendingMode: 1,
// 灰度
greyscale: false,
},
// LUT颜色滤镜
LUT: {
enabled: false,
lut: 'Bourbon 64.CUBE',
intensity: 1
},
// 运动残影
Afterimage: {
enabled: false,
damp: 0.95
}
},
// 天气
weather: {
fog: {
enabled: false,
type: "Fog", // Fog, FogExp2
color: "#ffffff",
near: 0.10,
far: 50.0,
density: 0.05,
},
rain: {
enabled: false,
speed: 0.4,
color: "#ffffff",
size: 0.5,
radian: 95,
alpha: 0.4
},
snow: {
enabled: false,
size: 0.5,
density: 1.0,
speed: 1.0,
alpha: 0.5,
accumulation: false,
}
},
// 场景信息
sceneInfo: {
// 场景id,使用uuid
id: "",
// 场景名称
sceneName: "",
// 场景分类 城市、园区、工厂、楼宇、设备、其他...
sceneType: "其他",
// 场景描述
sceneIntroduction: "",
// 场景版本
sceneVersion: 1,
// 项目类型。0Web3D-THREE 1WebGIS-Cesium
projectType: 0,
// 场景封面图
coverPicture: "",
// 场景是否包含图纸
hasDrawing: false,
// 场景zip包地址
zip: "",
// 场景zip包大小
zipSize: "0",
// WebGIS-Cesium 类型项目的基础Cesium配置
cesiumConfig: undefined
},
// 图纸
drawing: {
// 是否已上传图纸
isUploaded: false,
// 图片base64 / cad文件路径
imgSrc: "",
// 是否cad
isCad: false,
// cad图层信息
layers: {},
// 是否正在绘制矩形标记
isDrawingRect: false,
// 选中的矩形索引
selectedRectIndex: -1,
// 标记列表
markList: [],
// 标记图纸时的图纸属性信息
imgInfo: {
width: 0,
height: 0
}
}
})
let drawingMarkDoneFn: null | ((type: "add" | "update", rect: IAppProject.DrawingMark) => void) = null;
class Project {
public app: App
public info: IAppProject.Info;
constructor(app: App) {
this.app = app;
this.info = defaultProjectInfo();
drawingMarkDoneFn = this.drawingMarkListChange.bind(this);
useAddSignal("drawingMarkDone", drawingMarkDoneFn);
}
/**
* 获取配置
* @param {string} key 可以多层级,需用.分割,如a.b.c
*/
getKey(key: string): any {
return getNestedProperty(this.info, key);
}
/**
* 设置配置项,配置变更自动执行相应处理
* @param {string} key 可以多层级,需用.分割,如a.b.c
* @param {unknown} value 配置项的值
* @param {boolean} executeAction 是否自动执行相应处理
*/
setKey(key: string, value: unknown,executeAction: boolean = true) {
const keys = key.split(".");
if (keys.length === 1) {
this.info[key] = value;
} else {
let obj = this.info;
for (let i = 0; i < keys.length; i++) {
if (keys.length - i === 1) {
obj[keys[i]] = value;
break;
}
obj = obj[keys[i]];
}
}
/* 执行相应处理 */
if(!executeAction || ["xr","sceneInfo","drawing"].includes(keys[0])) return;
const secondProperty = keys[1];
// 如果setKey传入的是第一层级的变更且不是特殊单层处理的属性,则遍历为第二层级递归以执行相应处理
if(!secondProperty && !["renderer"].includes(key)) {
const propertyValue = this.info[key];
Object.keys(propertyValue).forEach(secondKey => {
this.setKey(`${key}.${secondKey}`, propertyValue[secondKey]);
})
return;
}
if (key.startsWith("renderer")) {
if(!this.app.viewer) return;
if (["renderer.antialias","renderer"].includes(key)) {
this.app.viewer.createEngine();
} else {
this.app.viewer.renderer.shadowMap.enabled = this.info.renderer.shadow.enabled;
this.app.viewer.renderer.shadowMap.type = this.info.renderer.shadow.type;
this.app.viewer.renderer.toneMapping = this.info.renderer.toneMapping;
this.app.viewer.renderer.toneMappingExposure = this.info.renderer.toneMappingExposure;
this.app.FPS = this.info.renderer.fps;
useDispatchSignal("rendererUpdated");
}
} else if (key.startsWith("csm")) {
switch (key) {
case "csm.enabled":
this.app.csm.enabled = this.info.csm.enabled;
break;
case "csm.fade":
case "csm.maxFar":
case "csm.mode":
this.app.csm.updateProperty(secondProperty, this.info.csm[secondProperty]);
break;
case "csm.shadowMapSize":
this.app.csm.reset();
break;
case "csm.lightColor":
this.app.csm.updateLightColor(this.info.csm.lightColor);
break;
case "csm.lightIntensity":
this.app.csm.updateLightIntensity(this.info.csm.lightIntensity);
break;
case "csm.lightDirectionX":
this.app.csm.updateLightDirection('x',this.info.csm.lightDirectionX);
break;
case "csm.lightDirectionY":
this.app.csm.updateLightDirection('y',this.info.csm.lightDirectionY);
break;
case "csm.lightDirectionZ":
this.app.csm.updateLightDirection('z',this.info.csm.lightDirectionZ);
break;
}
}else if(key.startsWith("effect")){
if(key === "effect.enabled"){
useDispatchSignal("effectEnabledChange",this.info.effect.enabled);
}else{
useDispatchSignal("effectPassConfigChange",secondProperty,this.info.effect[secondProperty]);
}
}else if(key.startsWith("weather")){
switch (key){
case "weather.fog":
useDispatchSignal("sceneFogSettingsChanged");
break;
case "weather.rain":
useDispatchSignal("sceneRainSettingsChanged");
break;
case "weather.snow":
useDispatchSignal("sceneSnowSettingsChanged");
break;
}
}
}
/**
* 设置图纸src
*/
setDrawingSrc(src: string) {
this.info.drawing.isCad = src.split(".").pop() === "dxf";
this.info.drawing.imgSrc = src;
}
/**
* 设置图纸图层显示隐藏
* @param layerName
* @param visible
*/
setDrawingLayerVisible(layerName: string, visible: boolean) {
this.info.drawing.layers[layerName].visible = visible;
}
/**
* 设置图纸所有图层显示隐藏
* @param visible
*/
setDrawingLayerAllVisible(visible: boolean) {
for (let key in this.info.drawing.layers) {
this.info.drawing.layers[key].visible = visible;
}
}
/**
* 图纸标记变更
* @param type
* @param rect
*/
drawingMarkListChange(type: "add" | "update", rect: IAppProject.DrawingMark) {
switch (type) {
case "add":
this.info.drawing.markList.push(rect);
break;
case "update":
const index = this.info.drawing.markList.findIndex(item => item.modelUuid === rect.modelUuid);
if (index !== -1) {
this.info.drawing.markList[index] = rect;
}
break;
}
}
/**
* 重置图纸配置,一般用于清除图纸状态
*/
resetDrawing() {
this.info.drawing = defaultProjectInfo().drawing;
}
// /**
// * 清空所有项目配置
// */
// clear(){
// const sceneInfo = {...this.info.sceneInfo};
//
// this.info = defaultProjectInfo();
//
// this.info.sceneInfo = sceneInfo;
// }
dispose() {
if (drawingMarkDoneFn) {
useRemoveSignal("drawingMarkDone", drawingMarkDoneFn);
drawingMarkDoneFn = null;
}
}
}
export {Project};
@@ -0,0 +1,18 @@
import * as THREE from 'three';
import Loader from "@/core/loader/Loader";
class Resource{
constructor() { }
loadURLTexture(url: string | THREE.Texture, onload: (tex: THREE.Texture) => void = ()=>{}, onerror: (err: any) => void = ()=>{}) {
if(url instanceof THREE.Texture) {
onload(url);
return url;
}
const extension = url.split(".").pop()?.toLowerCase() as string;
return Loader.loadUrlTexture(extension, url, onload,onerror);
}
}
export {Resource};
@@ -0,0 +1,85 @@
import {useDispatchSignal, useAddSignal} from '@/hooks';
import { MeshLambertMaterial } from "three";
import App from "@/core/app/App.ts";
import Loader from "@/core/loader/Loader.ts";
import * as THREE from "three";
class Selector {
public lastIsIFC = false; // 上一次选中的是否是IFC模型
public lastIFCModelID :number | null = null; // 上一次选中的IFC模型ID
private preselectMat = new MeshLambertMaterial({
transparent: true,
opacity: 0.6,
color: 0xff88ff,
depthTest: false,
});
constructor() {
// signals
useAddSignal("intersectionsDetected",async (intersects) => {
if(this.lastIFCModelID !== null){
// 移除之前IFC模型的高亮部分
Loader._ifcLoader.ifcManager.removeSubset(this.lastIFCModelID, this.preselectMat);
this.lastIFCModelID = null;
}
if (intersects.length > 0) {
const object = intersects[0].object;
// ---- 2023/8/10 添加IFC模型检测判断-----
if(object.isIFC){
const index = intersects[0].faceIndex;
const geometry = object.geometry;
const ifc = Loader._ifcLoader.ifcManager;
const id = ifc.getExpressId(geometry, index);
this.lastIFCModelID = object.modelID;
const props = await ifc.getItemProperties(this.lastIFCModelID as number, id,true);
useDispatchSignal("IFCPropertiesVisible",true,props)
this.lastIsIFC = true;
// TODO 部件选中
// 创建子集
Loader._ifcLoader.ifcManager.createSubset({
modelID: this.lastIFCModelID as number,
ids: [id],
material: this.preselectMat,
scene: App.scene,
removePrevious: true,
});
return
}
if(this.lastIsIFC){
useDispatchSignal("IFCPropertiesVisible",false)
this.lastIsIFC = false;
}
if(object.proxy){
this.select(object.proxy);
} else {
this.select(object);
}
} else {
this.select( null );
}
})
}
select(object:THREE.Object3D | null) {
if (App.selected === object) return;
App.selected = object;
useDispatchSignal("objectSelected",object, App.locked);
useDispatchSignal("sceneGraphChanged");
}
deselect() {
this.select(null);
}
}
export {Selector};
@@ -0,0 +1,61 @@
import localforage from 'localforage';
class Storage {
public dbs: { modelsDB: LocalForage,otherDB:LocalForage,configDB:LocalForage };
constructor() {
this.dbs = this.initDB();
}
initDB(){
return {
modelsDB: localforage.createInstance({
name: 'modelsDB',
}),
otherDB: localforage.createInstance({
name: 'otherDB'
}),
configDB: localforage.createInstance({
name: 'configDB'
})
}
}
setModel(key: string, value: any){
this.dbs.modelsDB.setItem(key, value);
}
async getModel(key: string){
return await this.dbs.modelsDB.getItem(key);
}
removeModel(key: string){
return this.dbs.modelsDB.removeItem(key);
}
setOtherItem(key: string, value: any){
this.dbs.otherDB.setItem(key, value);
}
async getOtherItem(key:string){
return await this.dbs.otherDB.getItem(key);
}
removeOtherItem(key: string){
return this.dbs.otherDB.removeItem(key);
}
setConfigItem(key: string, value: any){
return this.dbs.configDB.setItem(key, value);
}
async getConfigItem(key:string){
return await this.dbs.configDB.getItem(key);
}
removeConfigItem(key: string){
return this.dbs.configDB.removeItem(key);
}
}
export {Storage};
@@ -0,0 +1,7 @@
export {Config} from "./Config";
export {CSM} from "./CSM";
export {History} from "./History";
export {Project,defaultProjectInfo} from "./Project";
export {Resource} from "./Resource";
export {Selector} from "./Selector";
export {Storage} from "./Storage";
@@ -0,0 +1,272 @@
import * as THREE from 'three';
import { TeapotGeometry } from '@/core/geometries/TeapotGeometry.js';
import App from "@/core/app/App";
//组
export function Group() {
const group = new THREE.Group();
group.name = 'Group';
return group;
}
//正方体
export function Box() {
const geometry = new THREE.BoxGeometry(1, 1, 1, 1, 1, 1);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Box';
return mesh;
}
//胶囊
export function Capsule() {
const geometry = new THREE.CapsuleGeometry(1, 1, 4, 8);
const material = new THREE.MeshStandardMaterial();
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'Capsule';
return mesh;
}
//圆
export function Circle() {
const geometry = new THREE.CircleGeometry(1, 8, 0, Math.PI * 2);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Circle';
return mesh;
}
//圆柱体
export function Cylinder() {
const geometry = new THREE.CylinderGeometry(1, 1, 1, 8, 1, false, 0, Math.PI * 2);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Cylinder';
return mesh;
}
//十二面体
export function Dodecahedron() {
const geometry = new THREE.DodecahedronGeometry(1, 0);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Dodecahedron';
return mesh;
}
//二十面体
export function Icosahedron() {
const geometry = new THREE.IcosahedronGeometry(1, 0);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Icosahedron';
return mesh;
}
//双锥
export function DoubleCone() {
const geometry = new THREE.LatheGeometry();
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({side: THREE.DoubleSide})
);
mesh.name = 'DoubleCone';
return mesh;
}
//八面体
export function Octahedron() {
const geometry = new THREE.OctahedronGeometry(1, 0);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Octahedron';
return mesh;
}
//平面
export function Plane() {
const geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
const material = new THREE.MeshStandardMaterial();
const mesh = new THREE.Mesh(geometry, material);
mesh.name = 'Plane';
return mesh;
}
//环
export function Ring() {
const geometry = new THREE.RingGeometry(0.5, 1, 8, 1, 0, Math.PI * 2);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Ring';
return mesh;
}
//球体
export function Sphere() {
const geometry = new THREE.SphereGeometry(1, 32, 16, 0, Math.PI * 2, 0, Math.PI);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Sphere';
return mesh;
}
//精灵
export function Sprite() {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial());
sprite.name = 'Sprite';
return sprite;
}
//四面体
export function Tetrahedron() {
const geometry = new THREE.TetrahedronGeometry(1, 0);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Tetrahedron';
return mesh;
}
//圆环体
export function Torus() {
const geometry = new THREE.TorusGeometry(1, 0.4, 8, 6, Math.PI * 2);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Torus';
return mesh;
}
//环面扭结体
export function TorusKnot() {
const geometry = new THREE.TorusKnotGeometry(1, 0.4, 64, 8, 2, 3);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'TorusKnot';
return mesh;
}
//管
export function Tube() {
const path = new THREE.CatmullRomCurve3([
new THREE.Vector3(2, 2, -2),
new THREE.Vector3(2, -2, -0.6666666666666667),
new THREE.Vector3(-2, -2, 0.6666666666666667),
new THREE.Vector3(-2, 2, 2),
]);
const geometry = new THREE.TubeGeometry(path, 64, 1, 8, false);
const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
mesh.name = 'Tube';
return mesh;
}
//茶壶
export function Teapot() {
let size = 2;
let segments = 10;
let bottom = true;
let lid = true;
let body = true;
let fitLid = false;
let blinn = 1;
let material = new THREE.MeshStandardMaterial();
// @ts-ignore
let geometry = new TeapotGeometry(size, segments, bottom, lid, body, fitLid, blinn);
let mesh = new THREE.Mesh(geometry, material);
mesh.name = 'Teapot';
return mesh;
}
//环境光
export function AmbientLight() {
const color = 0xffffff;
const light = new THREE.AmbientLight(color);
light.name = 'AmbientLight';
return light;
}
//平行光
export function DirectionalLight() {
const color = 0xffffff;
const intensity = 1;
const light = new THREE.DirectionalLight(color, intensity);
light.name = 'DirectionalLight';
light.target.name = 'DirectionalLight Target';
light.position.set(5, 10, 7.5);
return light;
}
//半球光
export function HemisphereLight() {
const skyColor = 0x00aaff;
const groundColor = 0xffaa00;
const intensity = 1;
const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
light.name = 'HemisphereLight';
light.position.set(0, 10, 0);
return light;
}
//点光源
export function PointLight() {
const color = 0xffffff;
const intensity = 1;
const distance = 0;
const light = new THREE.PointLight(color, intensity, distance);
light.name = 'PointLight';
return light;
}
//聚光灯
export function Spotlight() {
const color = 0xffffff;
const intensity = 1;
const distance = 0;
const angle = Math.PI * 0.1;
const penumbra = 0;
const light = new THREE.SpotLight(color, intensity, distance, angle, penumbra);
light.name = 'SpotLight';
light.target.name = 'SpotLight Target';
light.position.set(5, 10, 7.5);
return light;
}
/*******************************************相机********************************************************/
//正交相机
export function OrthographicCamera() {
const aspect = App.camera.aspect;
const camera = new THREE.OrthographicCamera(-aspect, aspect);
camera.name = 'OrthographicCamera';
return camera;
}
//透视相机
export function PerspectiveCamera() {
const camera = new THREE.PerspectiveCamera();
camera.name = 'PerspectiveCamera';
return camera;
}
@@ -0,0 +1,278 @@
import * as THREE from 'three';
import * as _Particle from '@/core/libs/three-nebula';
export class Particles {
static DotImageUrl = new URL(import.meta.env.BASE_URL + 'resource/textures/dot.png', import.meta.url).href;
static SmokeImageUrl = new URL(import.meta.env.BASE_URL + 'resource/textures/smoke.png', import.meta.url).href;
constructor() { }
// 烟雾
static smoke(initPosition = { x: 0, y: 0, z: 0 }) {
const map = new THREE.TextureLoader().load(Particles.DotImageUrl);
const material = new THREE.SpriteMaterial({
map: map,
color: 0x000000,
// fog: true,
});
const body = new THREE.Sprite(material);
const emitter = new _Particle.Emitter();
emitter.damping = 0.008;
const position = new _Particle.Position();
position.addZone(new _Particle.PointZone(0, 0, 0));
emitter
.setRate(
new _Particle.Rate(
new _Particle.Span(20, 40), // 发射粒子的数量范围
new _Particle.Span(0.01, 0.02) //每次粒子发射之间的时间间隔
)
) // 设置粒子发射的速率
.setInitializers([
new _Particle.Mass(30, 10, true, true), // 设置初始化粒子的质量属性
new _Particle.Life(1, 3, false, true), // 设置初始化粒子的生命值属性
new _Particle.Body(body), // 设置初始化粒子的主体属性
new _Particle.Radius(1, 1, true, true), // 设置初始化粒子的半径属性
new _Particle.Rotation(0, 0, 0, true, true), // 设置初始化粒子的旋转属性
position,
new _Particle.VectorVelocity(new _Particle.Vector3D(1, 2, 1), 60, true), // 设置初始化粒子的速度属性
]) //设置发射器的粒子初始化器
.setBehaviours([
new _Particle.Alpha(1, 0, Infinity, _Particle.ease.easeOutCubic, true), // 对粒子应用阿尔法转换效果的行为
new _Particle.Color("#000000", "#0E0E0E", Infinity, _Particle.ease.easeOutCubic, true), // 一种随时间改变粒子颜色的行为
//new _Particle.Scale(1, 0.5, Infinity, _Particle.ease.easeLinear, true), // 缩放粒子的行为
new _Particle.Force(0, 2, 0, Infinity, _Particle.ease.easeLinear, true), // 迫使粒子沿特定轴线运动的行为
//new _Particle.Rotate(45, 0, 0, Infinity, _Particle.ease.easeLinear, true), // 旋转粒子的行为
new _Particle.RandomDrift(1, 2, 1, 0.7, Infinity, _Particle.ease.easeLinear), // 导致粒子漂移到三维空间随机坐标的行为
//new _Particle.Spring(1, 5, 0, 0.01, 1, Infinity, _Particle.ease.easeLinear, true) // 使粒子弹起的行为
])
.setPosition({ ...initPosition })
.setRotation({
x: 0,
y: 0,
z: 0,
})
.emit() // 可以接收两个参数来设置发射器发射粒子的总次数以及发射器的寿命。同时初始化发射器速率。这样发射器就能发射粒子。
.setTotalEmitTimes(Infinity) // 设置发射器的总发射次数
.setLife(Infinity) // 设置发射器的寿命(毫秒)
return {emitter,body};
}
// 火焰
static fire(initPosition = { x: 0, y: 0, z: 0 }) {
const map = new THREE.TextureLoader().load(Particles.SmokeImageUrl);
const material = new THREE.SpriteMaterial({
map: map,
color: 0xffffff,
// fog: true,
});
const body = new THREE.Sprite(material);
const emitter = new _Particle.Emitter();
emitter.damping = 0.02;
const position = new _Particle.Position();
position.addZone(new _Particle.PointZone(0, 0, 0));
emitter
.setRate(
new _Particle.Rate(
new _Particle.Span(20, 30),
new _Particle.Span(0.01, 0.02)
)
) // 设置粒子发射的速率
.setInitializers([
new _Particle.Mass(30, 10, true, true),
new _Particle.Life(1, 3, false, true),
new _Particle.Body(body),
new _Particle.Radius(1, 1, false, true),
new _Particle.Rotation(0, 0, 0, true, true),
position,
new _Particle.RadialVelocity(4, new _Particle.Vector3D(0, 1, 0), 45, true),
]) //设置发射器的粒子初始化器
.setBehaviours([
new _Particle.Alpha(1, 0, Infinity, _Particle.ease.easeOutQuad, true),
new _Particle.Color("#FF2D08", "#560000", Infinity, _Particle.ease.easeOutBack, true),
new _Particle.Force(0, 2, 0, Infinity, _Particle.ease.easeLinear, true),
new _Particle.Rotate(0, 0, 5, Infinity, _Particle.ease.easeLinear, true),
])
.setPosition({ ...initPosition })
.setRotation({
x: 0,
y: 0,
z: 0,
})
.emit()
.setTotalEmitTimes(Infinity)
.setLife(Infinity)
return {emitter,body};
}
// 火线
static fireLine(initPosition = { x: 0, y: 0, z: 0 }) {
const map = new THREE.TextureLoader().load(Particles.SmokeImageUrl);
const material = new THREE.SpriteMaterial({
map: map,
color: 0xffffff,
// fog: true,
});
const body = new THREE.Sprite(material);
const emitter = new _Particle.Emitter();
emitter.damping = 0.02;
const position = new _Particle.Position();
position.addZone(
new _Particle.LineZone(
5,
0,
0,
-5,
0,
0,
)
);
emitter
.setRate(
new _Particle.Rate(
new _Particle.Span(30, 50),
new _Particle.Span(0.01, 0.02)
)
) // 设置粒子发射的速率
.setInitializers([
new _Particle.Mass(60, 50, false, true),
new _Particle.Life(1, 3, false, true),
new _Particle.Body(body),
new _Particle.Radius(1, 1, false, true),
new _Particle.Rotation(0, 0, 0, true, true),
position,
]) //设置发射器的粒子初始化器
.setBehaviours([
new _Particle.Alpha(1, 0, Infinity, _Particle.ease.easeOutQuad, true),
new _Particle.Color("#FF2D08", "#560000", Infinity, _Particle.ease.easeOutBack, true),
new _Particle.Force(0, 2, 0, Infinity, _Particle.ease.easeLinear, true),
])
.setPosition({ ...initPosition })
.setRotation({
x: 0,
y: 0,
z: 0,
})
.emit()
.setTotalEmitTimes(Infinity)
.setLife(Infinity)
return {emitter,body};
}
// 萤火虫
static firefly(initPosition = { x: 0, y: 0, z: 0 }) {
const map = new THREE.TextureLoader().load(Particles.DotImageUrl);
const material = new THREE.SpriteMaterial({
map: map,
color: 0x000000,
// fog: true,
});
const body = new THREE.Sprite(material);
const emitter = new _Particle.Emitter();
emitter.damping = 1;
const position = new _Particle.Position();
position.addZone(
new _Particle.BoxZone(
0,
0,
0,
100,
100,
100,
)
);
emitter
.setRate(
new _Particle.Rate(
new _Particle.Span(10, 20),
new _Particle.Span(0.01, 0.02)
)
)
.setInitializers([
new _Particle.Life(1, 3, false, true),
new _Particle.Body(body),
new _Particle.Radius(0.5, 0.5, false, true),
position,
])
.setBehaviours([
new _Particle.Alpha(1, 0.1, Infinity, _Particle.ease.easeOutQuad, true),
new _Particle.Color("#3EF506", "#E6D200", Infinity, _Particle.ease.easeLinear, true),
new _Particle.RandomDrift(1, 2, 1, 0.7, Infinity, _Particle.ease.easeLinear),
])
.setPosition({ ...initPosition })
.setRotation({
x: 0,
y: 0,
z: 0,
})
.emit()
.setTotalEmitTimes(Infinity)
.setLife(Infinity)
return {emitter,body};
}
// 烟花
static fireworks(initPosition = { x: 0, y: 0, z: 0 }) {
const map = new THREE.TextureLoader().load(Particles.DotImageUrl);
const material = new THREE.SpriteMaterial({
map: map,
color: 0xff0000,
blending: THREE.AdditiveBlending,
// fog: true,
});
const body = new THREE.Sprite(material);
const emitter = new _Particle.Emitter();
const position = new _Particle.Position();
position.addZone(new _Particle.SphereZone(0, 0, 0, 1));
emitter
.setRate(
new _Particle.Rate(
new _Particle.Span(30, 50),
new _Particle.Span(0.05, 0.1)
)
)
.setInitializers([
new _Particle.Mass(1,1, false, true),
new _Particle.Life(1, 3, false, true),
new _Particle.Body(body),
new _Particle.Radius(1, 1, false, true),
position,
new _Particle.RadialVelocity(new _Particle.Span(50, 80), new _Particle.Vector3D(0, 1, 0), 30),
])
.setBehaviours([
new _Particle.Scale(new _Particle.Span(2, 2.5), 0, Infinity, _Particle.ease.easeLinear, true),
new _Particle.Color('#FF0026', '#ffff11', Infinity, _Particle.ease.easeOutSine, true),
new _Particle.Force(0, -0.6, 0, Infinity, _Particle.ease.easeLinear, true),
new _Particle.RandomDrift(1, 1, 1, 0.5, Infinity, _Particle.ease.easeLinear),
])
.setPosition({ ...initPosition })
.setRotation({
x: 0,
y: 0,
z: 0,
})
.emit()
.setTotalEmitTimes(Infinity)
.setLife(Infinity)
return {emitter,body};
}
}
@@ -0,0 +1,49 @@
import { Command } from './Command';
import { ObjectLoader } from '../loader/ObjectLoader';
import App from "../app/App";
/**
* @param object THREE.Object3D
* @constructor
*/
class AddObjectCommand extends Command {
public object;
constructor( object ) {
super();
this.type = 'AddObjectCommand';
this.object = object;
if ( object !== undefined ) {
this.name = `Add object`;
}
}
execute() {
App.addObject(this.object);
App.select(this.object);
}
undo() {
App.removeObject( this.object );
App.deselect();
}
toJSON() {
const output = super.toJSON();
output.object = this.object.toJSON();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.object.object.uuid );
if ( this.object === undefined ) {
const loader = new ObjectLoader();
this.object = loader.parse( json.object );
}
}
}
export { AddObjectCommand };
@@ -0,0 +1,64 @@
import {Object3D} from "three";
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param script javascript object
* @constructor
*/
class AddScriptCommand extends Command {
private object: Object3D;
private script: any;
constructor(object:Object3D, script) {
super();
this.type = 'AddScriptCommand';
this.name = 'Add script';
this.object = object;
this.script = script;
}
execute() {
if (App.scripts[this.object.uuid] === undefined) {
App.scripts[this.object.uuid] = [];
}
App.scripts[this.object.uuid].push(this.script);
useDispatchSignal("scriptAdded", this.object,this.script);
}
undo() {
if (App.scripts[ this.object.uuid ] === undefined ) return;
const index = App.scripts[ this.object.uuid ].indexOf( this.script );
if (index !== -1) {
App.scripts[ this.object.uuid ].splice( index, 1 );
}
useDispatchSignal("scriptRemoved", this.object,this.script);
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.script = this.script;
return output;
}
fromJSON(json) {
super.fromJSON( json );
this.script = json.script;
this.object = App.getObjectByUuid(json.objectUuid) as Object3D;
}
}
export { AddScriptCommand };
+35
View File
@@ -0,0 +1,35 @@
/**
* @constructor
*/
class Command {
protected id:number;
protected inMemory:boolean;
public updatable:boolean;
protected type:string;
protected name:string;
constructor() {
this.id = - 1;
this.inMemory = false;
this.updatable = false;
this.type = '';
this.name = '';
}
toJSON() {
const output:any = {};
output.type = this.type;
output.id = this.id;
output.name = this.name;
return output;
}
fromJSON( json ) {
this.inMemory = true;
this.type = json.type;
this.id = json.id;
this.name = json.name;
}
}
export { Command };
@@ -0,0 +1,21 @@
export { AddObjectCommand } from './AddObjectCommand';
export { AddScriptCommand } from './AddScriptCommand';
export { MoveObjectCommand } from './MoveObjectCommand';
export { RemoveObjectCommand } from './RemoveObjectCommand';
export { RemoveScriptCommand } from './RemoveScriptCommand';
export { SetColorCommand } from './SetColorCommand';
export { SetGeometryCommand } from './SetGeometryCommand';
export { SetGeometryValueCommand } from './SetGeometryValueCommand';
export { SetMaterialColorCommand } from './SetMaterialColorCommand';
export { SetMaterialCommand } from './SetMaterialCommand';
export { SetMaterialMapCommand } from './SetMaterialMapCommand';
export { SetMaterialRangeCommand } from './SetMaterialRangeCommand';
export { SetMaterialValueCommand } from './SetMaterialValueCommand';
export { SetMaterialVectorCommand } from './SetMaterialVectorCommand';
export { SetPositionCommand } from './SetPositionCommand';
export { SetRotationCommand } from './SetRotationCommand';
export { SetScaleCommand } from './SetScaleCommand';
export { SetSceneCommand } from './SetSceneCommand';
export { SetScriptValueCommand } from './SetScriptValueCommand';
export { SetUuidCommand } from './SetUuidCommand';
export { SetValueCommand } from './SetValueCommand';
@@ -0,0 +1,127 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newParent THREE.Object3D
* @param newBefore THREE.Object3D
* @constructor
*/
class MoveObjectCommand extends Command {
public object;
public oldParent;
public oldIndex;
public newParent;
public newIndex;
public newBefore;
constructor(object, newParent, newBefore ) {
super();
this.type = 'MoveObjectCommand';
this.name = 'Move object';
this.object = object;
this.oldParent = ( object !== undefined ) ? object.parent : undefined;
this.oldIndex = ( this.oldParent !== undefined ) ? this.oldParent.children.indexOf( this.object ) : undefined;
this.newParent = newParent;
if ( newBefore !== undefined ) {
this.newIndex = ( newParent !== undefined ) ? newParent.children.indexOf( newBefore ) : undefined;
} else {
this.newIndex = ( newParent !== undefined ) ? newParent.children.length : undefined;
}
if ( this.oldParent === this.newParent && this.newIndex > this.oldIndex ) {
this.newIndex--;
}
this.newBefore = newBefore;
}
execute() {
this.oldParent.remove(this.object);
/** 放置到新组下时不改变世界坐标 **/
// this.newParent.updateWorldMatrix(true, false);
// const _m1 = new Matrix4();
// _m1.copy(this.newParent.matrixWorld).invert();
// if (this.object.parent !== null) {
// this.object.parent.updateWorldMatrix( true, false );
// _m1.multiply( this.object.parent.matrixWorld );
// }
// this.object.applyMatrix4( _m1 );
/** 放置到新组下时不改变世界坐标 End **/
const children = this.newParent.children;
children.splice( this.newIndex, 0, this.object );
this.object.parent = this.newParent;
/** 放置到新组下时不改变世界坐标 **/
// this.object.updateWorldMatrix( false, true );
/** 放置到新组下时不改变世界坐标 End **/
this.object.dispatchEvent({ type: 'added' });
useDispatchSignal("sceneGraphChanged");
}
undo() {
this.newParent.remove(this.object);
/** 撤销时不改变世界坐标 **/
// this.oldParent.updateWorldMatrix(true, false);
// const _m1 = new Matrix4();
// _m1.copy(this.oldParent.matrixWorld).invert();
// if (this.object.parent !== null) {
// this.object.parent.updateWorldMatrix(true, false);
// _m1.multiply( this.object.parent.matrixWorld );
// }
// this.object.applyMatrix4(_m1);
/** 撤销时不改变世界坐标 End **/
const children = this.oldParent.children;
children.splice( this.oldIndex, 0, this.object );
this.object.parent = this.oldParent;
/** 撤销时不改变世界坐标 **/
// this.object.updateWorldMatrix( false, true );
/** 撤销时不改变世界坐标 End **/
this.object.dispatchEvent( { type: 'added' } );
useDispatchSignal("sceneGraphChanged");
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.newParentUuid = this.newParent.uuid;
output.oldParentUuid = this.oldParent.uuid;
output.newIndex = this.newIndex;
output.oldIndex = this.oldIndex;
return output;
}
fromJSON(json) {
super.fromJSON(json);
this.object = App.getObjectByUuid(json.objectUuid);
this.oldParent = App.getObjectByUuid(json.oldParentUuid);
if (this.oldParent === undefined) {
this.oldParent = App.scene;
}
this.newParent = App.getObjectByUuid(json.newParentUuid);
if (this.newParent === undefined) {
this.newParent = App.scene;
}
this.newIndex = json.newIndex;
this.oldIndex = json.oldIndex;
}
}
export { MoveObjectCommand };
@@ -0,0 +1,64 @@
import { Command } from './Command';
import { ObjectLoader } from '../loader/ObjectLoader';
import App from "../app/App";
/**
* @param object THREE.Object3D
* @constructor
*/
class RemoveObjectCommand extends Command {
public object;
public parent;
public index;
constructor( object ) {
super();
this.type = 'RemoveObjectCommand';
this.name = 'Remove object';
this.object = object;
this.parent = ( object !== undefined ) ? object.parent : undefined;
if ( this.parent !== undefined ) {
this.index = this.parent.children.indexOf( this.object );
}
}
execute() {
App.removeObject( this.object );
App.deselect();
}
undo() {
App.addObject( this.object, this.parent, this.index );
App.select( this.object );
}
toJSON() {
const output = super.toJSON();
output.object = this.object.toJSON();
output.index = this.index;
output.parentUuid = this.parent.uuid;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.parent = App.getObjectByUuid( json.parentUuid );
if ( this.parent === undefined ) {
this.parent = App.scene;
}
this.index = json.index;
this.object = App.getObjectByUuid( json.object.object.uuid );
if ( this.object === undefined ) {
const loader = new ObjectLoader();
this.object = loader.parse( json.object );
}
}
}
export { RemoveObjectCommand };
@@ -0,0 +1,68 @@
import {Object3D} from "three";
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param script javascript object
* @constructor
*/
class RemoveScriptCommand extends Command {
private object: Object3D;
private script: any;
private index: number = -1;
constructor(object:Object3D, script) {
super();
this.type = 'RemoveScriptCommand';
this.name = 'Remove script';
this.object = object;
this.script = script;
if (this.object && this.script) {
this.index = App.scripts[this.object.uuid].findIndex((i) => i.name === this.script.name);
}
}
execute() {
if (App.scripts[ this.object.uuid ] === undefined) return;
if (this.index !== -1) {
App.scripts[this.object.uuid].splice( this.index, 1 );
}
useDispatchSignal("scriptRemoved",this.object,this.script);
}
undo() {
if (App.scripts[ this.object.uuid ] === undefined) {
App.scripts[ this.object.uuid ] = [];
}
App.scripts[this.object.uuid].splice(this.index, 0, this.script);
useDispatchSignal("scriptAdded",this.object,this.script);
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.script = this.script;
output.index = this.index;
return output;
}
fromJSON(json) {
super.fromJSON( json );
this.script = json.script;
this.index = json.index;
this.object = App.getObjectByUuid(json.objectUuid) as Object3D;
}
}
export { RemoveScriptCommand };
@@ -0,0 +1,65 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks/useSignal";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newValue integer representing a hex color value
* @constructor
*/
class SetColorCommand extends Command {
public object;
public attributeName;
public oldValue;
public newValue;
constructor(object, attributeName, newValue ) {
super();
this.type = 'SetColorCommand';
this.name = `Set ${attributeName}`;
this.updatable = true;
this.object = object;
this.attributeName = attributeName;
this.oldValue = (object !== undefined) ? this.object[this.attributeName].getStyle() : undefined;
this.newValue = newValue;
}
execute() {
this.object[ this.attributeName ].setStyle(this.newValue);
useDispatchSignal("objectChanged",this.object);
}
undo() {
this.object[ this.attributeName ].setStyle(this.oldValue);
useDispatchSignal("objectChanged",this.object);
}
update( cmd ) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
}
}
export { SetColorCommand };
@@ -0,0 +1,77 @@
import { Command } from './Command';
import { BufferGeometry,Mesh, InstancedBufferGeometry} from 'three';
import { ObjectLoader } from '../loader/ObjectLoader';
import {useDispatchSignal} from "@/hooks";
import App from "../app/App";
/**
* @param editor Editor
* @param object THREE.Object3D
* @param newGeometry THREE.Geometry
* @constructor
*/
class SetGeometryCommand extends Command {
object:Mesh;
private oldGeometry: BufferGeometry | InstancedBufferGeometry | undefined;
private newGeometry: BufferGeometry | InstancedBufferGeometry
constructor(object:Mesh, newGeometry:BufferGeometry) {
super();
this.type = 'SetGeometryCommand';
this.name = 'Set geometry';
this.updatable = true;
this.object = object;
this.oldGeometry = ( object !== undefined ) ? object.geometry : undefined;
this.newGeometry = newGeometry;
}
execute() {
this.object.geometry.dispose();
this.object.geometry = this.newGeometry;
this.object.geometry.computeBoundingSphere();
useDispatchSignal("geometryChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
undo() {
this.object.geometry.dispose();
this.oldGeometry && (this.object.geometry = this.oldGeometry);
this.object.geometry.computeBoundingSphere();
useDispatchSignal("geometryChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
update(cmd: { newGeometry: BufferGeometry | InstancedBufferGeometry; }) {
this.newGeometry = cmd.newGeometry;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.oldGeometry = this.object.geometry.toJSON();
output.newGeometry = this.newGeometry.toJSON();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid ) as Mesh;
this.oldGeometry = parseGeometry( json.oldGeometry );
this.newGeometry = parseGeometry( json.newGeometry );
function parseGeometry(data) {
const loader = new ObjectLoader();
return loader.parseGeometries( [ data ] )[ data.uuid ];
}
}
}
export { SetGeometryCommand };
@@ -0,0 +1,64 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks/useSignal";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newValue number, string, boolean or object
* @constructor
*/
class SetGeometryValueCommand extends Command {
public object;
public attributeName;
public oldValue;
public newValue;
constructor(object, attributeName, newValue ) {
super();
this.type = 'SetGeometryValueCommand';
this.name = `Set geometry.${attributeName}`;
this.object = object;
this.attributeName = attributeName;
this.oldValue = ( object !== undefined ) ? object.geometry[ attributeName ] : undefined;
this.newValue = newValue;
}
execute() {
this.object.geometry[ this.attributeName ] = this.newValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("geometryChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
undo() {
this.object.geometry[ this.attributeName ] = this.oldValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("geometryChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
}
}
export { SetGeometryValueCommand };
@@ -0,0 +1,69 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newValue integer representing a hex color value
* @constructor
*/
class SetMaterialColorCommand extends Command {
public object;
public material;
public oldValue;
public newValue;
public attributeName;
constructor(object, attributeName, newValue, materialSlot ) {
super();
this.type = 'SetMaterialColorCommand';
this.name = `Set material.${attributeName}`;
this.updatable = true;
this.object = object;
this.material = (this.object !== undefined) ? App.getObjectMaterial(object, materialSlot) : undefined;
this.oldValue = (this.material !== undefined) ? this.material[attributeName].getHex() : undefined;
this.newValue = newValue;
this.attributeName = attributeName;
}
execute() {
this.material[ this.attributeName ].setHex( this.newValue );
useDispatchSignal("materialChanged",this.material);
}
undo() {
this.material[ this.attributeName ].setHex( this.oldValue );
useDispatchSignal("materialChanged",this.material);
}
update( cmd ) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
}
}
export { SetMaterialColorCommand };
@@ -0,0 +1,66 @@
import { Command } from './Command';
import { ObjectLoader } from '../loader/ObjectLoader';
import {useDispatchSignal} from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newMaterial THREE.Material
* @constructor
*/
class SetMaterialCommand extends Command {
private object;
private materialSlot;
private oldMaterial;
private newMaterial;
constructor(object, newMaterial, materialSlot? ) {
super();
this.type = 'SetMaterialCommand';
this.name = 'Set new material';
this.object = object;
this.materialSlot = materialSlot;
this.oldMaterial = App.getObjectMaterial( object, materialSlot );
this.newMaterial = newMaterial;
}
execute() {
App.setObjectMaterial( this.object, this.materialSlot, this.newMaterial );
useDispatchSignal("materialChanged",this.newMaterial);
}
undo() {
App.setObjectMaterial( this.object, this.materialSlot, this.oldMaterial );
useDispatchSignal("materialChanged",this.oldMaterial);
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.oldMaterial = this.oldMaterial.toJSON();
output.newMaterial = this.newMaterial.toJSON();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.oldMaterial = parseMaterial( json.oldMaterial );
this.newMaterial = parseMaterial( json.newMaterial );
function parseMaterial( json ) {
const loader = new ObjectLoader();
//@ts-ignore
const images = loader.parseImages( json.images );
const textures = loader.parseTextures( json.textures, images );
const materials = loader.parseMaterials( [ json ], textures );
return materials[ json.uuid ];
}
}
}
export { SetMaterialCommand };
@@ -0,0 +1,125 @@
import { Command } from './Command';
import { ObjectLoader } from '../loader/ObjectLoader';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param mapName string
* @param newMap THREE.Texture
* @constructor
*/
class SetMaterialMapCommand extends Command {
public object;
private material;
private oldMap;
private newMap;
private mapName;
constructor( object, mapName, newMap, materialSlot ) {
super();
this.type = 'SetMaterialMapCommand';
this.name = `Set material.${mapName}`;
this.object = object;
this.material = App.getObjectMaterial( object, materialSlot );
this.oldMap = ( object !== undefined ) ? this.material[ mapName ] : undefined;
this.newMap = newMap;
this.mapName = mapName;
}
execute() {
if ( this.oldMap !== null && this.oldMap !== undefined ) this.oldMap.dispose();
this.material[ this.mapName ] = this.newMap;
this.material.needsUpdate = true;
useDispatchSignal("materialChanged",this.material)
}
undo() {
this.material[ this.mapName ] = this.oldMap;
this.material.needsUpdate = true;
useDispatchSignal("materialChanged",this.material)
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.mapName = this.mapName;
output.newMap = serializeMap( this.newMap );
output.oldMap = serializeMap( this.oldMap );
return output;
// serializes a map (THREE.Texture)
function serializeMap( map ) {
if ( map === null || map === undefined ) return null;
const meta = {
geometries: {},
materials: {},
textures: {},
images: {}
};
const json = map.toJSON( meta );
const images = extractFromCache( meta.images );
if ( images.length > 0 ) json.images = images;
json.sourceFile = map.sourceFile;
return json;
}
// Note: The function 'extractFromCache' is copied from Object3D.toJSON()
// extract data from the cache hash
// remove metadata on each item
// and return as array
function extractFromCache( cache ) {
const values:any = [];
for ( const key in cache ) {
const data = cache[ key ];
delete data.metadata;
values.push( data );
}
return values;
}
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.mapName = json.mapName;
this.oldMap = parseTexture( json.oldMap );
this.newMap = parseTexture( json.newMap );
function parseTexture( json ) {
let map;
if ( json !== null ) {
const loader = new ObjectLoader();
const images = loader.parseImages( json.images,()=>{} );
const textures = loader.parseTextures( [ json ], images );
map = textures[ json.uuid ];
map.sourceFile = json.sourceFile;
}
return map;
}
}
}
export { SetMaterialMapCommand };
@@ -0,0 +1,76 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newMinValue number
* @param newMaxValue number
* @constructor
*/
class SetMaterialRangeCommand extends Command {
public object;
public material;
public oldValue;
public newValue;
public attributeName;
constructor(object, attributeName, newMinValue, newMaxValue, materialSlot ) {
super();
this.type = 'SetMaterialRangeCommand';
this.name = `Set material.${attributeName}`;
this.updatable = true;
this.object = object;
this.material = App.getObjectMaterial( object, materialSlot );
this.oldValue = ( this.material !== undefined && this.material[ attributeName ] !== undefined ) ? [ ...this.material[ attributeName ] ] : undefined;
this.newValue = [ newMinValue, newMaxValue ];
this.attributeName = attributeName;
}
execute() {
this.material[ this.attributeName ] = [ ...this.newValue ];
this.material.needsUpdate = true;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("materialChanged",this.material);
}
undo() {
this.material[ this.attributeName ] = [ ...this.oldValue ];
this.material.needsUpdate = true;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("materialChanged",this.material);
}
update( cmd ) {
this.newValue = [ ...cmd.newValue ];
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = [ ...this.oldValue ];
output.newValue = [ ...this.newValue ];
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.attributeName = json.attributeName;
this.oldValue = [ ...json.oldValue ];
this.newValue = [ ...json.newValue ];
this.object = App.getObjectByUuid( json.objectUuid );
}
}
export { SetMaterialRangeCommand };
@@ -0,0 +1,75 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newValue number, string, boolean or object
* @constructor
*/
class SetMaterialValueCommand extends Command {
public object;
public material;
public oldValue;
public newValue;
public attributeName;
constructor(object, attributeName, newValue, materialSlot = 0 ) {
super();
this.type = 'SetMaterialValueCommand';
this.name = `Set material.${attributeName}`;
this.updatable = true;
this.object = object;
this.material = App.getObjectMaterial( object, materialSlot );
this.oldValue = ( this.material !== undefined ) ? this.material[ attributeName ] : undefined;
this.newValue = newValue;
this.attributeName = attributeName;
}
execute() {
this.material[ this.attributeName ] = this.newValue;
this.material.needsUpdate = true;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("materialChanged",this.material);
}
undo() {
this.material[ this.attributeName ] = this.oldValue;
this.material.needsUpdate = true;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("materialChanged",this.material);
}
update( cmd ) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
this.object = App.getObjectByUuid( json.objectUuid );
}
}
export { SetMaterialValueCommand };
@@ -0,0 +1,67 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
class SetMaterialVectorCommand extends Command {
public object;
private material;
private oldValue;
private newValue;
private attributeName;
constructor(object, attributeName, newValue, materialSlot) {
super();
this.type = 'SetMaterialColorCommand';
this.name = `Set material.${attributeName}`;
this.updatable = true;
this.object = object;
this.material = App.getObjectMaterial( object, materialSlot );
this.attributeName = attributeName;
this.oldValue = (this.material !== undefined) ? this.attribute.toArray() : undefined;
this.newValue = newValue;
}
get attribute() {
return this.attributeName.split('.').reduce((obj, key) => obj[key], this.material);
}
execute() {
this.attribute.fromArray(this.newValue);
useDispatchSignal("materialChanged",this.material)
}
undo() {
this.attribute.fromArray(this.oldValue);
useDispatchSignal("materialChanged",this.material)
}
update( cmd ) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
}
}
export { SetMaterialVectorCommand };
@@ -0,0 +1,68 @@
import { Vector3 } from 'three';
import { Command } from './Command';
import { useDispatchSignal } from '@/hooks';
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newValue THREE.Vector3
* @param optionaloldValue THREE.Vector3
* @constructor
*/
class SetPositionCommand extends Command {
public object;
public oldValue;
public newValue;
constructor(object, newValue, optionaloldValue? ) {
super();
this.type = 'SetPositionCommand';
this.name = `Set position`;
this.updatable = true;
this.object = object;
if ( object !== undefined && newValue !== undefined ) {
this.oldValue = object.position.clone();
this.newValue = newValue.clone();
}
if ( optionaloldValue !== undefined ) {
this.oldValue = optionaloldValue.clone();
}
}
execute() {
this.object.position.copy( this.newValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object)
}
undo() {
this.object.position.copy( this.oldValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object);
}
update( command ) {
this.newValue.copy( command.newValue );
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.oldValue = this.oldValue.toArray();
output.newValue = this.newValue.toArray();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.oldValue = new Vector3().fromArray( json.oldValue );
this.newValue = new Vector3().fromArray( json.newValue );
}
}
export { SetPositionCommand };
@@ -0,0 +1,71 @@
import { Euler } from 'three';
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newValue THREE.Euler
* @param optionaloldValue THREE.Euler
* @constructor
*/
class SetRotationCommand extends Command {
public object;
public oldValue;
public newValue;
constructor(object, newValue, optionaloldValue ) {
super();
this.type = 'SetRotationCommand';
this.name = `Set rotation`;
this.updatable = true;
this.object = object;
if ( object !== undefined && newValue !== undefined ) {
this.oldValue = object.rotation.clone();
this.newValue = newValue.clone();
}
if ( optionaloldValue !== undefined ) {
this.oldValue = optionaloldValue.clone();
}
}
execute() {
this.object.rotation.copy( this.newValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object);
}
undo() {
this.object.rotation.copy( this.oldValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object);
}
update( command ) {
this.newValue.copy( command.newValue );
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.oldValue = this.oldValue.toArray();
output.newValue = this.newValue.toArray();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.oldValue = new Euler().fromArray( json.oldValue );
this.newValue = new Euler().fromArray( json.newValue );
}
}
export { SetRotationCommand };
@@ -0,0 +1,71 @@
import { Vector3 } from 'three';
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks/useSignal";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newValue THREE.Vector3
* @param optionaloldValue THREE.Vector3
* @constructor
*/
class SetScaleCommand extends Command {
public object;
public oldValue;
public newValue;
constructor(object, newValue, optionaloldValue ) {
super();
this.type = 'SetScaleCommand';
this.name = `Set scale`;
this.updatable = true;
this.object = object;
if ( object !== undefined && newValue !== undefined ) {
this.oldValue = object.scale.clone();
this.newValue = newValue.clone();
}
if ( optionaloldValue !== undefined ) {
this.oldValue = optionaloldValue.clone();
}
}
execute() {
this.object.scale.copy( this.newValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object);
}
undo() {
this.object.scale.copy( this.oldValue );
this.object.updateMatrixWorld( true );
useDispatchSignal("objectChanged",this.object);
}
update( command ) {
this.newValue.copy( command.newValue );
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.oldValue = this.oldValue.toArray();
output.newValue = this.newValue.toArray();
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.object = App.getObjectByUuid( json.objectUuid );
this.oldValue = new Vector3().fromArray( json.oldValue );
this.newValue = new Vector3().fromArray( json.newValue );
}
}
export { SetScaleCommand };
@@ -0,0 +1,85 @@
import {Scene} from "three";
import { Command } from './Command';
import { SetUuidCommand } from './SetUuidCommand';
import { SetValueCommand } from './SetValueCommand';
import { AddObjectCommand } from './AddObjectCommand';
import {useSignal} from "@/hooks";
import App from "../app/App";
const {setActive,dispatch} = useSignal();
/**
* @param scene containing children to import
* @constructor
*/
class SetSceneCommand extends Command {
private cmdArray: any[];
constructor(scene:Scene) {
super();
this.type = 'SetSceneCommand';
this.name = 'Set scene';
this.cmdArray = [];
if (scene !== undefined) {
this.cmdArray.push( new SetUuidCommand(App.scene, scene.uuid));
this.cmdArray.push( new SetValueCommand(App.scene, 'name', scene.name));
this.cmdArray.push( new SetValueCommand(App.scene, 'userData', JSON.parse(JSON.stringify(scene.userData))));
while ( scene.children.length > 0 ) {
const child = scene.children.pop();
this.cmdArray.push(new AddObjectCommand(child));
}
}
}
execute() {
setActive("sceneGraphChanged",false);
for (let i = 0; i < this.cmdArray.length; i++) {
this.cmdArray[i].execute();
}
setActive("sceneGraphChanged",true);
dispatch("sceneGraphChanged");
}
undo() {
setActive("sceneGraphChanged",false);
for (let i = this.cmdArray.length - 1; i >= 0; i--) {
this.cmdArray[i].undo();
}
setActive("sceneGraphChanged",true);
dispatch("sceneGraphChanged");
}
toJSON() {
const output = super.toJSON();
const cmds:string[] = [];
for ( let i = 0; i < this.cmdArray.length; i ++ ) {
cmds.push(this.cmdArray[ i ].toJSON());
}
output.cmds = cmds;
return output;
}
fromJSON(json) {
super.fromJSON( json );
const cmds = json.cmds;
for ( let i = 0; i < cmds.length; i ++ ) {
// @ts-ignore
const cmd = new window[cmds[i].type](); // 创建类型为“json.type”的新对象
cmd.fromJSON(cmds[i]);
this.cmdArray.push(cmd);
}
}
}
export { SetSceneCommand };
@@ -0,0 +1,74 @@
import { Object3D } from 'three';
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param script javascript object
* @param attributeName string
* @param newValue string, object
* @constructor
*/
class SetScriptValueCommand extends Command {
private object: Object3D;
private script: IScript.IStruct;
private attributeName: string;
private oldValue: any;
private newValue: string;
constructor(object:Object3D, script:IScript.IStruct, attributeName:string, newValue:string) {
super();
this.type = 'SetScriptValueCommand';
this.name = `Set script.${attributeName}`;
this.updatable = true;
this.object = object;
this.script = script;
this.attributeName = attributeName;
this.oldValue = ( script !== undefined ) ? script[ this.attributeName ] : undefined;
this.newValue = newValue;
}
execute() {
this.script[this.attributeName] = this.newValue;
useDispatchSignal("scriptChanged",this.attributeName,this.object,this.script);
}
undo() {
this.script[this.attributeName] = this.oldValue;
useDispatchSignal("scriptChanged",this.attributeName,this.object,this.script);
}
update(cmd) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.index = App.scripts[this.object.uuid].indexOf(this.script);
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON(json) {
super.fromJSON(json);
this.oldValue = json.oldValue;
this.newValue = json.newValue;
this.attributeName = json.attributeName;
this.object = App.getObjectByUuid(json.objectUuid) as Object3D;
this.script = App.scripts[json.objectUuid][json.index];
}
}
export { SetScriptValueCommand };
@@ -0,0 +1,61 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param newValue string
* @constructor
*/
class SetUuidCommand extends Command {
public object;
public oldValue;
public newValue;
constructor(object, newValue ) {
super();
this.type = 'SetUuidCommand';
this.name = `Update uuid`;
this.object = object;
this.oldValue = ( object !== undefined ) ? object.uuid : undefined;
this.newValue = newValue;
}
execute() {
this.object.uuid = this.newValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
undo() {
this.object.uuid = this.oldValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
toJSON() {
const output = super.toJSON();
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.oldValue = json.oldValue;
this.newValue = json.newValue;
this.object = App.getObjectByUuid( json.oldValue );
if ( this.object === undefined ) {
this.object = App.getObjectByUuid( json.newValue );
}
}
}
export { SetUuidCommand };
@@ -0,0 +1,68 @@
import { Command } from './Command';
import { useDispatchSignal } from "@/hooks";
import App from "../app/App";
/**
* @param object THREE.Object3D
* @param attributeName string
* @param newValue number, string, boolean or object
* @constructor
*/
class SetValueCommand extends Command {
public object;
public attributeName;
public oldValue;
public newValue;
constructor(object, attributeName, newValue) {
super();
this.type = 'SetValueCommand';
this.name = `Set ${attributeName}`;
this.updatable = true;
this.object = object;
this.attributeName = attributeName;
this.oldValue = ( object !== undefined ) ? object[ attributeName ] : undefined;
this.newValue = newValue;
}
execute() {
this.object[ this.attributeName ] = this.newValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
undo() {
this.object[ this.attributeName ] = this.oldValue;
useDispatchSignal("objectChanged",this.object);
useDispatchSignal("sceneGraphChanged");
}
update( cmd ) {
this.newValue = cmd.newValue;
}
toJSON() {
const output = super.toJSON();
output.objectUuid = this.object.uuid;
output.attributeName = this.attributeName;
output.oldValue = this.oldValue;
output.newValue = this.newValue;
return output;
}
fromJSON( json ) {
super.fromJSON( json );
this.attributeName = json.attributeName;
this.oldValue = json.oldValue;
this.newValue = json.newValue;
this.object = App.getObjectByUuid( json.objectUuid );
}
}
export { SetValueCommand };
@@ -0,0 +1,224 @@
import {
EventDispatcher,
Matrix4,
Plane,
Raycaster,
Vector2,
Vector3
} from 'three';
const _plane = new Plane();
const _raycaster = new Raycaster();
const _pointer = new Vector2();
const _offset = new Vector3();
const _intersection = new Vector3();
const _worldPosition = new Vector3();
const _inverseMatrix = new Matrix4();
class DragControls extends EventDispatcher {
constructor( _objects, _camera, _domElement ) {
super();
_domElement.style.touchAction = 'none'; // disable touch scroll
let _selected = null, _hovered = null;
const _intersections = [];
//
let isMove = false;
const scope = this;
function activate() {
_domElement.addEventListener( 'pointermove', onPointerMove );
_domElement.addEventListener( 'pointerdown', onPointerDown );
_domElement.addEventListener( 'pointerup', onPointerCancel );
_domElement.addEventListener( 'pointerleave', onPointerCancel );
}
function deactivate() {
_domElement.removeEventListener( 'pointermove', onPointerMove );
_domElement.removeEventListener( 'pointerdown', onPointerDown );
_domElement.removeEventListener( 'pointerup', onPointerCancel );
_domElement.removeEventListener( 'pointerleave', onPointerCancel );
_domElement.style.cursor = '';
}
function dispose() {
deactivate();
}
function setObjects( objects ) {
_objects = objects;
}
function getObjects() {
return _objects;
}
function getRaycaster() {
return _raycaster;
}
function onPointerMove( event ) {
if ( !scope.enabled || !scope.enabledMove) return;
isMove = true;
updatePointer( event );
_raycaster.setFromCamera( _pointer, _camera );
if ( _selected ) {
if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
_selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) );
}
scope.dispatchEvent( { type: 'drag', object: _selected } );
return;
}
// hover support
if ( event.pointerType === 'mouse' || event.pointerType === 'pen' ) {
_intersections.length = 0;
_raycaster.setFromCamera( _pointer, _camera );
_raycaster.intersectObjects( _objects, true, _intersections );
if ( _intersections.length > 0 ) {
const object = _intersections[ 0 ].object;
_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( object.matrixWorld ) );
if ( _hovered !== object && _hovered !== null ) {
scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
_domElement.style.cursor = 'auto';
_hovered = null;
}
if ( _hovered !== object ) {
scope.dispatchEvent( { type: 'hoveron', object: object } );
_domElement.style.cursor = 'pointer';
_hovered = object;
}
} else {
if ( _hovered !== null ) {
scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
_domElement.style.cursor = 'auto';
_hovered = null;
}
}
}
}
function onPointerDown( event ) {
if (scope.enabled === false) return;
updatePointer(event);
_intersections.length = 0;
_raycaster.setFromCamera( _pointer, _camera );
let objects = _objects;
if(window.viewer.modules.transformControls){
if(window.viewer.modules.transformControls.object && window.viewer.modules.transformControls._gizmo){
// 如果有transformControls,就把transformControls的gizmo(仅箭头)也加进来
objects = objects.concat(Object.values(window.viewer.modules.transformControls._gizmo.picker));
}
}
_raycaster.intersectObjects( objects, true, _intersections );
if (_intersections.length > 0) {
_selected = (scope.transformGroup === true) ? _objects[ 0 ] : _intersections[0].object;
if(scope.enabledMove) {
_plane.setFromNormalAndCoplanarPoint(_camera.getWorldDirection(_plane.normal), _worldPosition.setFromMatrixPosition(_selected.matrixWorld));
if (_raycaster.ray.intersectPlane(_plane, _intersection)) {
_inverseMatrix.copy(_selected.parent.matrixWorld).invert();
_offset.copy(_intersection).sub(_worldPosition.setFromMatrixPosition(_selected.matrixWorld));
}
_domElement.style.cursor = 'move';
}
scope.dispatchEvent( { type: 'dragstart', object: _selected,e:event } );
}
isMove = false;
}
function onPointerCancel(event) {
if ( scope.enabled === false ) return;
if ( _selected ) {
scope.dispatchEvent( { type: 'dragend', object: _selected,e:event } );
_selected = null;
}else if(!isMove){
// 添加点击空白处的事件
scope.dispatchEvent( { type: 'clickblank',e:event } );
}
_domElement.style.cursor = _hovered ? 'pointer' : 'auto';
}
function updatePointer( event ) {
const rect = _domElement.getBoundingClientRect();
_pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
_pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
}
activate();
// API
this.enabled = true;
this.enabledMove = true;
this.transformGroup = false;
this.activate = activate;
this.deactivate = deactivate;
this.dispose = dispose;
this.setObjects = setObjects;
this.getObjects = getObjects;
this.getRaycaster = getRaycaster;
this.setDomElement = (domElement) => {
_domElement = domElement;
_domElement.style.touchAction = 'none';
deactivate();
activate();
}
}
}
export { DragControls };
@@ -0,0 +1,15 @@
import { Material } from "three";
/**
* 从另一个材质中复制相同的属性(材质类型可能不同)
* @param source - 用于被复制属性的材质,属性为引用
*/
Material.prototype.copyAttr = function (source) {
if (!source.isMaterial) return;
Object.keys(source).forEach(key => {
if (this.hasOwnProperty(key)){
this[key] = source[key];
}
})
}
+273
View File
@@ -0,0 +1,273 @@
import * as THREE from "three";
/**
* 在对象以及后代中执行的回调函数,仅对满足条件的对象执行
* @param callback - 以一个object3D对象作为第一个参数的函数。
* @param condition - 需要满足该条件才继续后续回调的条件函数
*/
THREE.Object3D.prototype.traverseByCondition = function(callback, condition){
if (!condition(this)) return;
callback(this);
const children = this.children;
for (let i = 0, l = children.length; i < l; i++) {
children[i].traverseByCondition(callback, condition);
}
}
/**
* 判断 parentObj 是否是 当前对象 的任意层级祖先(包括祖父、曾祖父等)
* @param parentObj - 可能是祖先的对象
*/
THREE.Object3D.prototype.isAncestor = function(parentObj) {
let current:THREE.Object3D | null = this;
while (current) {
if (current === parentObj) return true;
current = current.parent;
}
return false;
}
/**
* 重写toJSON方法
*/
THREE.Object3D.prototype.toJSON = function(meta:any) {
// 当从JSON.stringify调用时,meta是一个字符串
const isRootObject = (meta === undefined || typeof meta === 'string');
// @ts-ignore
const output: any = {};
// meta是一个散列,用于收集几何图形,材料。不提供它意味着这是被序列化的根对象。
if (isRootObject) {
meta = {
geometries: {},
materials: {},
textures: {},
images: {},
shapes: {},
skeletons: {},
animations: {},
nodes: {}
};
output.metadata = {
version: 4.6,
type: 'Object',
generator: 'Astral.Object3D.toJSON'
};
}
// 标准Object3D序列化
const object:any = {
uuid: this.uuid,
type: this.type
};
if (this.name !== '') object.name = this.name;
if (this.castShadow === true) object.castShadow = true;
if (this.receiveShadow === true) object.receiveShadow = true;
if (this.visible === false) object.visible = false;
if (this.frustumCulled === false) object.frustumCulled = false;
if (this.renderOrder !== 0) object.renderOrder = this.renderOrder;
if (Object.keys(this.userData).length > 0) object.userData = this.userData;
object.layers = this.layers.mask;
object.matrix = this.matrix.toArray();
object.up = this.up.toArray();
if (this.matrixAutoUpdate === false) object.matrixAutoUpdate = false;
// 对象特定属性
if (this.isInstancedMesh) {
object.type = 'InstancedMesh';
object.count = this.count;
object.instanceMatrix = this.instanceMatrix?.toJSON();
if (this.instanceColor !== null) object.instanceColor = this.instanceColor?.toJSON();
}
if (this.isBatchedMesh) {
object.type = 'BatchedMesh';
object.perObjectFrustumCulled = this.perObjectFrustumCulled;
object.sortObjects = this.sortObjects;
object.drawRanges = this._drawRanges;
object.reservedRanges = this._reservedRanges;
object.visibility = this._visibility;
object.active = this._active;
object.bounds = this._bounds.map(bound => ({
boxInitialized: bound.boxInitialized,
boxMin: bound.box.min.toArray(),
boxMax: bound.box.max.toArray(),
sphereInitialized: bound.sphereInitialized,
sphereRadius: bound.sphere.radius,
sphereCenter: bound.sphere.center.toArray()
}));
object.maxInstanceCount = this._maxInstanceCount;
object.maxVertexCount = this._maxVertexCount;
object.maxIndexCount = this._maxIndexCount;
object.geometryInitialized = this._geometryInitialized;
object.geometryCount = this._geometryCount;
object.matricesTexture = this._matricesTexture?.toJSON(meta);
if (this._colorsTexture !== null) object.colorsTexture = this._colorsTexture?.toJSON(meta);
if (this.boundingSphere !== null) {
object.boundingSphere = {
center: object.boundingSphere.center.toArray(),
radius: object.boundingSphere.radius
};
}
if (this.boundingBox !== null) {
object.boundingBox = {
min: object.boundingBox.min.toArray(),
max: object.boundingBox.max.toArray()
};
}
}
function serialize(library, element) {
if (library[element.uuid] === undefined) {
library[element.uuid] = element.toJSON(meta);
}
return element.uuid;
}
if (this.isScene) {
if (this.background) {
if (this.background.isColor) {
object.background = this.background.toJSON();
} else if (this.background.isTexture) {
object.background = this.background.toJSON(meta).uuid;
}
}
if (this.environment && this.environment.isTexture && this.environment.isRenderTargetTexture !== true) {
object.environment = this.environment.toJSON(meta).uuid;
}
} else if (this.isMesh || this.isLine || this.isPoints) {
object.geometry = serialize(meta.geometries, this.geometry);
const parameters = this.geometry.parameters;
if (parameters !== undefined && parameters.shapes !== undefined) {
const shapes = parameters.shapes;
if (Array.isArray(shapes)) {
for (let i = 0, l = shapes.length; i < l; i++) {
const shape = shapes[i];
serialize(meta.shapes, shape);
}
} else {
serialize(meta.shapes, shapes);
}
}
}
if (this.isSkinnedMesh) {
object.bindMode = this.bindMode;
object.bindMatrix = this.bindMatrix.toArray();
if (this.skeleton !== undefined) {
serialize(meta.skeletons, this.skeleton);
object.skeleton = this.skeleton.uuid;
}
}
if (this.material !== undefined) {
// 判断元数据是否含有材质
// 创建新变量替代,不然正在使用的材质被还原回this.metaData.material会造成播放异常
let _material = this.material;
if(this.metaData?.material){
if (this.metaData.material instanceof THREE.Material){
_material = this.metaData.material;
}
}
if (Array.isArray(_material)) {
const uuids:string[] = [];
for (let i = 0, l = _material.length; i < l; i++) {
uuids.push(serialize(meta.materials, _material[i]));
}
object.material = uuids;
} else {
object.material = serialize(meta.materials, _material);
}
}
if (this.children.length > 0) {
object.children = [];
for (let i = 0; i < this.children.length; i++) {
object.children.push(this.children[i].toJSON(meta).object);
}
}
if (this.animations.length > 0) {
object.animations = [];
for (let i = 0; i < this.animations.length; i++) {
let animation = this.animations[i];
// 20250306 修复动画导出问题(代码中处理了object3D.animations,此属性下是AnimationAction数组)
if(animation instanceof THREE.AnimationAction){
animation = animation.getClip();
}
if(!animation) continue;
object.animations.push(serialize(meta.animations, animation));
}
}
if (isRootObject) {
const geometries = extractFromCache(meta.geometries);
const materials = extractFromCache(meta.materials);
const textures = extractFromCache(meta.textures);
const images = extractFromCache(meta.images);
const shapes = extractFromCache(meta.shapes);
const skeletons = extractFromCache(meta.skeletons);
const animations = extractFromCache(meta.animations);
const nodes = extractFromCache(meta.nodes);
if (geometries.length > 0) output.geometries = geometries;
if (materials.length > 0) output.materials = materials;
if (textures.length > 0) output.textures = textures;
if (images.length > 0) output.images = images;
if (shapes.length > 0) output.shapes = shapes;
if (skeletons.length > 0) output.skeletons = skeletons;
if (animations.length > 0) output.animations = animations.map(animation => {
animation.tracks = animation.tracks.map(track => {
if(!track.type){
track.type = 'vector';
}
return track;
});
return animation;
});
if (nodes.length > 0) output.nodes = nodes;
}
output.object = object;
return output;
// 从缓存哈希中提取数据,删除每个项目上的元数据并作为数组返回
function extractFromCache(cache) {
const values:any = [];
for (const key in cache) {
const data = cache[key];
delete data.metadata;
values.push(data);
}
return values;
}
}
+8
View File
@@ -0,0 +1,8 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/01/08
* @description 从原型链扩充原threejs对象方法
*/
import './Object3D';
import './Material';
@@ -0,0 +1,690 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2024/3/11 10:18
* @description 茶壶几何,修改于 three/examples/jsm/geometries/TeapotGeometry.js
*/
import {
BufferAttribute,
BufferGeometry,
Matrix4,
Vector3,
Vector4
} from 'three';
class TeapotGeometry extends BufferGeometry {
private _parameters: { size: number; bottom: boolean; lid: boolean; blinn: boolean; body: boolean; fitLid: boolean; segments: number };
get parameters(): { size: number; bottom: boolean; lid: boolean; blinn: boolean; body: boolean; fitLid: boolean; segments: number } {
return this._parameters;
}
constructor( size = 50, segments = 10, bottom = true, lid = true, body = true, fitLid = true, blinn = true ) {
// 32 * 4 * 4 Bezier spline patches
const teapotPatches = [
/*rim*/
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
3, 16, 17, 18, 7, 19, 20, 21, 11, 22, 23, 24, 15, 25, 26, 27,
18, 28, 29, 30, 21, 31, 32, 33, 24, 34, 35, 36, 27, 37, 38, 39,
30, 40, 41, 0, 33, 42, 43, 4, 36, 44, 45, 8, 39, 46, 47, 12,
/*body*/
12, 13, 14, 15, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
15, 25, 26, 27, 51, 60, 61, 62, 55, 63, 64, 65, 59, 66, 67, 68,
27, 37, 38, 39, 62, 69, 70, 71, 65, 72, 73, 74, 68, 75, 76, 77,
39, 46, 47, 12, 71, 78, 79, 48, 74, 80, 81, 52, 77, 82, 83, 56,
56, 57, 58, 59, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95,
59, 66, 67, 68, 87, 96, 97, 98, 91, 99, 100, 101, 95, 102, 103, 104,
68, 75, 76, 77, 98, 105, 106, 107, 101, 108, 109, 110, 104, 111, 112, 113,
77, 82, 83, 56, 107, 114, 115, 84, 110, 116, 117, 88, 113, 118, 119, 92,
/*handle*/
120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135,
123, 136, 137, 120, 127, 138, 139, 124, 131, 140, 141, 128, 135, 142, 143, 132,
132, 133, 134, 135, 144, 145, 146, 147, 148, 149, 150, 151, 68, 152, 153, 154,
135, 142, 143, 132, 147, 155, 156, 144, 151, 157, 158, 148, 154, 159, 160, 68,
/*spout*/
161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176,
164, 177, 178, 161, 168, 179, 180, 165, 172, 181, 182, 169, 176, 183, 184, 173,
173, 174, 175, 176, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196,
176, 183, 184, 173, 188, 197, 198, 185, 192, 199, 200, 189, 196, 201, 202, 193,
/*lid*/
203, 203, 203, 203, 204, 205, 206, 207, 208, 208, 208, 208, 209, 210, 211, 212,
203, 203, 203, 203, 207, 213, 214, 215, 208, 208, 208, 208, 212, 216, 217, 218,
203, 203, 203, 203, 215, 219, 220, 221, 208, 208, 208, 208, 218, 222, 223, 224,
203, 203, 203, 203, 221, 225, 226, 204, 208, 208, 208, 208, 224, 227, 228, 209,
209, 210, 211, 212, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240,
212, 216, 217, 218, 232, 241, 242, 243, 236, 244, 245, 246, 240, 247, 248, 249,
218, 222, 223, 224, 243, 250, 251, 252, 246, 253, 254, 255, 249, 256, 257, 258,
224, 227, 228, 209, 252, 259, 260, 229, 255, 261, 262, 233, 258, 263, 264, 237,
/*bottom*/
265, 265, 265, 265, 266, 267, 268, 269, 270, 271, 272, 273, 92, 119, 118, 113,
265, 265, 265, 265, 269, 274, 275, 276, 273, 277, 278, 279, 113, 112, 111, 104,
265, 265, 265, 265, 276, 280, 281, 282, 279, 283, 284, 285, 104, 103, 102, 95,
265, 265, 265, 265, 282, 286, 287, 266, 285, 288, 289, 270, 95, 94, 93, 92
];
const teapotVertices = [
1.4, 0, 2.4,
1.4, - 0.784, 2.4,
0.784, - 1.4, 2.4,
0, - 1.4, 2.4,
1.3375, 0, 2.53125,
1.3375, - 0.749, 2.53125,
0.749, - 1.3375, 2.53125,
0, - 1.3375, 2.53125,
1.4375, 0, 2.53125,
1.4375, - 0.805, 2.53125,
0.805, - 1.4375, 2.53125,
0, - 1.4375, 2.53125,
1.5, 0, 2.4,
1.5, - 0.84, 2.4,
0.84, - 1.5, 2.4,
0, - 1.5, 2.4,
- 0.784, - 1.4, 2.4,
- 1.4, - 0.784, 2.4,
- 1.4, 0, 2.4,
- 0.749, - 1.3375, 2.53125,
- 1.3375, - 0.749, 2.53125,
- 1.3375, 0, 2.53125,
- 0.805, - 1.4375, 2.53125,
- 1.4375, - 0.805, 2.53125,
- 1.4375, 0, 2.53125,
- 0.84, - 1.5, 2.4,
- 1.5, - 0.84, 2.4,
- 1.5, 0, 2.4,
- 1.4, 0.784, 2.4,
- 0.784, 1.4, 2.4,
0, 1.4, 2.4,
- 1.3375, 0.749, 2.53125,
- 0.749, 1.3375, 2.53125,
0, 1.3375, 2.53125,
- 1.4375, 0.805, 2.53125,
- 0.805, 1.4375, 2.53125,
0, 1.4375, 2.53125,
- 1.5, 0.84, 2.4,
- 0.84, 1.5, 2.4,
0, 1.5, 2.4,
0.784, 1.4, 2.4,
1.4, 0.784, 2.4,
0.749, 1.3375, 2.53125,
1.3375, 0.749, 2.53125,
0.805, 1.4375, 2.53125,
1.4375, 0.805, 2.53125,
0.84, 1.5, 2.4,
1.5, 0.84, 2.4,
1.75, 0, 1.875,
1.75, - 0.98, 1.875,
0.98, - 1.75, 1.875,
0, - 1.75, 1.875,
2, 0, 1.35,
2, - 1.12, 1.35,
1.12, - 2, 1.35,
0, - 2, 1.35,
2, 0, 0.9,
2, - 1.12, 0.9,
1.12, - 2, 0.9,
0, - 2, 0.9,
- 0.98, - 1.75, 1.875,
- 1.75, - 0.98, 1.875,
- 1.75, 0, 1.875,
- 1.12, - 2, 1.35,
- 2, - 1.12, 1.35,
- 2, 0, 1.35,
- 1.12, - 2, 0.9,
- 2, - 1.12, 0.9,
- 2, 0, 0.9,
- 1.75, 0.98, 1.875,
- 0.98, 1.75, 1.875,
0, 1.75, 1.875,
- 2, 1.12, 1.35,
- 1.12, 2, 1.35,
0, 2, 1.35,
- 2, 1.12, 0.9,
- 1.12, 2, 0.9,
0, 2, 0.9,
0.98, 1.75, 1.875,
1.75, 0.98, 1.875,
1.12, 2, 1.35,
2, 1.12, 1.35,
1.12, 2, 0.9,
2, 1.12, 0.9,
2, 0, 0.45,
2, - 1.12, 0.45,
1.12, - 2, 0.45,
0, - 2, 0.45,
1.5, 0, 0.225,
1.5, - 0.84, 0.225,
0.84, - 1.5, 0.225,
0, - 1.5, 0.225,
1.5, 0, 0.15,
1.5, - 0.84, 0.15,
0.84, - 1.5, 0.15,
0, - 1.5, 0.15,
- 1.12, - 2, 0.45,
- 2, - 1.12, 0.45,
- 2, 0, 0.45,
- 0.84, - 1.5, 0.225,
- 1.5, - 0.84, 0.225,
- 1.5, 0, 0.225,
- 0.84, - 1.5, 0.15,
- 1.5, - 0.84, 0.15,
- 1.5, 0, 0.15,
- 2, 1.12, 0.45,
- 1.12, 2, 0.45,
0, 2, 0.45,
- 1.5, 0.84, 0.225,
- 0.84, 1.5, 0.225,
0, 1.5, 0.225,
- 1.5, 0.84, 0.15,
- 0.84, 1.5, 0.15,
0, 1.5, 0.15,
1.12, 2, 0.45,
2, 1.12, 0.45,
0.84, 1.5, 0.225,
1.5, 0.84, 0.225,
0.84, 1.5, 0.15,
1.5, 0.84, 0.15,
- 1.6, 0, 2.025,
- 1.6, - 0.3, 2.025,
- 1.5, - 0.3, 2.25,
- 1.5, 0, 2.25,
- 2.3, 0, 2.025,
- 2.3, - 0.3, 2.025,
- 2.5, - 0.3, 2.25,
- 2.5, 0, 2.25,
- 2.7, 0, 2.025,
- 2.7, - 0.3, 2.025,
- 3, - 0.3, 2.25,
- 3, 0, 2.25,
- 2.7, 0, 1.8,
- 2.7, - 0.3, 1.8,
- 3, - 0.3, 1.8,
- 3, 0, 1.8,
- 1.5, 0.3, 2.25,
- 1.6, 0.3, 2.025,
- 2.5, 0.3, 2.25,
- 2.3, 0.3, 2.025,
- 3, 0.3, 2.25,
- 2.7, 0.3, 2.025,
- 3, 0.3, 1.8,
- 2.7, 0.3, 1.8,
- 2.7, 0, 1.575,
- 2.7, - 0.3, 1.575,
- 3, - 0.3, 1.35,
- 3, 0, 1.35,
- 2.5, 0, 1.125,
- 2.5, - 0.3, 1.125,
- 2.65, - 0.3, 0.9375,
- 2.65, 0, 0.9375,
- 2, - 0.3, 0.9,
- 1.9, - 0.3, 0.6,
- 1.9, 0, 0.6,
- 3, 0.3, 1.35,
- 2.7, 0.3, 1.575,
- 2.65, 0.3, 0.9375,
- 2.5, 0.3, 1.125,
- 1.9, 0.3, 0.6,
- 2, 0.3, 0.9,
1.7, 0, 1.425,
1.7, - 0.66, 1.425,
1.7, - 0.66, 0.6,
1.7, 0, 0.6,
2.6, 0, 1.425,
2.6, - 0.66, 1.425,
3.1, - 0.66, 0.825,
3.1, 0, 0.825,
2.3, 0, 2.1,
2.3, - 0.25, 2.1,
2.4, - 0.25, 2.025,
2.4, 0, 2.025,
2.7, 0, 2.4,
2.7, - 0.25, 2.4,
3.3, - 0.25, 2.4,
3.3, 0, 2.4,
1.7, 0.66, 0.6,
1.7, 0.66, 1.425,
3.1, 0.66, 0.825,
2.6, 0.66, 1.425,
2.4, 0.25, 2.025,
2.3, 0.25, 2.1,
3.3, 0.25, 2.4,
2.7, 0.25, 2.4,
2.8, 0, 2.475,
2.8, - 0.25, 2.475,
3.525, - 0.25, 2.49375,
3.525, 0, 2.49375,
2.9, 0, 2.475,
2.9, - 0.15, 2.475,
3.45, - 0.15, 2.5125,
3.45, 0, 2.5125,
2.8, 0, 2.4,
2.8, - 0.15, 2.4,
3.2, - 0.15, 2.4,
3.2, 0, 2.4,
3.525, 0.25, 2.49375,
2.8, 0.25, 2.475,
3.45, 0.15, 2.5125,
2.9, 0.15, 2.475,
3.2, 0.15, 2.4,
2.8, 0.15, 2.4,
0, 0, 3.15,
0.8, 0, 3.15,
0.8, - 0.45, 3.15,
0.45, - 0.8, 3.15,
0, - 0.8, 3.15,
0, 0, 2.85,
0.2, 0, 2.7,
0.2, - 0.112, 2.7,
0.112, - 0.2, 2.7,
0, - 0.2, 2.7,
- 0.45, - 0.8, 3.15,
- 0.8, - 0.45, 3.15,
- 0.8, 0, 3.15,
- 0.112, - 0.2, 2.7,
- 0.2, - 0.112, 2.7,
- 0.2, 0, 2.7,
- 0.8, 0.45, 3.15,
- 0.45, 0.8, 3.15,
0, 0.8, 3.15,
- 0.2, 0.112, 2.7,
- 0.112, 0.2, 2.7,
0, 0.2, 2.7,
0.45, 0.8, 3.15,
0.8, 0.45, 3.15,
0.112, 0.2, 2.7,
0.2, 0.112, 2.7,
0.4, 0, 2.55,
0.4, - 0.224, 2.55,
0.224, - 0.4, 2.55,
0, - 0.4, 2.55,
1.3, 0, 2.55,
1.3, - 0.728, 2.55,
0.728, - 1.3, 2.55,
0, - 1.3, 2.55,
1.3, 0, 2.4,
1.3, - 0.728, 2.4,
0.728, - 1.3, 2.4,
0, - 1.3, 2.4,
- 0.224, - 0.4, 2.55,
- 0.4, - 0.224, 2.55,
- 0.4, 0, 2.55,
- 0.728, - 1.3, 2.55,
- 1.3, - 0.728, 2.55,
- 1.3, 0, 2.55,
- 0.728, - 1.3, 2.4,
- 1.3, - 0.728, 2.4,
- 1.3, 0, 2.4,
- 0.4, 0.224, 2.55,
- 0.224, 0.4, 2.55,
0, 0.4, 2.55,
- 1.3, 0.728, 2.55,
- 0.728, 1.3, 2.55,
0, 1.3, 2.55,
- 1.3, 0.728, 2.4,
- 0.728, 1.3, 2.4,
0, 1.3, 2.4,
0.224, 0.4, 2.55,
0.4, 0.224, 2.55,
0.728, 1.3, 2.55,
1.3, 0.728, 2.55,
0.728, 1.3, 2.4,
1.3, 0.728, 2.4,
0, 0, 0,
1.425, 0, 0,
1.425, 0.798, 0,
0.798, 1.425, 0,
0, 1.425, 0,
1.5, 0, 0.075,
1.5, 0.84, 0.075,
0.84, 1.5, 0.075,
0, 1.5, 0.075,
- 0.798, 1.425, 0,
- 1.425, 0.798, 0,
- 1.425, 0, 0,
- 0.84, 1.5, 0.075,
- 1.5, 0.84, 0.075,
- 1.5, 0, 0.075,
- 1.425, - 0.798, 0,
- 0.798, - 1.425, 0,
0, - 1.425, 0,
- 1.5, - 0.84, 0.075,
- 0.84, - 1.5, 0.075,
0, - 1.5, 0.075,
0.798, - 1.425, 0,
1.425, - 0.798, 0,
0.84, - 1.5, 0.075,
1.5, - 0.84, 0.075
];
super();
// @ts-ignore
this.type = "TeapotGeometry";
this._parameters = {
size: size,
segments: segments,
bottom: bottom,
lid: lid,
body: body,
fitLid: fitLid,
blinn: blinn,
};
// number of segments per patch
segments = Math.max( 2, Math.floor( segments ) );
// Jim Blinn scaled the teapot down in size by about 1.3 for
// some rendering tests. He liked the new proportions that he kept
// the data in this form. The model was distributed with these new
// proportions and became the norm. Trivia: comparing images of the
// real teapot and the computer model, the ratio for the bowl of the
// real teapot is more like 1.25, but since 1.3 is the traditional
// value given, we use it here.
const blinnScale = 1.3;
// scale the size to be the real scaling factor
const maxHeight = 3.15 * ( blinn ? 1 : blinnScale );
const maxHeight2 = maxHeight / 2;
const trueSize = size / maxHeight2;
// Number of elements depends on what is needed. Subtract degenerate
// triangles at tip of bottom and lid out in advance.
let numTriangles = bottom ? ( 8 * segments - 4 ) * segments : 0;
numTriangles += lid ? ( 16 * segments - 4 ) * segments : 0;
numTriangles += body ? 40 * segments * segments : 0;
const indices = new Uint32Array( numTriangles * 3 );
let numVertices = bottom ? 4 : 0;
numVertices += lid ? 8 : 0;
numVertices += body ? 20 : 0;
numVertices *= ( segments + 1 ) * ( segments + 1 );
const vertices = new Float32Array( numVertices * 3 );
const normals = new Float32Array( numVertices * 3 );
const uvs = new Float32Array( numVertices * 2 );
// Bezier form
const ms = new Matrix4();
ms.set(
- 1.0, 3.0, - 3.0, 1.0,
3.0, - 6.0, 3.0, 0.0,
- 3.0, 3.0, 0.0, 0.0,
1.0, 0.0, 0.0, 0.0 );
const g:number[] = [];
const sp:number[] = [];
const tp:number[] = [];
const dsp:number[] = [];
const dtp:number[] = [];
// M * G * M matrix, sort of see
// http://www.cs.helsinki.fi/group/goa/mallinnus/curves/surfaces.html
const mgm:Matrix4[] = [];
const vert:number[] = [];
const sdir:number[] = [];
const tdir:number[] = [];
const norm = new Vector3();
let tcoord: Vector4;
let sval:number;
let tval:number;
let p;
let dsval = 0;
let dtval = 0;
const normOut = new Vector3();
const gmx = new Matrix4();
const tmtx = new Matrix4();
const vsp = new Vector4();
const vtp = new Vector4();
const vdsp = new Vector4();
const vdtp = new Vector4();
const vsdir = new Vector3();
const vtdir = new Vector3();
const mst = ms.clone();
mst.transpose();
// internal function: test if triangle has any matching vertices;
// if so, don't save triangle, since it won't display anything.
const notDegenerate = ( vtx1, vtx2, vtx3 ) => // if any vertex matches, return false
! ( ( ( vertices[ vtx1 * 3 ] === vertices[ vtx2 * 3 ] ) &&
( vertices[ vtx1 * 3 + 1 ] === vertices[ vtx2 * 3 + 1 ] ) &&
( vertices[ vtx1 * 3 + 2 ] === vertices[ vtx2 * 3 + 2 ] ) ) ||
( ( vertices[ vtx1 * 3 ] === vertices[ vtx3 * 3 ] ) &&
( vertices[ vtx1 * 3 + 1 ] === vertices[ vtx3 * 3 + 1 ] ) &&
( vertices[ vtx1 * 3 + 2 ] === vertices[ vtx3 * 3 + 2 ] ) ) || ( vertices[ vtx2 * 3 ] === vertices[ vtx3 * 3 ] ) &&
( vertices[ vtx2 * 3 + 1 ] === vertices[ vtx3 * 3 + 1 ] ) &&
( vertices[ vtx2 * 3 + 2 ] === vertices[ vtx3 * 3 + 2 ] ) );
for ( let i = 0; i < 3; i ++ ) {
mgm[ i ] = new Matrix4();
}
const minPatches = body ? 0 : 20;
const maxPatches = bottom ? 32 : 28;
const vertPerRow = segments + 1;
let surfCount = 0;
let vertCount = 0;
let normCount = 0;
let uvCount = 0;
let indexCount = 0;
for ( let surf = minPatches; surf < maxPatches; surf ++ ) {
// lid is in the middle of the data, patches 20-27,
// so ignore it for this part of the loop if the lid is not desired
if ( lid || ( surf < 20 || surf >= 28 ) ) {
// get M * G * M matrix for x,y,z
for ( let i = 0; i < 3; i ++ ) {
// get control patches
for ( let r = 0; r < 4; r ++ ) {
for ( let c = 0; c < 4; c ++ ) {
// transposed
g[ c * 4 + r ] = teapotVertices[ teapotPatches[ surf * 16 + r * 4 + c ] * 3 + i ];
// is the lid to be made larger, and is this a point on the lid
// that is X or Y?
if ( fitLid && ( surf >= 20 && surf < 28 ) && ( i !== 2 ) ) {
// increase XY size by 7.7%, found empirically. I don't
// increase Z so that the teapot will continue to fit in the
// space -1 to 1 for Y (Y is up for the final model).
g[ c * 4 + r ] *= 1.077;
}
// Blinn "fixed" the teapot by dividing Z by blinnScale, and that's the
// data we now use. The original teapot is taller. Fix it:
if ( ! blinn && ( i === 2 ) ) {
g[ c * 4 + r ] *= blinnScale;
}
}
}
gmx.set( g[ 0 ], g[ 1 ], g[ 2 ], g[ 3 ], g[ 4 ], g[ 5 ], g[ 6 ], g[ 7 ], g[ 8 ], g[ 9 ], g[ 10 ], g[ 11 ], g[ 12 ], g[ 13 ], g[ 14 ], g[ 15 ] );
tmtx.multiplyMatrices( gmx, ms );
mgm[ i ].multiplyMatrices( mst, tmtx );
}
// step along, get points, and output
for ( let sstep = 0; sstep <= segments; sstep ++ ) {
const s = sstep / segments;
for ( let tstep = 0; tstep <= segments; tstep ++ ) {
const t = tstep / segments;
// point from basis
// get power vectors and their derivatives
for ( p = 4, sval = tval = 1.0; p --; ) {
sp[ p ] = sval;
tp[ p ] = tval;
sval *= s;
tval *= t;
if ( p === 3 ) {
dsp[ p ] = dtp[ p ] = 0.0;
dsval = dtval = 1.0;
} else {
dsp[ p ] = dsval * ( 3 - p );
dtp[ p ] = dtval * ( 3 - p );
dsval *= s;
dtval *= t;
}
}
vsp.fromArray( sp );
vtp.fromArray( tp );
vdsp.fromArray( dsp );
vdtp.fromArray( dtp );
// do for x,y,z
for ( let i = 0; i < 3; i ++ ) {
// multiply power vectors times matrix to get value
tcoord = vsp.clone();
tcoord.applyMatrix4( mgm[ i ] );
vert[ i ] = tcoord.dot( vtp );
// get s and t tangent vectors
tcoord = vdsp.clone();
tcoord.applyMatrix4( mgm[ i ] );
sdir[ i ] = tcoord.dot( vtp );
tcoord = vsp.clone();
tcoord.applyMatrix4( mgm[ i ] );
tdir[ i ] = tcoord.dot( vdtp );
}
// find normal
vsdir.fromArray( sdir );
vtdir.fromArray( tdir );
norm.crossVectors( vtdir, vsdir );
norm.normalize();
// if X and Z length is 0, at the cusp, so point the normal up or down, depending on patch number
if ( vert[ 0 ] === 0 && vert[ 1 ] === 0 ) {
// if above the middle of the teapot, normal points up, else down
normOut.set( 0, vert[ 2 ] > maxHeight2 ? 1 : - 1, 0 );
} else {
// standard output: rotate on X axis
normOut.set( norm.x, norm.z, - norm.y );
}
// store it all
vertices[ vertCount ++ ] = trueSize * vert[ 0 ];
vertices[ vertCount ++ ] = trueSize * ( vert[ 2 ] - maxHeight2 );
vertices[ vertCount ++ ] = - trueSize * vert[ 1 ];
normals[ normCount ++ ] = normOut.x;
normals[ normCount ++ ] = normOut.y;
normals[ normCount ++ ] = normOut.z;
uvs[ uvCount ++ ] = 1 - t;
uvs[ uvCount ++ ] = 1 - s;
}
}
// save the faces
for ( let sstep = 0; sstep < segments; sstep ++ ) {
for ( let tstep = 0; tstep < segments; tstep ++ ) {
const v1 = surfCount * vertPerRow * vertPerRow + sstep * vertPerRow + tstep;
const v2 = v1 + 1;
const v3 = v2 + vertPerRow;
const v4 = v1 + vertPerRow;
// Normals and UVs cannot be shared. Without clone(), you can see the consequences
// of sharing if you call geometry.applyMatrix4( matrix ).
if ( notDegenerate( v1, v2, v3 ) ) {
indices[ indexCount ++ ] = v1;
indices[ indexCount ++ ] = v2;
indices[ indexCount ++ ] = v3;
}
if ( notDegenerate( v1, v3, v4 ) ) {
indices[ indexCount ++ ] = v1;
indices[ indexCount ++ ] = v3;
indices[ indexCount ++ ] = v4;
}
}
}
// increment only if a surface was used
surfCount ++;
}
}
this.setIndex( new BufferAttribute( indices, 1 ) );
this.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
this.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
this.setAttribute( 'uv', new BufferAttribute( uvs, 2 ) );
this.computeBoundingSphere();
}
copy( source ) {
super.copy( source );
this._parameters = Object.assign( {}, source.parameters );
return this;
}
static fromJSON(data) {
return new TeapotGeometry(data.size, data.segments, data.bottom, data.lid, data.body, data.fitLid, data.blinn);
}
}
export { TeapotGeometry };
@@ -0,0 +1 @@
export {TeapotGeometry} from "./TeapotGeometry";
@@ -0,0 +1 @@
export class EnforceNonZeroErrorPlugin {}
@@ -0,0 +1,48 @@
export class EnforceNonZeroErrorPlugin {
constructor() {
this.name = 'ENFORCE_NONZERO_ERROR';
this.priority = - Infinity;
this.originalError = new Map();
}
preprocessNode( tile ) {
// if a tile has zero error then traverse the parents and find some geometric error value in
// the parent hierarchy to use for calculating a pseudo geometric error for this tile.
if ( tile.geometricError === 0 ) {
let parent = tile.parent;
let depth = 1;
let targetDepth = - 1;
let targetError = Infinity;
while ( parent !== null ) {
if ( parent.geometricError !== 0 && parent.geometricError < targetError ) {
targetError = parent.geometricError;
targetDepth = depth;
}
parent = parent.parent;
depth ++;
}
// find the smallest error in the parent list to avoid grabbing artificially inflated error values
// for the sake of forced refinement. Then scale the error by the depth.
if ( targetDepth !== - 1 ) {
tile.geometricError = targetError * ( 2 ** - depth );
}
}
}
}
@@ -0,0 +1 @@
export class ImplicitTilingPlugin {}
@@ -0,0 +1,93 @@
import { SUBTREELoader } from './SUBTREELoader.js';
export class ImplicitTilingPlugin {
constructor() {
this.name = 'IMPLICIT_TILING_PLUGIN';
}
init( tiles ) {
this.tiles = tiles;
}
preprocessNode( tile, tileSetDir, parentTile ) {
if ( tile.implicitTiling ) {
tile.__hasUnrenderableContent = true;
tile.__hasRenderableContent = false;
// Declare some properties
tile.__subtreeIdx = 0; // Idx of the tile in its subtree
tile.__implicitRoot = tile; // Keep this tile as an Implicit Root Tile
// Coords of the tile
tile.__x = 0;
tile.__y = 0;
tile.__z = 0;
tile.__level = 0;
} else if ( /.subtree$/i.test( tile.content?.uri ) ) {
// Handling content uri pointing to a subtree file
tile.__hasUnrenderableContent = true;
tile.__hasRenderableContent = false;
}
}
parseTile( buffer, tile, extension ) {
if ( /^subtree$/i.test( extension ) ) {
const loader = new SUBTREELoader( tile );
loader.workingPath = tile.__basePath;
loader.fetchOptions = this.tiles.fetchOptions;
return loader.parse( buffer );
}
}
preprocessURL( url, tile ) {
if ( tile && tile.implicitTiling ) {
const implicitUri = tile.implicitTiling.subtrees.uri
.replace( '{level}', tile.__level )
.replace( '{x}', tile.__x )
.replace( '{y}', tile.__y )
.replace( '{z}', tile.__z );
return new URL( implicitUri, tile.__basePath + '/' ).toString();
}
return url;
}
disposeTile( tile ) {
if ( /.subtree$/i.test( tile.content?.uri ) ) {
// TODO: ideally the plugin doesn't need to know about children being processed
tile.children.forEach( child => {
// TODO: there should be a reliable way for removing children like this.
this.tiles.processNodeQueue.remove( child );
} );
tile.children.length = 0;
tile.__childrenProcessed = 0;
}
}
}
@@ -0,0 +1,867 @@
/**
* Structure almost identical to Cesium, also the comments and the names are kept
* https://github.com/CesiumGS/cesium/blob/0a69f67b393ba194eefb7254600811c4b712ddc0/packages/engine/Source/Scene/Implicit3DTileContent.js
*/
import { LoaderBase, LoaderUtils } from '3d-tiles-renderer/core';
function isOctreeSubdivision( tile ) {
return tile.__implicitRoot.implicitTiling.subdivisionScheme === 'OCTREE';
}
function getBoundsDivider( tile ) {
return isOctreeSubdivision( tile ) ? 8 : 4;
}
function getSubtreeCoordinates( tile, parentTile ) {
if ( ! parentTile ) {
return [ 0, 0, 0 ];
}
const x = 2 * parentTile.__x + ( tile.__subtreeIdx % 2 );
const y = 2 * parentTile.__y + ( Math.floor( tile.__subtreeIdx / 2 ) % 2 );
const z = isOctreeSubdivision( tile ) ?
2 * parentTile.__z + ( Math.floor( tile.__subtreeIdx / 4 ) % 2 ) : 0;
return [ x, y, z ];
}
class SubtreeTile {
constructor( parentTile, childMortonIndex ) {
this.parent = parentTile;
this.children = [];
this.__level = parentTile.__level + 1;
this.__implicitRoot = parentTile.__implicitRoot;
// Index inside the tree
this.__subtreeIdx = childMortonIndex;
[ this.__x, this.__y, this.__z ] = getSubtreeCoordinates( this, parentTile );
}
static copy( tile ) {
const copyTile = {};
copyTile.children = [];
copyTile.__level = tile.__level;
copyTile.__implicitRoot = tile.__implicitRoot;
// Index inside the tree
copyTile.__subtreeIdx = tile.__subtreeIdx;
[ copyTile.__x, copyTile.__y, copyTile.__z ] = [ tile.__x, tile.__y, tile.__z ];
copyTile.boundingVolume = tile.boundingVolume;
copyTile.geometricError = tile.geometricError;
return copyTile;
}
}
export class SUBTREELoader extends LoaderBase {
constructor( tile ) {
super();
this.tile = tile;
this.rootTile = tile.__implicitRoot; // The implicit root tile
this.workingPath = null;
}
/**
* A helper object for storing the two parts of the subtree binary
*
* @typedef {object} Subtree
* @property {number} version
* @property {JSON} subtreeJson
* @property {ArrayBuffer} subtreeByte
* @private
*/
/**
*
* @param buffer
* @return {Subtree}
*/
parseBuffer( buffer ) {
const dataView = new DataView( buffer );
let offset = 0;
// 16-byte header
// 4 bytes
const magic = LoaderUtils.readMagicBytes( dataView );
console.assert( magic === 'subt', 'SUBTREELoader: The magic bytes equal "subt".' );
offset += 4;
// 4 bytes
const version = dataView.getUint32( offset, true );
console.assert( version === 1, 'SUBTREELoader: The version listed in the header is "1".' );
offset += 4;
// From Cesium
// Read the bottom 32 bits of the 64-bit byte length.
// This is ok for now because:
// 1) not all browsers have native 64-bit operations
// 2) the data is well under 4GB
// 8 bytes
const jsonLength = dataView.getUint32( offset, true );
offset += 8;
// 8 bytes
const byteLength = dataView.getUint32( offset, true );
offset += 8;
const subtreeJson = JSON.parse( LoaderUtils.arrayToString( new Uint8Array( buffer, offset, jsonLength ) ) );
offset += jsonLength;
const subtreeByte = buffer.slice( offset, offset + byteLength );
return {
version,
subtreeJson,
subtreeByte
};
}
async parse( buffer ) {
// todo here : handle json
const subtree = this.parseBuffer( buffer );
const subtreeJson = subtree.subtreeJson;
// TODO Handle metadata
/*
const subtreeMetadata = subtreeJson.subtreeMetadata;
subtree._metadata = subtreeMetadata;
*/
/*
Tile availability indicates which tiles exist within the subtree
Content availability indicates which tiles have associated content resources
Child subtree availability indicates what subtrees are reachable from this subtree
*/
// After identifying how availability is stored, put the results in this new array for consistent processing later
subtreeJson.contentAvailabilityHeaders = [].concat( subtreeJson.contentAvailability );
const bufferHeaders = this.preprocessBuffers( subtreeJson.buffers );
const bufferViewHeaders = this.preprocessBufferViews(
subtreeJson.bufferViews,
bufferHeaders
);
// Buffers and buffer views are inactive until explicitly marked active.
// This way we can avoid fetching buffers that will not be used.
this.markActiveBufferViews( subtreeJson, bufferViewHeaders );
// Await the active buffers. If a buffer is external (isExternal === true),
// fetch it from its URI.
const buffersU8 = await this.requestActiveBuffers(
bufferHeaders,
subtree.subtreeByte
);
const bufferViewsU8 = this.parseActiveBufferViews( bufferViewHeaders, buffersU8 );
this.parseAvailability( subtree, subtreeJson, bufferViewsU8 );
this.expandSubtree( this.tile, subtree );
}
/**
* Determine which buffer views need to be loaded into memory. This includes:
*
* <ul>
* <li>The tile availability bitstream (if a bitstream is defined)</li>
* <li>The content availability bitstream(s) (if a bitstream is defined)</li>
* <li>The child subtree availability bitstream (if a bitstream is defined)</li>
* </ul>
*
* <p>
* This function modifies the buffer view headers' isActive flags in place.
* </p>
*
* @param {JSON} subtreeJson The JSON chunk from the subtree
* @param {BufferViewHeader[]} bufferViewHeaders The preprocessed buffer view headers
* @private
*/
markActiveBufferViews( subtreeJson, bufferViewHeaders ) {
let header;
const tileAvailabilityHeader = subtreeJson.tileAvailability;
// Check for bitstream first, which is part of the current schema.
// bufferView is the name of the bitstream from an older schema.
if ( ! isNaN( tileAvailabilityHeader.bitstream ) ) {
header = bufferViewHeaders[ tileAvailabilityHeader.bitstream ];
} else if ( ! isNaN( tileAvailabilityHeader.bufferView ) ) {
header = bufferViewHeaders[ tileAvailabilityHeader.bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
const contentAvailabilityHeaders = subtreeJson.contentAvailabilityHeaders;
for ( let i = 0; i < contentAvailabilityHeaders.length; i ++ ) {
header = undefined;
if ( ! isNaN( contentAvailabilityHeaders[ i ].bitstream ) ) {
header = bufferViewHeaders[ contentAvailabilityHeaders[ i ].bitstream ];
} else if ( ! isNaN( contentAvailabilityHeaders[ i ].bufferView ) ) {
header = bufferViewHeaders[ contentAvailabilityHeaders[ i ].bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
}
header = undefined;
const childSubtreeAvailabilityHeader = subtreeJson.childSubtreeAvailability;
if ( ! isNaN( childSubtreeAvailabilityHeader.bitstream ) ) {
header = bufferViewHeaders[ childSubtreeAvailabilityHeader.bitstream ];
} else if ( ! isNaN( childSubtreeAvailabilityHeader.bufferView ) ) {
header = bufferViewHeaders[ childSubtreeAvailabilityHeader.bufferView ];
}
if ( header ) {
header.isActive = true;
header.bufferHeader.isActive = true;
}
}
/**
* Go through the list of buffers and gather all the active ones into
* a dictionary.
* <p>
* The results are put into a dictionary object. The keys are indices of
* buffers, and the values are Uint8Arrays of the contents. Only buffers
* marked with the isActive flag are fetched.
* </p>
* <p>
* The internal buffer (the subtree's binary chunk) is also stored in this
* dictionary if it is marked active.
* </p>
* @param {BufferHeader[]} bufferHeaders The preprocessed buffer headers
* @param {ArrayBuffer} internalBuffer The binary chunk of the subtree file
* @returns {object} buffersU8 A dictionary of buffer index to a Uint8Array of its contents.
* @private
*/
async requestActiveBuffers( bufferHeaders, internalBuffer ) {
const promises = [];
for ( let i = 0; i < bufferHeaders.length; i ++ ) {
const bufferHeader = bufferHeaders[ i ];
// If the buffer is not active, resolve with undefined.
if ( ! bufferHeader.isActive ) {
promises.push( Promise.resolve( ) );
} else if ( bufferHeader.isExternal ) {
// Get the absolute URI of the external buffer.
const url = this.parseImplicitURIBuffer(
this.tile,
this.rootTile.implicitTiling.subtrees.uri,
bufferHeader.uri
);
const fetchPromise = fetch( url, this.fetchOptions )
.then( response => {
if ( ! response.ok ) {
throw new Error( `SUBTREELoader: Failed to load external buffer from ${ bufferHeader.uri } with error code ${ response.status }.` );
}
return response.arrayBuffer();
} )
.then( arrayBuffer => new Uint8Array( arrayBuffer ) );
promises.push( fetchPromise );
} else {
promises.push( Promise.resolve( new Uint8Array( internalBuffer ) ) );
}
}
const bufferResults = await Promise.all( promises );
const buffersU8 = {};
for ( let i = 0; i < bufferResults.length; i ++ ) {
const result = bufferResults[ i ];
if ( result ) {
buffersU8[ i ] = result;
}
}
return buffersU8;
}
/**
* Go through the list of buffer views, and if they are marked as active,
* extract a subarray from one of the active buffers.
*
* @param {BufferViewHeader[]} bufferViewHeaders
* @param {object} buffersU8 A dictionary of buffer index to a Uint8Array of its contents.
* @returns {object} A dictionary of buffer view index to a Uint8Array of its contents.
* @private
*/
parseActiveBufferViews( bufferViewHeaders, buffersU8 ) {
const bufferViewsU8 = {};
for ( let i = 0; i < bufferViewHeaders.length; i ++ ) {
const bufferViewHeader = bufferViewHeaders[ i ];
if ( ! bufferViewHeader.isActive ) {
continue;
}
const start = bufferViewHeader.byteOffset;
const end = start + bufferViewHeader.byteLength;
const buffer = buffersU8[ bufferViewHeader.buffer ];
bufferViewsU8[ i ] = buffer.slice( start, end );
}
return bufferViewsU8;
}
/**
* A buffer header is the JSON header from the subtree JSON chunk plus
* a couple extra boolean flags for easy reference.
*
* Buffers are assumed inactive until explicitly marked active. This is used
* to avoid fetching unneeded buffers.
*
* @typedef {object} BufferHeader
* @property {boolean} isActive Whether this buffer is currently used.
* @property {string} [uri] The URI of the buffer (external buffers only)
* @property {number} byteLength The byte length of the buffer, including any padding contained within.
* @private
*/
/**
* Iterate over the list of buffers from the subtree JSON and add the isActive field for easier parsing later.
* This modifies the objects in place.
* @param {Object[]} [bufferHeaders=[]] The JSON from subtreeJson.buffers.
* @returns {BufferHeader[]} The same array of headers with additional fields.
* @private
*/
preprocessBuffers( bufferHeaders = [] ) {
for ( let i = 0; i < bufferHeaders.length; i ++ ) {
const bufferHeader = bufferHeaders[ i ];
bufferHeader.isActive = false;
bufferHeader.isExternal = !! bufferHeader.uri;
}
return bufferHeaders;
}
/**
* A buffer view header is the JSON header from the subtree JSON chunk plus
* the isActive flag and a reference to the header for the underlying buffer.
*
* @typedef {object} BufferViewHeader
* @property {BufferHeader} bufferHeader A reference to the header for the underlying buffer
* @property {boolean} isActive Whether this bufferView is currently used.
* @property {number} buffer The index of the underlying buffer.
* @property {number} byteOffset The start byte of the bufferView within the buffer.
* @property {number} byteLength The length of the bufferView. No padding is included in this length.
* @private
*/
/**
* Iterate the list of buffer views from the subtree JSON and add the
* isActive flag. Also save a reference to the bufferHeader.
*
* @param {Object[]} [bufferViewHeaders=[]] The JSON from subtree.bufferViews.
* @param {BufferHeader[]} bufferHeaders The preprocessed buffer headers.
* @returns {BufferViewHeader[]} The same array of bufferView headers with additional fields.
* @private
*/
preprocessBufferViews( bufferViewHeaders = [], bufferHeaders ) {
for ( let i = 0; i < bufferViewHeaders.length; i ++ ) {
const bufferViewHeader = bufferViewHeaders[ i ];
bufferViewHeader.bufferHeader = bufferHeaders[ bufferViewHeader.buffer ];
bufferViewHeader.isActive = false;
// Keep the external flag for potential use in requestActiveBuffers
bufferViewHeader.isExternal = bufferViewHeader.bufferHeader.isExternal;
}
return bufferViewHeaders;
}
/**
* Parse the three availability bitstreams and store them in the subtree.
*
* @param {Subtree} subtree The subtree to modify.
* @param {Object} subtreeJson The subtree JSON.
* @param {Object} bufferViewsU8 A dictionary of buffer view index to a Uint8Array of its contents.
* @private
*/
parseAvailability( subtree, subtreeJson, bufferViewsU8 ) {
const branchingFactor = getBoundsDivider( this.rootTile );
const subtreeLevels = this.rootTile.implicitTiling.subtreeLevels;
const tileAvailabilityBits =
( Math.pow( branchingFactor, subtreeLevels ) - 1 ) / ( branchingFactor - 1 );
const childSubtreeBits = Math.pow( branchingFactor, subtreeLevels );
subtree._tileAvailability = this.parseAvailabilityBitstream(
subtreeJson.tileAvailability,
bufferViewsU8,
tileAvailabilityBits
);
subtree._contentAvailabilityBitstreams = [];
for ( let i = 0; i < subtreeJson.contentAvailabilityHeaders.length; i ++ ) {
const bitstream = this.parseAvailabilityBitstream(
subtreeJson.contentAvailabilityHeaders[ i ],
bufferViewsU8,
// content availability has the same length as tile availability.
tileAvailabilityBits
);
subtree._contentAvailabilityBitstreams.push( bitstream );
}
subtree._childSubtreeAvailability = this.parseAvailabilityBitstream(
subtreeJson.childSubtreeAvailability,
bufferViewsU8,
childSubtreeBits
);
}
/**
* Given the JSON describing an availability bitstream, turn it into an
* in-memory representation using an object. This handles bitstreams from a bufferView.
*
* @param {Object} availabilityJson A JSON object representing the availability.
* @param {Object} bufferViewsU8 A dictionary of buffer view index to its Uint8Array contents.
* @param {number} lengthBits The length of the availability bitstream in bits.
* @returns {object}
* @private
*/
parseAvailabilityBitstream(
availabilityJson,
bufferViewsU8,
lengthBits,
) {
if ( ! isNaN( availabilityJson.constant ) ) {
return {
constant: Boolean( availabilityJson.constant ),
lengthBits: lengthBits,
};
}
let bufferView;
// Check for bitstream first, which is part of the current schema.
// bufferView is the name of the bitstream from an older schema.
if ( ! isNaN( availabilityJson.bitstream ) ) {
bufferView = bufferViewsU8[ availabilityJson.bitstream ];
} else if ( ! isNaN( availabilityJson.bufferView ) ) {
bufferView = bufferViewsU8[ availabilityJson.bufferView ];
}
return {
bitstream: bufferView,
lengthBits: lengthBits
};
}
/**
* Expand a single subtree tile. This transcodes the subtree into
* a tree of {@link SubtreeTile}. The root of this tree is stored in
* the placeholder tile's children array. This method also creates
* tiles for the child subtrees to be lazily expanded as needed.
*
* @param {Object | SubtreeTile} subtreeRoot The first node of the subtree.
* @param {Subtree} subtree The parsed subtree.
* @private
*/
expandSubtree( subtreeRoot, subtree ) {
// TODO If multiple contents were supported then this tile could contain both renderable and un renderable content.
const contentTile = SubtreeTile.copy( subtreeRoot );
// If the subtree root tile has content, then create a placeholder child with cloned parameters
// Todo Multiple contents not handled, keep the first content found
for ( let i = 0; subtree && i < subtree._contentAvailabilityBitstreams.length; i ++ ) {
if ( subtree && this.getBit( subtree._contentAvailabilityBitstreams[ i ], 0 ) ) {
// Create a child holding the content uri, this child is similar to its parent and doesn't have any children.
contentTile.content = { uri: this.parseImplicitURI( subtreeRoot, this.rootTile.content.uri ) };
break;
}
}
subtreeRoot.children.push( contentTile );
// Creating each leaf inside the current subtree.
const bottomRow = this.transcodeSubtreeTiles(
contentTile,
subtree
);
// For each child subtree, create a tile containing the uri of the next subtree to fetch.
const childSubtrees = this.listChildSubtrees( subtree, bottomRow );
for ( let i = 0; i < childSubtrees.length; i ++ ) {
const subtreeLocator = childSubtrees[ i ];
const leafTile = subtreeLocator.tile;
const subtreeTile = this.deriveChildTile(
null,
leafTile,
null,
subtreeLocator.childMortonIndex
);
// Assign subtree uri as content.
subtreeTile.content = { uri: this.parseImplicitURI( subtreeTile, this.rootTile.implicitTiling.subtrees.uri ) };
leafTile.children.push( subtreeTile );
}
}
/**
* Transcode the implicitly defined tiles within this subtree and generate
* explicit {@link SubtreeTile} objects. This function only transcodes tiles,
* child subtrees are handled separately.
*
* @param {Object | SubtreeTile} subtreeRoot The root of the current subtree.
* @param {Subtree} subtree The subtree to get availability information.
* @returns {Array} The bottom row of transcoded tiles. This is helpful for processing child subtrees.
* @private
*/
transcodeSubtreeTiles( subtreeRoot, subtree ) {
// Sliding window over the levels of the tree.
// Each row is branchingFactor * length of previous row.
// Tiles within a row are ordered by Morton index.
let parentRow = [ subtreeRoot ];
let currentRow = [];
for ( let level = 1; level < this.rootTile.implicitTiling.subtreeLevels; level ++ ) {
const branchingFactor = getBoundsDivider( this.rootTile );
const levelOffset = ( Math.pow( branchingFactor, level ) - 1 ) / ( branchingFactor - 1 );
const numberOfChildren = branchingFactor * parentRow.length;
for ( let childMortonIndex = 0; childMortonIndex < numberOfChildren; childMortonIndex ++ ) {
const childBitIndex = levelOffset + childMortonIndex;
const parentMortonIndex = childMortonIndex >> Math.log2( branchingFactor );
const parentTile = parentRow[ parentMortonIndex ];
// Check if tile is available.
if ( ! this.getBit( subtree._tileAvailability, childBitIndex ) ) {
currentRow.push( undefined );
continue;
}
// Create a tile and add it as a child.
const childTile = this.deriveChildTile(
subtree,
parentTile,
childBitIndex,
childMortonIndex
);
parentTile.children.push( childTile );
currentRow.push( childTile );
}
parentRow = currentRow;
currentRow = [];
}
return parentRow;
}
/**
* Given a parent tile and information about which child to create, derive
* the properties of the child tile implicitly.
* <p>
* This creates a real tile for rendering.
* </p>
*
* @param {Subtree} subtree The subtree the child tile belongs to.
* @param {Object | SubtreeTile} parentTile The parent of the new child tile.
* @param {number} childBitIndex The index of the child tile within the tile's availability information.
* @param {number} childMortonIndex The morton index of the child tile relative to its parent.
* @returns {SubtreeTile} The new child tile.
* @private
*/
deriveChildTile(
subtree,
parentTile,
childBitIndex,
childMortonIndex
) {
const subtreeTile = new SubtreeTile( parentTile, childMortonIndex );
subtreeTile.boundingVolume = this.getTileBoundingVolume( subtreeTile );
subtreeTile.geometricError = this.getGeometricError( subtreeTile );
// Todo Multiple contents not handled, keep the first found content.
for ( let i = 0; subtree && i < subtree._contentAvailabilityBitstreams.length; i ++ ) {
if ( subtree && this.getBit( subtree._contentAvailabilityBitstreams[ i ], childBitIndex ) ) {
subtreeTile.content = { uri: this.parseImplicitURI( subtreeTile, this.rootTile.content.uri ) };
break;
}
}
return subtreeTile;
}
/**
* Get a bit from the bitstream as a Boolean. If the bitstream
* is a constant, the constant value is returned instead.
*
* @param {ParsedBitstream} object
* @param {number} index The integer index of the bit.
* @returns {boolean} The value of the bit.
* @private
*/
getBit( object, index ) {
if ( index < 0 || index >= object.lengthBits ) {
throw new Error( 'Bit index out of bounds.' );
}
if ( object.constant !== undefined ) {
return object.constant;
}
// byteIndex is floor(index / 8)
const byteIndex = index >> 3;
const bitIndex = index % 8;
return ( ( new Uint8Array( object.bitstream )[ byteIndex ] >> bitIndex ) & 1 ) === 1;
}
/**
* //TODO Adapt for Sphere
* To maintain numerical stability during this subdivision process,
* the actual bounding volumes should not be computed progressively by subdividing a non-root tile volume.
* Instead, the exact bounding volumes are computed directly for a given level.
* @param {Object | SubtreeTile} tile
* @return {Object} object containing the bounding volume.
*/
getTileBoundingVolume( tile ) {
const boundingVolume = {};
if ( this.rootTile.boundingVolume.region ) {
const region = [ ...this.rootTile.boundingVolume.region ];
const minX = region[ 0 ];
const maxX = region[ 2 ];
const minY = region[ 1 ];
const maxY = region[ 3 ];
const sizeX = ( maxX - minX ) / Math.pow( 2, tile.__level );
const sizeY = ( maxY - minY ) / Math.pow( 2, tile.__level );
region[ 0 ] = minX + sizeX * tile.__x; //west
region[ 2 ] = minX + sizeX * ( tile.__x + 1 ); //east
region[ 1 ] = minY + sizeY * tile.__y; //south
region[ 3 ] = minY + sizeY * ( tile.__y + 1 ); //north
for ( let k = 0; k < 4; k ++ ) {
const coord = region[ k ];
if ( coord < - Math.PI ) {
region[ k ] += 2 * Math.PI;
} else if ( coord > Math.PI ) {
region[ k ] -= 2 * Math.PI;
}
}
//Also divide the height in the case of octree.
if ( isOctreeSubdivision( tile ) ) {
const minZ = region[ 4 ];
const maxZ = region[ 5 ];
const sizeZ = ( maxZ - minZ ) / Math.pow( 2, tile.__level );
region[ 4 ] = minZ + sizeZ * tile.__z; //minimum height
region[ 5 ] = minZ + sizeZ * ( tile.__z + 1 ); //maximum height
}
boundingVolume.region = region;
}
if ( this.rootTile.boundingVolume.box ) {
// 0-2: center of the box
// 3-5: x axis direction and half length
// 6-8: y axis direction and half length
// 9-11: z axis direction and half length
const box = [ ...this.rootTile.boundingVolume.box ];
const cellSteps = 2 ** tile.__level - 1;
const scale = Math.pow( 2, - tile.__level );
const axisNumber = isOctreeSubdivision( tile ) ? 3 : 2;
for ( let i = 0; i < axisNumber; i ++ ) {
// scale the bounds axes
box[ 3 + i * 3 + 0 ] *= scale;
box[ 3 + i * 3 + 1 ] *= scale;
box[ 3 + i * 3 + 2 ] *= scale;
// axis vector
const x = box[ 3 + i * 3 + 0 ];
const y = box[ 3 + i * 3 + 1 ];
const z = box[ 3 + i * 3 + 2 ];
// adjust the center by the x, y and z axes
const axisOffset = i === 0 ? tile.__x : ( i === 1 ? tile.__y : tile.__z );
box[ 0 ] += 2 * x * ( - 0.5 * cellSteps + axisOffset );
box[ 1 ] += 2 * y * ( - 0.5 * cellSteps + axisOffset );
box[ 2 ] += 2 * z * ( - 0.5 * cellSteps + axisOffset );
}
boundingVolume.box = box;
}
return boundingVolume;
}
/**
* Each childs geometricError is half of its parents geometricError.
* @param {Object | SubtreeTile} tile
* @return {number}
*/
getGeometricError( tile ) {
return this.rootTile.geometricError / Math.pow( 2, tile.__level );
}
/**
* Determine what child subtrees exist and return a list of information.
*
* @param {Object} subtree The subtree for looking up availability.
* @param {Array} bottomRow The bottom row of tiles in a transcoded subtree.
* @returns {[]} A list of identifiers for the child subtrees.
* @private
*/
listChildSubtrees( subtree, bottomRow ) {
const results = [];
const branchingFactor = getBoundsDivider( this.rootTile );
for ( let i = 0; i < bottomRow.length; i ++ ) {
const leafTile = bottomRow[ i ];
if ( leafTile === undefined ) {
continue;
}
for ( let j = 0; j < branchingFactor; j ++ ) {
const index = i * branchingFactor + j;
if ( this.getBit( subtree._childSubtreeAvailability, index ) ) {
results.push( {
tile: leafTile,
childMortonIndex: index
} );
}
}
}
return results;
}
/**
* Replaces placeholder tokens in a URI template with the corresponding tile properties.
*
* The URI template should contain the tokens:
* - `{level}` for the tile's subdivision level.
* - `{x}` for the tile's x-coordinate.
* - `{y}` for the tile's y-coordinate.
* - `{z}` for the tile's z-coordinate.
*
* @param {Object} tile - The tile object containing properties __level, __x, __y, and __z.
* @param {string} uri - The URI template string with placeholders.
* @returns {string} The URI with placeholders replaced by the tile's properties.
*/
parseImplicitURI( tile, uri ) {
uri = uri.replace( '{level}', tile.__level );
uri = uri.replace( '{x}', tile.__x );
uri = uri.replace( '{y}', tile.__y );
uri = uri.replace( '{z}', tile.__z );
return uri;
}
/**
* Generates the full external buffer URI for a tile by combining an implicit URI with a buffer URI.
*
* First, it parses the implicit URI using the tile properties and the provided template. Then, it creates a new URL
* relative to the tile's base path, removes the last path segment, and appends the buffer URI.
*
* @param {Object} tile - The tile object that contains properties:
* - __level: the subdivision level,
* - __x, __y, __z: the tile coordinates,
* @param {string} uri - The URI template string with placeholders for the tile (e.g., `{level}`, `{x}`, `{y}`, `{z}`).
* @param {string} bufUri - The buffer file name to append (e.g., "0_1.bin").
* @returns {string} The full external buffer URI.
*/
parseImplicitURIBuffer( tile, uri, bufUri ) {
// Generate the base tile URI by replacing placeholders
const subUri = this.parseImplicitURI( tile, uri );
// Create a URL object relative to the tile's base path
const url = new URL( subUri, this.workingPath + '/' );
// Remove the last path segment
url.pathname = url.pathname.substring( 0, url.pathname.lastIndexOf( '/' ) );
// Construct the final URL with the buffer URI appended
return new URL( url.pathname + '/' + bufUri, this.workingPath + '/' ).toString();
}
}
@@ -0,0 +1,80 @@
// Class for making fetches to Cesium Ion, refreshing the token if needed.
export class CesiumIonAuth {
constructor( options = {} ) {
const { apiToken, autoRefreshToken = false } = options;
this.apiToken = apiToken;
this.autoRefreshToken = autoRefreshToken;
this.authURL = null;
this._tokenRefreshPromise = null;
this._bearerToken = null;
}
async fetch( url, options ) {
await this._tokenRefreshPromise;
// insert the authorization token
const fetchOptions = { ...options };
fetchOptions.headers = fetchOptions.headers || {};
fetchOptions.headers = {
...fetchOptions.headers,
Authorization: this._bearerToken,
};
// try to refresh the token if we failed to load the tile data
const res = await fetch( url, fetchOptions );
if ( res.status >= 400 && res.status <= 499 && this.autoRefreshToken ) {
// refresh the bearer token
await this.refreshToken( options );
fetchOptions.headers.Authorization = this._bearerToken;
return fetch( url, fetchOptions );
} else {
return res;
}
}
refreshToken( options ) {
if ( this._tokenRefreshPromise === null ) {
// construct the url to fetch the endpoint
const url = new URL( this.authURL );
url.searchParams.set( 'access_token', this.apiToken );
this._tokenRefreshPromise = fetch( url, options )
.then( res => {
if ( ! res.ok ) {
throw new Error( `CesiumIonAuthPlugin: Failed to load data with error code ${ res.status }` );
}
return res.json();
} )
.then( json => {
this._bearerToken = `Bearer ${ json.accessToken }`;
this._tokenRefreshPromise = null;
return json;
} );
}
return this._tokenRefreshPromise;
}
}
@@ -0,0 +1,166 @@
import { TraversalUtils } from '3d-tiles-renderer/core';
const TILES_MAP_URL = 'https://tile.googleapis.com/v1/createSession';
// Class for making fetches to Google Cloud, refreshing the token if needed.
// Supports both the 2d map tiles API in addition to 3d tiles.
export class GoogleCloudAuth {
get isMapTilesSession() {
return this.authURL === TILES_MAP_URL;
}
constructor( options = {} ) {
const { apiToken, sessionOptions = null, autoRefreshToken = false } = options;
this.apiToken = apiToken;
this.autoRefreshToken = autoRefreshToken;
this.authURL = TILES_MAP_URL;
this.sessionToken = null;
this.sessionOptions = sessionOptions;
this._tokenRefreshPromise = null;
}
async fetch( url, options ) {
// if we're using a map tiles session then we have to refresh the token separately
if ( this.sessionToken === null && this.isMapTilesSession ) {
this.refreshToken( options );
}
await this._tokenRefreshPromise;
// construct the url
const fetchUrl = new URL( url );
fetchUrl.searchParams.set( 'key', this.apiToken );
if ( this.sessionToken ) {
fetchUrl.searchParams.set( 'session', this.sessionToken );
}
// try to refresh the session token if we failed to load it
let res = await fetch( fetchUrl, options );
if ( res.status >= 400 && res.status <= 499 && this.autoRefreshToken ) {
// refresh the session token
await this.refreshToken( options );
if ( this.sessionToken ) {
fetchUrl.searchParams.set( 'session', this.sessionToken );
}
res = await fetch( fetchUrl, options );
}
if ( this.sessionToken === null && ! this.isMapTilesSession ) {
// if we're using a 3d tiles session then we get the session key in the first request
return res
.json()
.then( json => {
this.sessionToken = getSessionToken( json );
return json;
} );
} else {
return res;
}
}
refreshToken( options ) {
if ( this._tokenRefreshPromise === null ) {
// construct the url to fetch the endpoint
const url = new URL( this.authURL );
url.searchParams.set( 'key', this.apiToken );
// initialize options for map tiles
const fetchOptions = { ...options };
if ( this.isMapTilesSession ) {
fetchOptions.method = 'POST';
fetchOptions.body = JSON.stringify( this.sessionOptions );
fetchOptions.headers = fetchOptions.headers || {};
fetchOptions.headers = {
...fetchOptions.headers,
'Content-Type': 'application/json',
};
}
this._tokenRefreshPromise = fetch( url, fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `GoogleCloudAuth: Failed to load data with error code ${ res.status }` );
}
return res.json();
} )
.then( json => {
this.sessionToken = getSessionToken( json );
this._tokenRefreshPromise = null;
return json;
} );
}
return this._tokenRefreshPromise;
}
}
// Takes a json response from the auth url and extracts the session token
function getSessionToken( json ) {
if ( 'session' in json ) {
// if using the 2d maps api
return json.session;
} else {
// is using the 3d tiles api
let sessionToken = null;
const root = json.root;
TraversalUtils.traverseSet( root, tile => {
if ( tile.content && tile.content.uri ) {
const [ , params ] = tile.content.uri.split( '?' );
sessionToken = new URLSearchParams( params ).get( 'session' );
return true;
}
return false;
} );
return sessionToken;
}
}
@@ -0,0 +1,2 @@
export * from './ImplicitTilingPlugin.js';
export * from './EnforceNonZeroErrorPlugin.js';
@@ -0,0 +1,5 @@
export * from './ImplicitTilingPlugin.js';
export * from './EnforceNonZeroErrorPlugin.js';
export * from './auth/GoogleCloudAuth.js';
export * from './auth/CesiumIonAuth.js';
export * from './loaders/QuantizedMeshLoaderBase.js';
@@ -0,0 +1,261 @@
import { LoaderBase } from '3d-tiles-renderer/core';
export function zigZagDecode( value ) {
return ( value >> 1 ) ^ ( - ( value & 1 ) );
}
export class QuantizedMeshLoaderBase extends LoaderBase {
constructor( ...args ) {
super( ...args );
this.fetchOptions.header = {
Accept: 'application/vnd.quantized-mesh,application/octet-stream;q=0.9',
};
}
loadAsync( ...args ) {
const { fetchOptions } = this;
fetchOptions.header = fetchOptions.header || {};
fetchOptions.header[ 'Accept' ] = 'application/vnd.quantized-mesh,application/octet-stream;q=0.9';
fetchOptions.header[ 'Accept' ] += ';extensions=octvertexnormals-watermask-metadata';
return super.loadAsync( ...args );
}
parse( buffer ) {
let pointer = 0;
const view = new DataView( buffer );
const readFloat64 = () => {
const result = view.getFloat64( pointer, true );
pointer += 8;
return result;
};
const readFloat32 = () => {
const result = view.getFloat32( pointer, true );
pointer += 4;
return result;
};
const readInt = () => {
const result = view.getUint32( pointer, true );
pointer += 4;
return result;
};
const readByte = () => {
const result = view.getUint8( pointer );
pointer += 1;
return result;
};
const readBuffer = ( count, type ) => {
const result = new type( buffer, pointer, count );
pointer += count * type.BYTES_PER_ELEMENT;
return result;
};
// extract header
const header = {
center: [ readFloat64(), readFloat64(), readFloat64() ],
minHeight: readFloat32(),
maxHeight: readFloat32(),
sphereCenter: [ readFloat64(), readFloat64(), readFloat64() ],
sphereRadius: readFloat64(),
horizonOcclusionPoint: [ readFloat64(), readFloat64(), readFloat64() ],
};
// extract vertex data
const vertexCount = readInt();
const uBuffer = readBuffer( vertexCount, Uint16Array );
const vBuffer = readBuffer( vertexCount, Uint16Array );
const hBuffer = readBuffer( vertexCount, Uint16Array );
const uResult = new Float32Array( vertexCount );
const vResult = new Float32Array( vertexCount );
const hResult = new Float32Array( vertexCount );
// decode vertex data
let u = 0;
let v = 0;
let h = 0;
const MAX_VALUE = 32767;
for ( let i = 0; i < vertexCount; ++ i ) {
u += zigZagDecode( uBuffer[ i ] );
v += zigZagDecode( vBuffer[ i ] );
h += zigZagDecode( hBuffer[ i ] );
uResult[ i ] = u / MAX_VALUE;
vResult[ i ] = v / MAX_VALUE;
hResult[ i ] = h / MAX_VALUE;
}
// align pointer for index data
const is32 = vertexCount > 65536;
const bufferType = is32 ? Uint32Array : Uint16Array;
if ( is32 ) {
pointer = Math.ceil( pointer / 4 ) * 4;
} else {
pointer = Math.ceil( pointer / 2 ) * 2;
}
// extract index data
const triangleCount = readInt();
const indices = readBuffer( triangleCount * 3, bufferType );
// decode the index data
let highest = 0;
for ( var i = 0; i < indices.length; ++ i ) {
const code = indices[ i ];
indices[ i ] = highest - code;
if ( code === 0 ) {
++ highest;
}
}
// sort functions for the edges since they are not pre-sorted
const vSort = ( a, b ) => vResult[ b ] - vResult[ a ];
const vSortReverse = ( a, b ) => - vSort( a, b );
const uSort = ( a, b ) => uResult[ a ] - uResult[ b ];
const uSortReverse = ( a, b ) => - uSort( a, b );
// get edge indices
const westVertexCount = readInt();
const westIndices = readBuffer( westVertexCount, bufferType );
westIndices.sort( vSort );
const southVertexCount = readInt();
const southIndices = readBuffer( southVertexCount, bufferType );
southIndices.sort( uSort );
const eastVertexCount = readInt();
const eastIndices = readBuffer( eastVertexCount, bufferType );
eastIndices.sort( vSortReverse );
const northVertexCount = readInt();
const northIndices = readBuffer( northVertexCount, bufferType );
northIndices.sort( uSortReverse );
const edgeIndices = {
westIndices,
southIndices,
eastIndices,
northIndices,
};
// parse extensions
const extensions = {};
while ( pointer < view.byteLength ) {
const extensionId = readByte();
const extensionLength = readInt();
if ( extensionId === 1 ) {
// oct encoded normals
const xy = readBuffer( vertexCount * 2, Uint8Array );
const normals = new Float32Array( vertexCount * 3 );
// https://github.com/CesiumGS/cesium/blob/baaabaa49058067c855ad050be73a9cdfe9b6ac7/packages/engine/Source/Core/AttributeCompression.js#L119-L140
for ( let i = 0; i < vertexCount; i ++ ) {
let x = ( xy[ 2 * i + 0 ] / 255 ) * 2 - 1;
let y = ( xy[ 2 * i + 1 ] / 255 ) * 2 - 1;
const z = 1.0 - ( Math.abs( x ) + Math.abs( y ) );
if ( z < 0.0 ) {
const oldVX = x;
x = ( 1.0 - Math.abs( y ) ) * signNotZero( oldVX );
y = ( 1.0 - Math.abs( oldVX ) ) * signNotZero( y );
}
const len = Math.sqrt( x * x + y * y + z * z );
normals[ 3 * i + 0 ] = x / len;
normals[ 3 * i + 1 ] = y / len;
normals[ 3 * i + 2 ] = z / len;
}
extensions[ 'octvertexnormals' ] = {
extensionId,
normals,
};
} else if ( extensionId === 2 ) {
// water mask
const size = extensionLength === 1 ? 1 : 256;
const mask = readBuffer( size * size, Uint8Array );
extensions[ 'watermask' ] = {
extensionId,
mask,
size,
};
} else if ( extensionId === 4 ) {
// metadata
const jsonLength = readInt();
const jsonBuffer = readBuffer( jsonLength, Uint8Array );
const json = new TextDecoder().decode( jsonBuffer );
extensions[ 'metadata' ] = {
extensionId,
json: JSON.parse( json ),
};
}
}
return {
header,
indices,
vertexData: {
u: uResult,
v: vResult,
height: hResult,
},
edgeIndices,
extensions,
};
}
}
function signNotZero( v ) {
return v < 0.0 ? - 1.0 : 1.0;
}
@@ -0,0 +1,3 @@
export const WGS84_RADIUS: number;
export const WGS84_FLATTENING: number;
export const WGS84_HEIGHT: number;
@@ -0,0 +1,12 @@
// FAILED is negative so lru cache priority sorting will unload it first
export const FAILED = - 1;
export const UNLOADED = 0;
export const LOADING = 1;
export const PARSING = 2;
export const LOADED = 3;
// https://en.wikipedia.org/wiki/World_Geodetic_System
// https://en.wikipedia.org/wiki/Flattening
export const WGS84_RADIUS = 6378137;
export const WGS84_FLATTENING = 1 / 298.257223563;
export const WGS84_HEIGHT = - ( WGS84_FLATTENING * WGS84_RADIUS - WGS84_RADIUS );
@@ -0,0 +1,16 @@
// common
export { TilesRendererBase } from './tiles/TilesRendererBase.js';
export { Tile } from './tiles/Tile.js';
export { TileBase } from './tiles/TileBase.js';
export { Tileset } from './tiles/Tileset.js';
export * from './loaders/B3DMLoaderBase.js';
export * from './loaders/I3DMLoaderBase.js';
export * from './loaders/PNTSLoaderBase.js';
export * from './loaders/CMPTLoaderBase.js';
export * from './loaders/LoaderBase.js';
export * from './constants.js';
export { LRUCache } from './utilities/LRUCache.js';
export { PriorityQueue } from './utilities/PriorityQueue.js';
export { BatchTable } from './utilities/BatchTable.js';
export { FeatureTable } from './utilities/FeatureTable.js';
@@ -0,0 +1,13 @@
// common
export { TilesRendererBase } from './tiles/TilesRendererBase.js';
export { LoaderBase } from './loaders/LoaderBase.js';
export * from './loaders/B3DMLoaderBase.js';
export * from './loaders/I3DMLoaderBase.js';
export * from './loaders/PNTSLoaderBase.js';
export * from './loaders/CMPTLoaderBase.js';
export * from './constants.js';
export { LRUCache } from './utilities/LRUCache.js';
export { PriorityQueue } from './utilities/PriorityQueue.js';
export * as TraversalUtils from './utilities/TraversalUtils.js';
export * as LoaderUtils from './utilities/LoaderUtils.js';
@@ -0,0 +1,17 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface B3DMBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
glbBytes : Uint8Array;
}
export class B3DMLoaderBase<Result = B3DMBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,85 @@
// B3DM File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Batched3DModel/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
export class B3DMLoaderBase extends LoaderBase {
parse( buffer ) {
// TODO: this should be able to take a uint8array with an offset and length
const dataView = new DataView( buffer );
// 28-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'b3dm' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// Feature Table
const featureTableStart = 28;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'BATCH_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
const glbStart = batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength;
const glbBytes = new Uint8Array( buffer, glbStart, byteLength - glbStart );
return {
version,
featureTable,
batchTable,
glbBytes,
};
}
}
@@ -0,0 +1,21 @@
import { LoaderBase } from './LoaderBase.js';
interface TileInfo {
type : string;
buffer : Uint8Array;
version : string;
}
export interface CMPTBaseResult {
version : string;
tiles : Array< TileInfo >;
}
export class CMPTLoaderBase<Result = CMPTBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,61 @@
// CMPT File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Composite/README.md
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
export class CMPTLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 16-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'cmpt', 'CMPTLoader: The magic bytes equal "cmpt".' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1, 'CMPTLoader: The version listed in the header is "1".' );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength, 'CMPTLoader: The contents buffer length listed in the header matches the file.' );
// 4 bytes
const tilesLength = dataView.getUint32( 12, true );
const tiles = [];
let offset = 16;
for ( let i = 0; i < tilesLength; i ++ ) {
const tileView = new DataView( buffer, offset, 12 );
const tileMagic = readMagicBytes( tileView );
const tileVersion = tileView.getUint32( 4, true );
const byteLength = tileView.getUint32( 8, true );
const tileBuffer = new Uint8Array( buffer, offset, byteLength );
tiles.push( {
type: tileMagic,
buffer: tileBuffer,
version: tileVersion,
} );
offset += byteLength;
}
return {
version,
tiles,
};
}
}
@@ -0,0 +1,17 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface I3DMBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
glbBytes : Uint8Array;
}
export class I3DMLoaderBase<Result = I3DMBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,127 @@
// I3DM File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/Instanced3DModel/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
import { readMagicBytes, arrayToString, getWorkingPath } from '../utilities/LoaderUtils.js';
export class I3DMLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 32-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'i3dm' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// 4 bytes
const gltfFormat = dataView.getUint32( 28, true );
// Feature Table
const featureTableStart = 32;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'INSTANCES_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
const glbStart = batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength;
const bodyBytes = new Uint8Array( buffer, glbStart, byteLength - glbStart );
let glbBytes = null;
let promise = null;
let gltfWorkingPath = null;
if ( gltfFormat ) {
glbBytes = bodyBytes;
promise = Promise.resolve();
} else {
const externalUri = this.resolveExternalURL( arrayToString( bodyBytes ) );
//Store the gltf working path
gltfWorkingPath = getWorkingPath( externalUri );
promise = fetch( externalUri, this.fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `I3DMLoaderBase : Failed to load file "${ externalUri }" with status ${ res.status } : ${ res.statusText }` );
}
return res.arrayBuffer();
} )
.then( buffer => {
glbBytes = new Uint8Array( buffer );
} );
}
return promise.then( () => {
return {
version,
featureTable,
batchTable,
glbBytes,
gltfWorkingPath
};
} );
}
}
@@ -0,0 +1,9 @@
export class LoaderBase<Result = any, ParseResult = Promise< Result >> {
fetchOptions: any;
workingPath: string;
load( url: string ): Promise< Result >;
resolveExternalURL( url: string ): string;
parse( buffer: ArrayBuffer ): ParseResult;
}
@@ -0,0 +1,58 @@
import { getWorkingPath } from '../utilities/LoaderUtils.js';
export class LoaderBase {
constructor() {
this.fetchOptions = {};
this.workingPath = '';
}
load( ...args ) {
console.warn( 'Loader: "load" function has been deprecated in favor of "loadAsync".' );
return this.loadAsync( ...args );
}
loadAsync( url ) {
return fetch( url, this.fetchOptions )
.then( res => {
if ( ! res.ok ) {
throw new Error( `Failed to load file "${ url }" with status ${ res.status } : ${ res.statusText }` );
}
return res.arrayBuffer();
} )
.then( buffer => {
if ( this.workingPath === '' ) {
this.workingPath = getWorkingPath( url );
}
return this.parse( buffer );
} );
}
resolveExternalURL( url ) {
return new URL( url, this.workingPath ).href;
}
parse( buffer ) {
throw new Error( 'LoaderBase: Parse not implemented.' );
}
}
@@ -0,0 +1,16 @@
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { LoaderBase } from './LoaderBase.js';
export interface PNTSBaseResult {
version : string;
featureTable: FeatureTable;
batchTable : BatchTable;
}
export class PNTSLoaderBase<Result = PNTSBaseResult, ParseResult = Result>
extends LoaderBase<Result, ParseResult> {
}
@@ -0,0 +1,82 @@
// PNTS File Format
// https://github.com/CesiumGS/3d-tiles/blob/master/specification/TileFormats/PointCloud/README.md
import { BatchTable } from '../utilities/BatchTable.js';
import { FeatureTable } from '../utilities/FeatureTable.js';
import { readMagicBytes } from '../utilities/LoaderUtils.js';
import { LoaderBase } from './LoaderBase.js';
export class PNTSLoaderBase extends LoaderBase {
parse( buffer ) {
const dataView = new DataView( buffer );
// 28-byte header
// 4 bytes
const magic = readMagicBytes( dataView );
console.assert( magic === 'pnts' );
// 4 bytes
const version = dataView.getUint32( 4, true );
console.assert( version === 1 );
// 4 bytes
const byteLength = dataView.getUint32( 8, true );
console.assert( byteLength === buffer.byteLength );
// 4 bytes
const featureTableJSONByteLength = dataView.getUint32( 12, true );
// 4 bytes
const featureTableBinaryByteLength = dataView.getUint32( 16, true );
// 4 bytes
const batchTableJSONByteLength = dataView.getUint32( 20, true );
// 4 bytes
const batchTableBinaryByteLength = dataView.getUint32( 24, true );
// Feature Table
const featureTableStart = 28;
const featureTableBuffer = buffer.slice(
featureTableStart,
featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength,
);
const featureTable = new FeatureTable(
featureTableBuffer,
0,
featureTableJSONByteLength,
featureTableBinaryByteLength,
);
// Batch Table
const batchTableStart = featureTableStart + featureTableJSONByteLength + featureTableBinaryByteLength;
const batchTableBuffer = buffer.slice(
batchTableStart,
batchTableStart + batchTableJSONByteLength + batchTableBinaryByteLength,
);
const batchTable = new BatchTable(
batchTableBuffer,
featureTable.getData( 'BATCH_LENGTH' ) || featureTable.getData( 'POINTS_LENGTH' ),
0,
batchTableJSONByteLength,
batchTableBinaryByteLength,
);
return Promise.resolve( {
version,
featureTable,
batchTable,
} );
}
}
@@ -0,0 +1,50 @@
import { TileBase } from './TileBase.js';
/**
* Documented 3d-tile state managed by the TilesRenderer* / used/usable in priority / traverseFunctions!
*/
export interface Tile extends TileBase {
parent: Tile;
/**
* Hierarchy Depth from the TileGroup
*/
__depth : number;
/**
* The screen space error for this tile
*/
__error : number;
/**
* How far is this tiles bounds from the nearest active Camera.
* Expected to be filled in during calculateError implementations.
*/
__distanceFromCamera : number;
/**
* This tile is currently active if:
* 1: Tile content is loaded and ready to be made visible if needed
*/
__active : boolean;
/**
* This tile is currently visible if:
* 1: Tile content is loaded
* 2: Tile is within a camera frustum
* 3: Tile meets the SSE requirements
*/
__visible : boolean;
/**
* Whether or not the tile was visited during the last update run.
*/
__used : boolean;
/**
* Whether or not the tile was within the frustum on the last update run.
*/
__inFrustum : boolean;
/**
* The depth of the tiles that increments only when a child with geometry content is encountered
*/
__depthFromRenderedParent : number;
}
@@ -0,0 +1,76 @@
/**
* 3d-tiles Tile object per spec:
* (incomplete, expanding as features become supported by this package.)
*
* See spec for full schema: https://github.com/CesiumGS/3d-tiles/blob/master/specification/schema/tile.schema.json
*/
export interface TileBase {
boundingVolume: {
/**
* An array of 12 numbers that define an oriented bounding box. The first three elements define the x, y, and z
* values for the center of the box. The next three elements (with indices 3, 4, and 5) define the x axis
* direction and half-length. The next three elements (indices 6, 7, and 8) define the y axis direction and
* half-length. The last three elements (indices 9, 10, and 11) define the z axis direction and half-length.
*/
box?: number[];
/**
* An array of four numbers that define a bounding sphere. The first three elements define the x, y, and z
* values for the center of the sphere. The last element (with index 3) defines the radius in meters.
*/
sphere?: number[];
};
/**
* The error, in meters, introduced if this tileset is not rendered. At runtime, the geometric error is used to compute screen space error (SSE), i.e., the error measured in pixels.
*/
geometricError: number;
// optional properties
children?: TileBase[];
content?: {
uri: string;
/**
* Dictionary object with content specific extension objects.
*/
extensions?: Record<string, any>;
extras?: Record<string, any>;
// Non standard, noted here as it exists in the code in this package to support old pre-1.0 tilesets
url?: string;
};
// An object that describes the implicit subdivision of this tile.
implicitTiling: {
// A string describing the subdivision scheme used within the tileset.
subdivisionScheme: 'QUADTREE' | 'OCTREE';
subtreeLevels: number;
availableLevels: number;
// An object describing the location of subtree files.
subtrees: {
// A template URI pointing to subtree files
uri: string;
}
},
/**
* Dictionary object with tile specific extension objects.
*/
extensions?: Record<string, any>;
extras?: Record<string, any>;
refine?: 'REPLACE' | 'ADD';
transform?: number[];
}
@@ -0,0 +1,35 @@
import { Tile } from './Tile.js';
/**
* Internal state used/set by the package.
*/
export interface TileInternal extends Tile {
// tile description
__isLeaf: boolean;
__hasContent: boolean;
__hasRenderableContent: boolean;
__hasUnrenderableContent: boolean;
// resource tracking
__usedLastFrame: boolean;
__used: boolean;
// Visibility tracking
__allChildrenLoaded: boolean;
__inFrustum: boolean;
__wasSetVisible: boolean;
// download state tracking
/**
* This tile is currently active if:
* 1: Tile content is loaded and ready to be made visible if needed
*/
__active: boolean;
__loadIndex: number;
__loadAbort: AbortController | null;
__loadingState: number;
__wasSetActive: boolean;
}
@@ -0,0 +1,38 @@
import { LRUCache } from '../utilities/LRUCache.js';
import { PriorityQueue } from '../utilities/PriorityQueue.js';
export class TilesRendererBase {
readonly rootTileSet : object | null;
readonly root : object | null;
errorTarget : number;
errorThreshold : number;
displayActiveTiles : boolean;
maxDepth : number;
loadProgress: number;
fetchOptions : RequestInit;
preprocessURL : ( ( uri: string | URL ) => string ) | null;
lruCache : LRUCache;
parseQueue : PriorityQueue;
downloadQueue : PriorityQueue;
processNodeQueue: PriorityQueue;
constructor( url?: string );
update() : void;
registerPlugin( plugin: object ) : void;
unregisterPlugin( plugin: object | string ) : boolean;
getPluginByName( plugin: object | string ) : object;
traverse(
beforeCb : ( ( tile : object, parent : object, depth : number ) => boolean ) | null,
afterCb : ( ( tile : object, parent : object, depth : number ) => boolean ) | null
) : void;
getAttributions( target? : Array<{ type: string, value: any }> ) : Array<{ type: string, value: any }>;
dispose() : void;
resetFailedTiles() : void;
}
@@ -0,0 +1,66 @@
import { TileBase } from './TileBase.js';
/**
* A 3d-tiles tileset.
*
* Schema, see: https://github.com/CesiumGS/3d-tiles/blob/main/specification/schema/tileset.schema.json
*/
export interface Tileset {
/**
* Metadata about the entire tileset.
*/
asset: {
/**
* 3d-tiles version
*/
version: string,
/**
* Application specific version
*/
tilesetVersion?: string,
/**
* Dictionary object with extension-specific objects.
*/
extensions? : Record<string, any>,
};
/**
* The error, in meters, introduced if this tileset is not rendered. At runtime, the geometric error is used to compute screen space error (SSE), i.e., the error measured in pixels.
*/
geometricError: number;
/**
* The root tile.
*/
root: TileBase;
// optional properties
/**
* Names of 3D Tiles extensions used somewhere in this tileset.
*/
extensionsUsed?: string[];
/**
* Names of 3D Tiles extensions required to properly load this tileset.
*/
extensionsRequired?: string[];
/**
* A dictionary object of metadata about per-feature properties.
*/
properties?: Record<string, any>;
/**
* Dictionary object with extension-specific objects.
*/
extensions? : Record<string, any>;
extras? : Record<string, any>;
}
@@ -0,0 +1,454 @@
import { LOADED, FAILED } from '../constants.js';
const viewErrorTarget = {
inView: false,
error: Infinity,
distanceFromCamera: Infinity,
};
// flag guiding the behavior of the traversal to load the siblings at the root of the
// tile set or not. The spec seems to indicate "true" when using REPLACE define but
// Cesium's behavior is "false".
// See CesiumGS/3d-tiles#776
const LOAD_ROOT_SIBLINGS = true;
function isDownloadFinished( value ) {
return value === LOADED || value === FAILED;
}
// Checks whether this tile was last used on the given frame.
function isUsedThisFrame( tile, frameCount ) {
return tile.__lastFrameVisited === frameCount && tile.__used;
}
function areChildrenProcessed( tile ) {
return tile.__childrenProcessed === tile.children.length;
}
// Resets the frame frame information for the given tile
function resetFrameState( tile, renderer ) {
if ( tile.__lastFrameVisited !== renderer.frameCount ) {
tile.__lastFrameVisited = renderer.frameCount;
tile.__used = false;
tile.__inFrustum = false;
tile.__isLeaf = false;
tile.__visible = false;
tile.__active = false;
tile.__error = Infinity;
tile.__distanceFromCamera = Infinity;
tile.__allChildrenLoaded = false;
// update tile frustum and error state
renderer.calculateTileViewError( tile, viewErrorTarget );
tile.__inFrustum = viewErrorTarget.inView;
tile.__error = viewErrorTarget.error;
tile.__distanceFromCamera = viewErrorTarget.distanceFromCamera;
}
}
// Recursively mark tiles used down to the next layer, skipping external tile sets
function recursivelyMarkUsed( tile, renderer ) {
renderer.ensureChildrenArePreprocessed( tile );
resetFrameState( tile, renderer );
markUsed( tile, renderer );
// don't traverse if the children have not been processed, yet but tile set content
// should be considered to be "replaced" by the loaded children so await that here.
if ( tile.__hasUnrenderableContent && areChildrenProcessed( tile ) ) {
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
recursivelyMarkUsed( children[ i ], renderer );
}
}
}
// Recursively traverses to the next tiles with unloaded renderable content to load them
function recursivelyLoadNextRenderableTiles( tile, renderer ) {
renderer.ensureChildrenArePreprocessed( tile );
// exit the recursion if the tile hasn't been used this frame
if ( isUsedThisFrame( tile, renderer.frameCount ) ) {
// queue this tile to download content
if ( tile.__hasContent ) {
renderer.queueTileForDownload( tile );
}
if ( areChildrenProcessed( tile ) ) {
// queue any used child tiles
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
recursivelyLoadNextRenderableTiles( children[ i ], renderer );
}
}
}
}
// Mark a tile as being used by current view
function markUsed( tile, renderer ) {
if ( tile.__used ) {
return;
}
tile.__used = true;
renderer.markTileUsed( tile );
renderer.stats.used ++;
if ( tile.__inFrustum === true ) {
renderer.stats.inFrustum ++;
}
}
// Returns whether the tile can be traversed to the next layer of children by checking the tile metrics
function canTraverse( tile, renderer ) {
// If we've met the error requirements then don't load further - if an external tile set is encountered,
// though, then continue to refine.
if ( tile.__error <= renderer.errorTarget && ! tile.__hasUnrenderableContent ) {
return false;
}
// Early out if we've reached the maximum allowed depth.
if ( renderer.maxDepth > 0 && tile.__depth + 1 >= renderer.maxDepth ) {
return false;
}
// Early out if the children haven't been processed, yet
if ( ! areChildrenProcessed( tile ) ) {
return false;
}
return true;
}
// Determine which tiles are used by the renderer given the current camera configuration
export function markUsedTiles( tile, renderer ) {
// determine frustum set is run first so we can ensure the preprocessing of all the necessary
// child tiles has happened here.
renderer.ensureChildrenArePreprocessed( tile );
resetFrameState( tile, renderer );
if ( ! tile.__inFrustum ) {
return;
}
if ( ! canTraverse( tile, renderer ) ) {
markUsed( tile, renderer );
return;
}
// Traverse children and see if any children are in view.
let anyChildrenUsed = false;
let anyChildrenInFrustum = false;
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
markUsedTiles( c, renderer );
anyChildrenUsed = anyChildrenUsed || isUsedThisFrame( c, renderer.frameCount );
anyChildrenInFrustum = anyChildrenInFrustum || c.__inFrustum;
}
// Disabled for now because this will cause otherwise unused children to be added to the lru cache
// if none of the children are in the frustum then this tile shouldn't be displayed.
// Otherwise this can cause load oscillation as parents are traversed and loaded and then determined
// to not be used because children aren't visible. See #1165.
// if ( tile.refine === 'REPLACE' && ! anyChildrenInFrustum && children.length !== 0 && ! tile.__hasUnrenderableContent ) {
// // TODO: we're not checking tiles with unrenderable content here since external tile sets might look like they're in the frustum,
// // load the children, then the children indicate that it's not visible, causing it to be unloaded. Then it will be loaded again.
// // The impact when including external tile set roots in the check is more significant but can't be used unless we keep external tile
// // sets around even when they're not needed. See issue #741.
// // TODO: what if we mark the tile as not in the frustum but we _do_ mark it as used? Then we can stop frustum traversal and at least
// // prevent tiles from rendering unless they're needed.
// console.log('FAILED')
// tile.__inFrustum = false;
// return;
// }
// wait until after the above condition to mark the traversed tile as used or not
markUsed( tile, renderer );
// If this is a tile that needs children loaded to refine then recursively load child
// tiles until error is met
if ( anyChildrenUsed && tile.refine === 'REPLACE' && ( tile.__depth !== 0 || LOAD_ROOT_SIBLINGS ) ) {
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
recursivelyMarkUsed( c, renderer );
}
}
}
// Traverse and mark the tiles that are at the leaf nodes of the "used" tree.
export function markUsedSetLeaves( tile, renderer ) {
const frameCount = renderer.frameCount;
if ( ! isUsedThisFrame( tile, frameCount ) ) {
return;
}
// This tile is a leaf if none of the children had been used.
const children = tile.children;
let anyChildrenUsed = false;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
anyChildrenUsed = anyChildrenUsed || isUsedThisFrame( c, frameCount );
}
if ( ! anyChildrenUsed ) {
tile.__isLeaf = true;
} else {
let allChildrenLoaded = true;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
markUsedSetLeaves( c, renderer );
if ( isUsedThisFrame( c, frameCount ) ) {
// consider a child to be loaded if
// - the children's children have been loaded
// - the tile content has loaded
// - the tile is completely empty - ie has no children and no content
// - the child tile set has tried to load but failed
const childLoaded =
c.__allChildrenLoaded ||
! c.__hasContent ||
( c.__hasRenderableContent && isDownloadFinished( c.__loadingState ) ) ||
( c.__hasUnrenderableContent && c.__loadingState === FAILED );
allChildrenLoaded = allChildrenLoaded && childLoaded;
}
}
tile.__allChildrenLoaded = allChildrenLoaded;
}
}
// TODO: revisit implementation
// Skip past tiles we consider unrenderable because they are outside the error threshold.
export function markVisibleTiles( tile, renderer ) {
const stats = renderer.stats;
if ( ! isUsedThisFrame( tile, renderer.frameCount ) ) {
return;
}
// Request the tile contents or mark it as visible if we've found a leaf.
if ( tile.__isLeaf ) {
if ( tile.__loadingState === LOADED ) {
if ( tile.__inFrustum ) {
tile.__visible = true;
stats.visible ++;
}
tile.__active = true;
stats.active ++;
} else if ( tile.__hasContent ) {
renderer.queueTileForDownload( tile );
}
return;
}
const children = tile.children;
const hasContent = tile.__hasContent;
const loadedContent = isDownloadFinished( tile.__loadingState ) && hasContent;
const errorRequirement = ( renderer.errorTarget + 1 ) * renderer.errorThreshold;
const meetsSSE = tile.__error <= errorRequirement;
const isAdditiveRefine = tile.refine === 'ADD';
// TODO: the "meetsSSE" field can be removed when the "errorThreshold" field has been removed
// Don't wait for all children tiles to load if this tile set has empty tiles at the root in order
// to match Cesium's behavior
const allChildrenLoaded = tile.__allChildrenLoaded || ( tile.__depth === 0 && ! LOAD_ROOT_SIBLINGS );
// If we've met the SSE requirements and we can load content then fire a fetch.
if ( hasContent && ( meetsSSE || isAdditiveRefine ) ) {
renderer.queueTileForDownload( tile );
}
// By this time only tiles that meet the screen space error requirements will be traversed. Only mark this
// as visible if it's been loaded and not all children have loaded yet or it's an additive tile, meaning it needs
// to display in addition to the children.
// Skip the tile entirely if there's no content to load
if ( meetsSSE && loadedContent && ! allChildrenLoaded || loadedContent && isAdditiveRefine ) {
if ( tile.__inFrustum ) {
tile.__visible = true;
stats.visible ++;
}
tile.__active = true;
stats.active ++;
}
// If we're additive then don't stop the traversal here because it doesn't matter whether the children load in
// at the same rate.
if ( ! isAdditiveRefine && meetsSSE && ! allChildrenLoaded ) {
// load the child content if we've found that we've been loaded so we can move down to the next tile
// layer when the data has loaded.
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
if ( isUsedThisFrame( c, renderer.frameCount ) ) {
recursivelyLoadNextRenderableTiles( c, renderer );
}
}
} else {
for ( let i = 0, l = children.length; i < l; i ++ ) {
markVisibleTiles( children[ i ], renderer );
}
}
}
// Final traverse to toggle tile visibility.
export function toggleTiles( tile, renderer ) {
const isUsed = isUsedThisFrame( tile, renderer.frameCount );
if ( isUsed || tile.__usedLastFrame ) {
let setActive = false;
let setVisible = false;
if ( isUsed ) {
// enable visibility if active due to shadows
setActive = tile.__active;
if ( renderer.displayActiveTiles ) {
setVisible = tile.__active || tile.__visible;
} else {
setVisible = tile.__visible;
}
} else {
// if the tile was used last frame but not this one then there's potential for the tile
// to not have been visited during the traversal, meaning it hasn't been reset and has
// stale values. This ensures the values are not stale.
resetFrameState( tile, renderer );
}
// If the active or visible state changed then call the functions.
if ( tile.__hasRenderableContent && tile.__loadingState === LOADED ) {
if ( tile.__wasSetActive !== setActive ) {
renderer.invokeOnePlugin( plugin => plugin.setTileActive && plugin.setTileActive( tile, setActive ) );
}
if ( tile.__wasSetVisible !== setVisible ) {
renderer.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, setVisible ) );
}
}
tile.__wasSetActive = setActive;
tile.__wasSetVisible = setVisible;
tile.__usedLastFrame = isUsed;
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const c = children[ i ];
toggleTiles( c, renderer );
}
}
}
@@ -0,0 +1,24 @@
export class BatchTable {
count : number;
constructor(
buffer : ArrayBuffer,
count : number,
start : number,
headerLength : number,
binLength : number
);
getKeys() : Array< string >;
getDataFromId(
id: number,
target?: object
) : object;
getPropertyArray(
key: string,
) : number | string | ArrayBufferView;
}
@@ -0,0 +1,78 @@
import { BatchTableHierarchyExtension } from './BatchTableHierarchyExtension.js';
import { FeatureTable } from './FeatureTable.js';
export class BatchTable extends FeatureTable {
get batchSize() {
console.warn( 'BatchTable.batchSize has been deprecated and replaced with BatchTable.count.' );
return this.count;
}
constructor( buffer, count, start, headerLength, binLength ) {
super( buffer, start, headerLength, binLength );
this.count = count;
this.extensions = {};
const extensions = this.header.extensions;
if ( extensions ) {
if ( extensions[ '3DTILES_batch_table_hierarchy' ] ) {
this.extensions[ '3DTILES_batch_table_hierarchy' ] = new BatchTableHierarchyExtension( this );
}
}
}
getData( key, componentType = null, type = null ) {
console.warn( 'BatchTable: BatchTable.getData is deprecated. Use BatchTable.getDataFromId to get all' +
'properties for an id or BatchTable.getPropertyArray for getting an array of value for a property.' );
return super.getData( key, this.count, componentType, type );
}
getDataFromId( id, target = {} ) {
if ( id < 0 || id >= this.count ) {
throw new Error( `BatchTable: id value "${ id }" out of bounds for "${ this.count }" features number.` );
}
for ( const key of this.getKeys() ) {
target[ key ] = super.getData( key, this.count )[ id ];
}
for ( const extensionName in this.extensions ) {
const extension = this.extensions[ extensionName ];
if ( extension.getDataFromId instanceof Function ) {
target[ extensionName ] = target[ extensionName ] || {};
extension.getDataFromId( id, target[ extensionName ] );
}
}
return target;
}
getPropertyArray( key ) {
return super.getData( key, this.count );
}
}
@@ -0,0 +1,127 @@
import { parseBinArray } from './FeatureTable.js';
export class BatchTableHierarchyExtension {
constructor( batchTable ) {
this.batchTable = batchTable;
const extensionHeader = batchTable.header.extensions[ '3DTILES_batch_table_hierarchy' ];
this.classes = extensionHeader.classes;
for ( const classDef of this.classes ) {
const instances = classDef.instances;
for ( const property in instances ) {
classDef.instances[ property ] = this._parseProperty( instances[ property ], classDef.length, property );
}
}
this.instancesLength = extensionHeader.instancesLength;
this.classIds = this._parseProperty( extensionHeader.classIds, this.instancesLength, 'classIds' );
if ( extensionHeader.parentCounts ) {
this.parentCounts = this._parseProperty( extensionHeader.parentCounts, this.instancesLength, 'parentCounts' );
} else {
this.parentCounts = new Array( this.instancesLength ).fill( 1 );
}
if ( extensionHeader.parentIds ) {
const parentIdsLength = this.parentCounts.reduce( ( a, b ) => a + b, 0 );
this.parentIds = this._parseProperty( extensionHeader.parentIds, parentIdsLength, 'parentIds' );
} else {
this.parentIds = null;
}
this.instancesIds = [];
const classCounter = {};
for ( const classId of this.classIds ) {
classCounter[ classId ] = classCounter[ classId ] ?? 0;
this.instancesIds.push( classCounter[ classId ] );
classCounter[ classId ] ++;
}
}
_parseProperty( property, propertyLength, propertyName ) {
if ( Array.isArray( property ) ) {
return property;
} else {
const { buffer, binOffset } = this.batchTable;
const byteOffset = property.byteOffset;
const componentType = property.componentType || 'UNSIGNED_SHORT';
const arrayStart = binOffset + byteOffset;
return parseBinArray( buffer, arrayStart, propertyLength, 'SCALAR', componentType, propertyName );
}
}
getDataFromId( id, target = {} ) {
// Get properties inherited from parents
const parentCount = this.parentCounts[ id ];
if ( this.parentIds && parentCount > 0 ) {
let parentIdsOffset = 0;
for ( let i = 0; i < id; i ++ ) {
parentIdsOffset += this.parentCounts[ i ];
}
for ( let i = 0; i < parentCount; i ++ ) {
const parentId = this.parentIds[ parentIdsOffset + i ];
if ( parentId !== id ) {
this.getDataFromId( parentId, target );
}
}
}
// Get properties proper to this instance
const classId = this.classIds[ id ];
const instances = this.classes[ classId ].instances;
const className = this.classes[ classId ].name;
const instanceId = this.instancesIds[ id ];
for ( const key in instances ) {
target[ className ] = target[ className ] || {};
target[ className ][ key ] = instances[ key ][ instanceId ];
}
return target;
}
}
@@ -0,0 +1,30 @@
interface FeatureTableHeader {
extensions?: object;
extras?: any;
}
export class FeatureTable {
header: FeatureTableHeader;
constructor(
buffer : ArrayBuffer,
start : number,
headerLength : number,
binLength : number
);
getKeys() : Array< string >;
getData(
key : string,
count : number,
defaultComponentType? : string | null,
defaultType? : string | null
) : number | string | ArrayBufferView;
getBuffer( byteOffset : number, byteLength : number ) : ArrayBuffer;
}
@@ -0,0 +1,159 @@
import { arrayToString } from './LoaderUtils.js';
export function parseBinArray( buffer, arrayStart, count, type, componentType, propertyName ) {
let stride;
switch ( type ) {
case 'SCALAR':
stride = 1;
break;
case 'VEC2':
stride = 2;
break;
case 'VEC3':
stride = 3;
break;
case 'VEC4':
stride = 4;
break;
default:
throw new Error( `FeatureTable : Feature type not provided for "${ propertyName }".` );
}
let data;
const arrayLength = count * stride;
switch ( componentType ) {
case 'BYTE':
data = new Int8Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_BYTE':
data = new Uint8Array( buffer, arrayStart, arrayLength );
break;
case 'SHORT':
data = new Int16Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_SHORT':
data = new Uint16Array( buffer, arrayStart, arrayLength );
break;
case 'INT':
data = new Int32Array( buffer, arrayStart, arrayLength );
break;
case 'UNSIGNED_INT':
data = new Uint32Array( buffer, arrayStart, arrayLength );
break;
case 'FLOAT':
data = new Float32Array( buffer, arrayStart, arrayLength );
break;
case 'DOUBLE':
data = new Float64Array( buffer, arrayStart, arrayLength );
break;
default:
throw new Error( `FeatureTable : Feature component type not provided for "${ propertyName }".` );
}
return data;
}
export class FeatureTable {
constructor( buffer, start, headerLength, binLength ) {
this.buffer = buffer;
this.binOffset = start + headerLength;
this.binLength = binLength;
let header = null;
if ( headerLength !== 0 ) {
const headerData = new Uint8Array( buffer, start, headerLength );
header = JSON.parse( arrayToString( headerData ) );
} else {
header = {};
}
this.header = header;
}
getKeys() {
return Object.keys( this.header ).filter( key => key !== 'extensions' );
}
getData( key, count, defaultComponentType = null, defaultType = null ) {
const header = this.header;
if ( ! ( key in header ) ) {
return null;
}
const feature = header[ key ];
if ( ! ( feature instanceof Object ) ) {
return feature;
} else if ( Array.isArray( feature ) ) {
return feature;
} else {
const { buffer, binOffset, binLength } = this;
const byteOffset = feature.byteOffset || 0;
const featureType = feature.type || defaultType;
const featureComponentType = feature.componentType || defaultComponentType;
if ( 'type' in feature && defaultType && feature.type !== defaultType ) {
throw new Error( 'FeatureTable: Specified type does not match expected type.' );
}
const arrayStart = binOffset + byteOffset;
const data = parseBinArray( buffer, arrayStart, count, featureType, featureComponentType, key );
const dataEnd = arrayStart + data.byteLength;
if ( dataEnd > binOffset + binLength ) {
throw new Error( 'FeatureTable: Feature data read outside binary body length.' );
}
return data;
}
}
getBuffer( byteOffset, byteLength ) {
const { buffer, binOffset } = this;
return buffer.slice( binOffset + byteOffset, binOffset + byteOffset + byteLength );
}
}
@@ -0,0 +1,12 @@
export class LRUCache {
minSize: number;
maxSize: number;
minBytesSize: number;
maxBytesSize: number;
unloadPercent: number;
autoMarkUnused: boolean;
unloadPriorityCallback: ( item: any ) => number;
}
@@ -0,0 +1,369 @@
const GIGABYTE_BYTES = 2 ** 30;
class LRUCache {
get unloadPriorityCallback() {
return this._unloadPriorityCallback;
}
set unloadPriorityCallback( cb ) {
if ( cb.length === 1 ) {
console.warn( 'LRUCache: "unloadPriorityCallback" function has been changed to take two arguments.' );
this._unloadPriorityCallback = ( a, b ) => {
const valA = cb( a );
const valB = cb( b );
if ( valA < valB ) return - 1;
if ( valA > valB ) return 1;
return 0;
};
} else {
this._unloadPriorityCallback = cb;
}
}
constructor() {
// options
this.minSize = 6000;
this.maxSize = 8000;
this.minBytesSize = 0.3 * GIGABYTE_BYTES;
this.maxBytesSize = 0.4 * GIGABYTE_BYTES;
this.unloadPercent = 0.05;
this.autoMarkUnused = true;
// "itemSet" doubles as both the list of the full set of items currently
// stored in the cache (keys) as well as a map to the time the item was last
// used so it can be sorted appropriately.
this.itemSet = new Map();
this.itemList = [];
this.usedSet = new Set();
this.callbacks = new Map();
this.unloadingHandle = - 1;
this.cachedBytes = 0;
this.bytesMap = new Map();
this.loadedSet = new Set();
this._unloadPriorityCallback = null;
const itemSet = this.itemSet;
this.defaultPriorityCallback = item => itemSet.get( item );
}
// Returns whether or not the cache has reached the maximum size
isFull() {
return this.itemSet.size >= this.maxSize || this.cachedBytes >= this.maxBytesSize;
}
getMemoryUsage( item ) {
return this.bytesMap.get( item ) || 0;
}
setMemoryUsage( item, bytes ) {
const { bytesMap, itemSet } = this;
if ( ! itemSet.has( item ) ) {
return;
}
this.cachedBytes -= bytesMap.get( item ) || 0;
bytesMap.set( item, bytes );
this.cachedBytes += bytes;
}
add( item, removeCb ) {
const itemSet = this.itemSet;
if ( itemSet.has( item ) ) {
return false;
}
if ( this.isFull() ) {
return false;
}
const usedSet = this.usedSet;
const itemList = this.itemList;
const callbacks = this.callbacks;
itemList.push( item );
usedSet.add( item );
itemSet.set( item, Date.now() );
callbacks.set( item, removeCb );
return true;
}
has( item ) {
return this.itemSet.has( item );
}
remove( item ) {
const usedSet = this.usedSet;
const itemSet = this.itemSet;
const itemList = this.itemList;
const bytesMap = this.bytesMap;
const callbacks = this.callbacks;
const loadedSet = this.loadedSet;
if ( itemSet.has( item ) ) {
this.cachedBytes -= bytesMap.get( item ) || 0;
bytesMap.delete( item );
callbacks.get( item )( item );
const index = itemList.indexOf( item );
itemList.splice( index, 1 );
usedSet.delete( item );
itemSet.delete( item );
callbacks.delete( item );
loadedSet.delete( item );
return true;
}
return false;
}
// Marks whether tiles in the cache have been completely loaded or not. Tiles that have not been completely
// loaded are subject to being disposed early if the cache is full above its max size limits, even if they
// are marked as used.
setLoaded( item, value ) {
const { itemSet, loadedSet } = this;
if ( itemSet.has( item ) ) {
if ( value === true ) {
loadedSet.add( item );
} else {
loadedSet.delete( item );
}
}
}
markUsed( item ) {
const itemSet = this.itemSet;
const usedSet = this.usedSet;
if ( itemSet.has( item ) && ! usedSet.has( item ) ) {
itemSet.set( item, Date.now() );
usedSet.add( item );
}
}
markUnused( item ) {
this.usedSet.delete( item );
}
markAllUnused() {
this.usedSet.clear();
}
// TODO: this should be renamed because it's not necessarily unloading all unused content
// Maybe call it "cleanup" or "unloadToMinSize"
unloadUnusedContent() {
const {
unloadPercent,
minSize,
maxSize,
itemList,
itemSet,
usedSet,
loadedSet,
callbacks,
bytesMap,
minBytesSize,
maxBytesSize,
} = this;
const unused = itemList.length - usedSet.size;
const unloaded = itemList.length - loadedSet.size;
const excessNodes = Math.max( Math.min( itemList.length - minSize, unused ), 0 );
const excessBytes = this.cachedBytes - minBytesSize;
const unloadPriorityCallback = this.unloadPriorityCallback || this.defaultPriorityCallback;
let needsRerun = false;
const hasNodesToUnload = excessNodes > 0 && unused > 0 || unloaded && itemList.length > maxSize;
const hasBytesToUnload = unused && this.cachedBytes > minBytesSize || unloaded && this.cachedBytes > maxBytesSize;
if ( hasBytesToUnload || hasNodesToUnload ) {
// used items should be at the end of the array, "unloaded" items in the middle of the array
itemList.sort( ( a, b ) => {
const usedA = usedSet.has( a );
const usedB = usedSet.has( b );
if ( usedA === usedB ) {
const loadedA = loadedSet.has( a );
const loadedB = loadedSet.has( b );
if ( loadedA === loadedB ) {
// Use the sort function otherwise
// higher priority should be further to the left
return - unloadPriorityCallback( a, b );
} else {
return loadedA ? 1 : - 1;
}
} else {
// If one is used and the other is not move the used one towards the end of the array
return usedA ? 1 : - 1;
}
} );
// address corner cases where the minSize might be zero or smaller than maxSize - minSize,
// which would result in a very small or no items being unloaded.
const maxUnload = Math.max( minSize * unloadPercent, excessNodes * unloadPercent );
const nodesToUnload = Math.ceil( Math.min( maxUnload, unused, excessNodes ) );
const maxBytesUnload = Math.max( unloadPercent * excessBytes, unloadPercent * minBytesSize );
const bytesToUnload = Math.min( maxBytesUnload, excessBytes );
let removedNodes = 0;
let removedBytes = 0;
// evict up to the max node or bytes size, keeping one more item over the max bytes limit
// so the "full" function behaves correctly.
while (
this.cachedBytes - removedBytes > maxBytesSize ||
itemList.length - removedNodes > maxSize
) {
const item = itemList[ removedNodes ];
const bytes = bytesMap.get( item ) || 0;
if (
usedSet.has( item ) && loadedSet.has( item ) ||
this.cachedBytes - removedBytes - bytes < maxBytesSize &&
itemList.length - removedNodes <= maxSize
) {
break;
}
removedBytes += bytes;
removedNodes ++;
}
// evict up to the min node or bytes size, keeping one more item over the min bytes limit
// so we're meeting it
while (
removedBytes < bytesToUnload ||
removedNodes < nodesToUnload
) {
const item = itemList[ removedNodes ];
const bytes = bytesMap.get( item ) || 0;
if (
usedSet.has( item ) ||
this.cachedBytes - removedBytes - bytes < minBytesSize &&
removedNodes >= nodesToUnload
) {
break;
}
removedBytes += bytes;
removedNodes ++;
}
// remove the nodes
itemList.splice( 0, removedNodes ).forEach( item => {
this.cachedBytes -= bytesMap.get( item ) || 0;
callbacks.get( item )( item );
bytesMap.delete( item );
itemSet.delete( item );
callbacks.delete( item );
loadedSet.delete( item );
usedSet.delete( item );
} );
// if we didn't remove enough nodes or we still have excess bytes and there are nodes to removed
// then we want to fire another round of unloading
needsRerun = removedNodes < excessNodes || removedBytes < excessBytes && removedNodes < unused;
needsRerun = needsRerun && removedNodes > 0;
}
if ( needsRerun ) {
this.unloadingHandle = requestAnimationFrame( () => this.scheduleUnload() );
}
}
scheduleUnload() {
cancelAnimationFrame( this.unloadingHandle );
if ( ! this.scheduled ) {
this.scheduled = true;
queueMicrotask( () => {
this.scheduled = false;
this.unloadUnusedContent();
} );
}
}
}
export { LRUCache };
@@ -0,0 +1,49 @@
export function readMagicBytes( bufferOrDataView ) {
if ( bufferOrDataView === null || bufferOrDataView.byteLength < 4 ) {
return '';
}
let view;
if ( bufferOrDataView instanceof DataView ) {
view = bufferOrDataView;
} else {
view = new DataView( bufferOrDataView );
}
if ( String.fromCharCode( view.getUint8( 0 ) ) === '{' ) {
return null;
}
let magicBytes = '';
for ( let i = 0; i < 4; i ++ ) {
magicBytes += String.fromCharCode( view.getUint8( i ) );
}
return magicBytes;
}
const utf8decoder = new TextDecoder();
export function arrayToString( array ) {
return utf8decoder.decode( array );
}
// Returns a working path with a trailing slash
export function getWorkingPath( url ) {
return url.replace( /[\\/][^\\/]+$/, '' ) + '/';
}
@@ -0,0 +1,17 @@
export class PriorityQueue {
maxJobs : number;
autoUpdate : boolean;
priorityCallback : ( itemA : any, itemB : any ) => number;
schedulingCallback : ( func : Function ) => void;
sort() : void;
add( item : any, callback : ( item : any ) => any ) : Promise< any >;
remove( item : any ) : void;
removeByFilter( filter : ( item : any ) => boolean ) : void;
tryRunJobs() : void;
scheduleJobRun() : void;
}
@@ -0,0 +1,200 @@
class PriorityQueue {
// returns whether tasks are queued or actively running
get running() {
return this.items.length !== 0 || this.currJobs !== 0;
}
constructor() {
// options
this.maxJobs = 6;
this.items = [];
this.callbacks = new Map();
this.currJobs = 0;
this.scheduled = false;
this.autoUpdate = true;
this.priorityCallback = null;
// Customizable scheduling callback. Default using requestAnimationFrame()
this.schedulingCallback = func => {
requestAnimationFrame( func );
};
this._runjobs = () => {
this.scheduled = false;
this.tryRunJobs();
};
}
sort() {
const priorityCallback = this.priorityCallback;
const items = this.items;
if ( priorityCallback !== null ) {
items.sort( priorityCallback );
}
}
has( item ) {
return this.callbacks.has( item );
}
add( item, callback ) {
const data = {
callback,
reject: null,
resolve: null,
promise: null,
};
data.promise = new Promise( ( resolve, reject ) => {
const items = this.items;
const callbacks = this.callbacks;
data.resolve = resolve;
data.reject = reject;
items.unshift( item );
callbacks.set( item, data );
if ( this.autoUpdate ) {
this.scheduleJobRun();
}
} );
return data.promise;
}
remove( item ) {
const items = this.items;
const callbacks = this.callbacks;
const index = items.indexOf( item );
if ( index !== - 1 ) {
// reject the promise to ensure there are no dangling promises - add a
// catch here to handle the case where the promise was never used anywhere
// else.
const info = callbacks.get( item );
info.promise.catch( () => {} );
info.reject( new Error( 'PriorityQueue: Item removed.' ) );
items.splice( index, 1 );
callbacks.delete( item );
}
}
removeByFilter( filter ) {
const { items } = this;
for ( let i = 0; i < items.length; i ++ ) {
const item = items[ i ];
if ( filter( item ) ) {
this.remove( item );
}
}
}
tryRunJobs() {
this.sort();
const items = this.items;
const callbacks = this.callbacks;
const maxJobs = this.maxJobs;
let iterated = 0;
const completedCallback = () => {
this.currJobs --;
if ( this.autoUpdate ) {
this.scheduleJobRun();
}
};
while ( maxJobs > this.currJobs && items.length > 0 && iterated < maxJobs ) {
this.currJobs ++;
iterated ++;
const item = items.pop();
const { callback, resolve, reject } = callbacks.get( item );
callbacks.delete( item );
let result;
try {
result = callback( item );
} catch ( err ) {
reject( err );
completedCallback();
}
if ( result instanceof Promise ) {
result
.then( resolve )
.catch( reject )
.finally( completedCallback );
} else {
resolve( result );
completedCallback();
}
}
}
scheduleJobRun() {
if ( ! this.scheduled ) {
this.schedulingCallback( this._runjobs );
this.scheduled = true;
}
}
}
export { PriorityQueue };
@@ -0,0 +1,78 @@
// Helper function for traversing a tile set. If `beforeCb` returns `true` then the
// traversal will end early.
export function traverseSet( tile, beforeCb = null, afterCb = null ) {
const stack = [];
// A stack-based, depth-first traversal, storing
// triplets (tile, parent, depth) in the stack array.
stack.push( tile );
stack.push( null );
stack.push( 0 );
while ( stack.length > 0 ) {
const depth = stack.pop();
const parent = stack.pop();
const tile = stack.pop();
if ( beforeCb && beforeCb( tile, parent, depth ) ) {
if ( afterCb ) {
afterCb( tile, parent, depth );
}
return;
}
const children = tile.children;
// Children might be undefined if the tile has not been preprocessed yet
if ( children ) {
for ( let i = children.length - 1; i >= 0; i -- ) {
stack.push( children[ i ] );
stack.push( tile );
stack.push( depth + 1 );
}
}
if ( afterCb ) {
afterCb( tile, parent, depth );
}
}
}
// Traverses the ancestry of the tile up to the root tile.
export function traverseAncestors( tile, callback = null ) {
let current = tile;
while ( current ) {
const depth = current.__depth;
const parent = current.parent;
if ( callback ) {
callback( current, parent, depth );
}
current = parent;
}
}
@@ -0,0 +1,21 @@
// function that rate limits the amount of time a function can be called to once
// per frame, initially queuing a new call for the next frame.
export function throttle( callback ) {
let handle = null;
return () => {
if ( handle === null ) {
handle = requestAnimationFrame( () => {
handle = null;
callback();
} );
}
};
}
@@ -0,0 +1,43 @@
/**
* Returns the file extension of the path component of a URL
* @param {string} url
* @returns {string} null if no extension found
*/
export function getUrlExtension( url ) {
if ( ! url ) {
return null;
}
// Find the last occurrence of '?' and '#' to handle query params and fragments
let endIndex = url.length;
const queryIndex = url.indexOf( '?' );
const fragmentIndex = url.indexOf( '#' );
if ( queryIndex !== - 1 ) {
endIndex = Math.min( endIndex, queryIndex );
}
if ( fragmentIndex !== - 1 ) {
endIndex = Math.min( endIndex, fragmentIndex );
}
// Check if the string is just a hostname or whether the path does not end in an extension
const lastPeriodIndex = url.lastIndexOf( '.', endIndex );
const lastSlashIndex = url.lastIndexOf( '/', endIndex );
const protocolIndex = url.indexOf( '://' );
const isHostOnly = protocolIndex !== - 1 && protocolIndex + 2 === lastSlashIndex;
if ( isHostOnly || lastPeriodIndex === - 1 || lastPeriodIndex < lastSlashIndex ) {
return null;
}
return url.substring( lastPeriodIndex + 1, endIndex ) || null;
}
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core';
export * from '3d-tiles-renderer/three';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core';
export * from '3d-tiles-renderer/three';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core/plugins';
export * from '3d-tiles-renderer/three/plugins';
@@ -0,0 +1,2 @@
export * from '3d-tiles-renderer/core/plugins';
export * from '3d-tiles-renderer/three/plugins';
@@ -0,0 +1,250 @@
# 3D Tiles React Components
Set of components for loading and rendering 3D Tiles in [@react-three/fiber](https://r3f.docs.pmnd.rs/).
**Examples**
[Basic example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/basic.html)
[Cesium Ion example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/ion.html)
[Google Photorealistic Tiles example](https://nasa-ammos.github.io/3DTilesRendererJS/example/bundle/r3f/globe.html)
# Use
## Simple
```jsx
import { TilesRenderer } from '3d-tiles-renderer/r3f';
const TILESET_URL = /* your tile set url */;
const cameraPosition = [ x, y, z ]; // Set the camera position so the tiles are visible
export default function App() {
return (
<Canvas camera={ { position: cameraPosition } }>
<TilesRenderer url={ TILESET_URL } />
</Canvas>
);
}
```
## With Plugins, Controls, & Attribution
Basic set up for Google Photorealistic tiles, Globe controls, and an overlay for displaying data set attributions.
```jsx
import { TilesRenderer, TilesPlugin, GlobeControls, TilesAttributionOverlay } from '3d-tiles-renderer/r3f';
import { DebugTilesPlugin, GoogleCloudAuthPlugin } from '3d-tiles-renderer/plugins';
export default function App() {
return (
<Canvas camera={ { position: [ 0, 0, 1e8 ] } }>
<TilesRenderer>
<TilesPlugin plugin={ DebugTilesPlugin } displayBoxBounds={ true } />
<TilesPlugin plugin={ GoogleCloudAuthPlugin } args={ { apiToken: /* your api token here */ } } />
<GlobeControls />
<TilesAttributionOverlay />
</TilesRenderer>
</Canvas>
);
}
```
## Cesium Ion & Google Cloud
Simplified wrappers for using the TilesRenderer with Cesium Ion and Google Cloud for Photorealistic Tiles. Use the `TilesAttributionOverlay` to display appropriate credits for the data sets.
```jsx
import { TilesRenderer, TilesPlugin } from '3d-tiles-renderer/r3f';
import { CesiumIonAuthPlugin, GoogleCloudAuthPlugin } from '3d-tiles-renderer/plugins';
function GoogleTilesRenderer( { children, apiToken, ...rest } ) {
return (
<TilesRenderer { ...rest } key={ apiToken }>
<TilesPlugin plugin={ GoogleCloudAuthPlugin } args={ { apiToken } } />
{ children }
</TilesRenderer>
);
}
function CesiumIonTilesRenderer( { children, apiToken, assetId, ...rest } ) {
return (
<TilesRenderer { ...rest } key={ apiToken + assetId }>
<TilesPlugin plugin={ CesiumIonAuthPlugin } args={ { apiToken, assetId } } />
{ children }
</TilesRenderer>
);
}
```
# Components
## TilesRenderer
Wrapper for the three.js `TilesRenderer` class. Listening for events are specified with a camel-case property prefixed with `on`, such as `onLoadModel`, and all other properties are specified as individual properties with dashes being used to indicate nested properties. For example, `lruCache-minSize` is used to set `lruCache.minSize`.
```jsx
<TilesRenderer
url={ tilesetUrl }
// if false then "update" is not called
enabled={ true }
// pass properties to apply to the tile set root object
group={ {
position: [ 0, 10, 0 ],
rotation: [ Math.PI / 2, 0, 0 ],
} }
// set options to the TilesRenderer object
errorTarget={ 6 }
errorThreshold={ 10 }
// set nested object options of the TilesRenderer
parseQueue-maxJobs={ 30 }
downloadQueue-maxJobs={ 10 }
lruCache-minBytesSize={ 0.25 * 1e6 }
lruCache-maxBytesSize={ 0.5 * 1e6 }
// event registration
onLoadTileSet={ onLoadTileSetCallback }
onLoadModel={ onLoadModelCallback }
/>
```
## TilesPlugin
Plugins can be set as children of the TilesRenderer component to add additional functionality. TilePlugin components must be nested inside a TilesRenderer component. Constructor arguments are passed via the `args` parameter while local members can be passed via the regular properties. But note that depending on the plugin some properties cannot be changed after construction and initialization.
See the [PLUGINS documentation](https://github.com/NASA-AMMOS/3DTilesRendererJS/blob/master/PLUGINS.md) for docs on all avilable plugins.
```jsx
<TilesRenderer url={ tilesetUrl }>
<TilesPlugin
plugin={ PluginClassName }
args={ /* constructor arguments as array or object */ }
{ ...pluginProps }
/>
</TilesRenderer>
```
And a practical example of creating and using a plugin:
```jsx
<TilesRenderer url={ tilesetUrl } >
<TilesPlugin plugin={ GLTFExtensionsPlugin }
dracoLoader={ dracoLoader }
ktxLoader={ ktx2Loader }
autoDispose={ false }
{ /*
// alternatively the options can be passed via constructor arguments
// or a mix of both can be used.
args = { {
dracoLoader,
ktxLoader,
autoDispose: false,
} }
*/ }
/>
</TilesRenderer>
```
## Controls
These `EnvironmentControls` and `GlobeControls` classes have been wrapped as components to handle user-interaction. They will both be set to the `controls` react three fiber state field when in use. All properties on the original classes can be passed as properties:
```jsx
<>
<TilesRenderer url={ url } { ...props } />
<EnvironmentControls enableDamping={ true } enabled={ true } />
</>
```
The `GlobeControls` component must be set as a child of the `TilesRenderer` component that is providing the ellipsoid to orbit around.
```jsx
<TilesRenderer url={ url } { ...props }>
<GlobeControls enableDamping={ true } />
</TilesRenderer>
```
## EastNorthUpFrame
The `EastNorthUpFrame` creates a root object that is centered on the provided point relative to the tile sets ellipsoid, specified via lat/lon/height and euler angle props and is used to place 3D objects relative to that point. It does not rotate the original tile set and must be a child of a `TilesRenderer` component.
It can be used to place markers on the surface of the ellipsoid, such as a cone for pointing to a location:
```jsx
<TilesRenderer url={ url } { ...props }>
{ /* ... */ }
<EastNorthUpFrame
{/* The latitude and longitude to place the frame at in radians */}
lat={ lat }
lon={ lon }
{/* The height above the ellipsoid to place the frame at in meters */}
height={ 100 }
{/*
The azimuth, elevation, and roll around the "north" axis, applied
in that order intrinsicly, in radians
*/}
az={ 0 }
el={ 0 }
roll={ 0 }
>
{/* Children are position relative to the east, north, up frame */}
<mesh rotation-x={ - Math.PI / 2 } scale={ 100 } position-z={ 50 }>
<coneGeometry args={ [ 0.5 ] } />
<meshStandardMaterial color={ 'red' } />
</mesh>
</EastNorthUpFrame>
</TilesRenderer>
```
## TilesAttributionOverlay
The `TilesAttributionOverlay` component must be embedded in a tile set and will automatically display the credits associated with the loaded data set.
```jsx
<TilesRenderer url={ url } { ...props }>
<TilesAttributionOverlay
{ /*
Callback function for generating attribution elements from credit info.
Takes the list of attributions and a unique "id" assigned to the overlay dom element.
*/ }
generateAttributions={ null }
{ /* remaining properties are assigned to the root overlay element */ }
/>
</TilesRenderer>
```
## CompassGizmo
Adds a compass to the bottom right of the page that orients to "north" based on the camera position and orientation. Must be nested in a `TilesRenderer` component.
Any children passed into the class will replace the default red and white compass design with +Y pointing north and +X pointing east. The graphic children should fit within a volume from - 0.5 to 0.5 along all axes.
```jsx
<CompassGizmo
{/* Specifies whether the compass will render in '2d' or '3d' */}
mode={ '3d' }
{/* The size of the compass in pixels */}
scale={ 35 }
{/* The number pixels in margin to add relative to the bottom right of the screen */}
margin={ 10 }
{/* Whether to render the main scene */}
overrideRenderLoop={ true }
{/* Whether the gizmo is visible and rendering */}
visible={ true }
{/* Any remaining props including click events are passed through to the parent group */}
onClick={ () => console.log( 'compass clicked!' ) }
/>
```
@@ -0,0 +1,27 @@
import type { ForwardRefExoticComponent, RefAttributes } from 'react';
import type { Camera, Object3D } from 'three';
import type { EnvironmentControls as EnvironmentControlsImpl, GlobeControls as GlobeControlsImpl } from '3d-tiles-renderer/three';
import type { TilesRenderer } from './TilesRenderer.jsx';
interface ControlsBaseProps {
domElement?: HTMLCanvasElement | null;
scene?: Object3D | null;
camera?: Camera | null;
tilesRenderer?: typeof TilesRenderer | null;
}
type EnvironmentControlsProps = Partial<
InstanceType<typeof EnvironmentControlsImpl>
> &
ControlsBaseProps;
type GlobeControlsProps = Partial<InstanceType<typeof GlobeControlsImpl>> &
ControlsBaseProps;
export declare const EnvironmentControls: ForwardRefExoticComponent<
EnvironmentControlsProps & RefAttributes<EnvironmentControlsImpl>
>;
export declare const GlobeControls: ForwardRefExoticComponent<
GlobeControlsProps & RefAttributes<GlobeControlsImpl>
>;

Some files were not shown because too many files have changed in this diff Show More