Advance Blender WebEngine N-015 through N-022 parity
This commit is contained in:
@@ -50,6 +50,7 @@ interface MeshEditSelection {
|
||||
mode: MeshElementMode;
|
||||
indices: Set<number>;
|
||||
nonMeshKind?: NonMeshElementKind;
|
||||
nonMeshSelections?: Map<NonMeshElementKind, Set<number>>;
|
||||
}
|
||||
|
||||
function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers, textureAssets, lodLevels, selectedObjectIds, editMode, meshSelection, onSelect, onElementSelect, onTransform }: {
|
||||
@@ -106,9 +107,12 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
|
||||
if (renderer && snapshot && lodLevels) {
|
||||
for (const [meshId, levels] of Object.entries(lodLevels)) renderer.installLODLevels(meshId, levels);
|
||||
}
|
||||
renderer?.setSelection(selectedObjectIds);
|
||||
const elementSelection = meshSelection.meshId && meshSelection.nonMeshSelections
|
||||
? new Map([[meshSelection.meshId, meshSelection.nonMeshSelections]])
|
||||
: undefined;
|
||||
renderer?.setSelection(selectedObjectIds, elementSelection);
|
||||
renderer?.setInteractionMode(editMode, meshSelection.mode);
|
||||
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode]);
|
||||
}, [snapshot, geometryBuffers, nonMeshGeometryBuffers, lodLevels, selectedObjectIds, editMode, meshSelection.mode, meshSelection.meshId, meshSelection.nonMeshSelections]);
|
||||
|
||||
useEffect(() => {
|
||||
rendererRef.current?.setTextureAssets(textureAssets);
|
||||
@@ -382,7 +386,7 @@ export function App() {
|
||||
return next;
|
||||
});
|
||||
setSnapshot((current) => current ? { ...current, activeObjectId: id } : current);
|
||||
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set() }));
|
||||
setMeshSelection((current) => ({ ...current, meshId: null, indices: new Set(), nonMeshSelections: undefined }));
|
||||
};
|
||||
const selectMeshElement = (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind): void => {
|
||||
const owner = snapshot?.nodes.find((node) => node.dataId === meshId);
|
||||
@@ -391,11 +395,20 @@ export function App() {
|
||||
setSnapshot((current) => current ? { ...current, activeObjectId: owner.id } : current);
|
||||
}
|
||||
setMeshSelection((current) => {
|
||||
const preserve = additive && current.meshId === meshId && current.mode === mode && current.nonMeshKind === nonMeshKind;
|
||||
const preserve = additive && current.meshId === meshId && current.mode === mode;
|
||||
const next = preserve ? new Set(current.indices) : new Set<number>();
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return { meshId, mode, indices: next, nonMeshKind };
|
||||
if (!nonMeshKind) return { meshId, mode, indices: next, nonMeshKind, nonMeshSelections: undefined };
|
||||
const selections = new Map<NonMeshElementKind, Set<number>>(preserve ? [...(current.nonMeshSelections ?? [])].map(([kind, values]) => [kind, new Set(values)]) : []);
|
||||
const kindIndices = selections.get(nonMeshKind) ?? new Set<number>();
|
||||
if (kindIndices.has(index)) kindIndices.delete(index);
|
||||
else kindIndices.add(index);
|
||||
if (kindIndices.size === 0) selections.delete(nonMeshKind);
|
||||
else selections.set(nonMeshKind, kindIndices);
|
||||
const combined = new Set<number>();
|
||||
for (const values of selections.values()) for (const value of values) combined.add(value);
|
||||
return { meshId, mode, indices: combined, nonMeshKind, nonMeshSelections: selections };
|
||||
});
|
||||
};
|
||||
const restoreCachedLODs = async (projectId: string, scene: SceneSnapshotIR): Promise<void> => {
|
||||
@@ -511,14 +524,14 @@ export function App() {
|
||||
};
|
||||
const setMeshSelectionMode = (mode: MeshElementMode): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set() });
|
||||
setMeshSelection({ meshId: activeNode?.dataId ?? null, mode, indices: new Set(), nonMeshSelections: undefined });
|
||||
};
|
||||
const selectAllMeshElements = (): void => {
|
||||
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
|
||||
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode?.dataId);
|
||||
if (!mesh) return;
|
||||
const count = meshSelection.mode === "VERT" ? mesh.vertexCount : meshSelection.mode === "EDGE" ? mesh.edgeCount : mesh.faceCount;
|
||||
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)) });
|
||||
setMeshSelection({ meshId: mesh.id, mode: meshSelection.mode, indices: new Set(Array.from({ length: count }, (_, index) => index)), nonMeshSelections: undefined });
|
||||
};
|
||||
const runMeshEdit = (operation: MeshEditOperation): void => {
|
||||
if (!meshSelection.meshId || meshSelection.indices.size === 0) return;
|
||||
@@ -546,19 +559,19 @@ export function App() {
|
||||
if (!activeNode) return;
|
||||
if (uiState.context.mode === "Edit" && activeNode.dataId) {
|
||||
const nonMesh = snapshot?.nonMeshData?.find((candidate) => candidate.id === activeNode.dataId);
|
||||
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id &&
|
||||
(meshSelection.nonMeshKind === "HANDLE_LEFT" || meshSelection.nonMeshKind === "HANDLE_RIGHT") &&
|
||||
meshSelection.indices.size === 1 && nonMesh.handlePoints) {
|
||||
const pointIndex = [...meshSelection.indices][0];
|
||||
const packedPointIndex = nonMesh.handlePointIndices?.indexOf(pointIndex) ?? pointIndex;
|
||||
if (packedPointIndex < 0) return;
|
||||
const handleOffset = packedPointIndex * 6 + (meshSelection.nonMeshKind === "HANDLE_RIGHT" ? 3 : 0);
|
||||
const position: [number, number, number] = [
|
||||
nonMesh.handlePoints[handleOffset], nonMesh.handlePoints[handleOffset + 1], nonMesh.handlePoints[handleOffset + 2],
|
||||
];
|
||||
position[axis] += amount;
|
||||
void applyEditCommand({ type: "setCurveHandle", dataId: nonMesh.id, pointIndex,
|
||||
side: meshSelection.nonMeshKind === "HANDLE_RIGHT" ? "RIGHT" : "LEFT", position });
|
||||
if (nonMesh?.type === "CURVE" && tool === "translate" && meshSelection.meshId === nonMesh.id && nonMesh.handlePoints && meshSelection.nonMeshSelections && meshSelection.nonMeshSelections.size > 0) {
|
||||
const handlePoints = nonMesh.handlePoints.slice();
|
||||
const pointIndices = nonMesh.handlePointIndices ?? Array.from({ length: handlePoints.length / 6 }, (_, index) => index);
|
||||
for (const [kind, selected] of meshSelection.nonMeshSelections) {
|
||||
if (kind === "CONTROL_POINT") continue;
|
||||
const sideOffset = kind === "HANDLE_RIGHT" ? 3 : 0;
|
||||
for (const pointIndex of selected) {
|
||||
const packedPointIndex = pointIndices.indexOf(pointIndex);
|
||||
if (packedPointIndex < 0) continue;
|
||||
handlePoints[packedPointIndex * 6 + sideOffset + axis] += amount;
|
||||
}
|
||||
}
|
||||
void applyEditCommand({ type: "setCurveTopology", dataId: nonMesh.id, splineTypes: nonMesh.splineTypes, cyclicU: nonMesh.cyclicU, cyclicV: nonMesh.cyclicV, handleTypes: nonMesh.handleTypes, handlePoints });
|
||||
return;
|
||||
}
|
||||
const mesh = snapshot?.meshes.find((candidate) => candidate.id === activeNode.dataId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { LODCacheRecord } from "../../../protocol/lod";
|
||||
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
|
||||
@@ -127,6 +127,10 @@ export class StorageClient {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
}
|
||||
|
||||
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
|
||||
}
|
||||
|
||||
@@ -380,3 +380,26 @@ export async function readContentAsset(projectId: string, sha256: string, storag
|
||||
const file = await directory.getFileHandle(sha256);
|
||||
return (await file.getFile()).arrayBuffer();
|
||||
}
|
||||
|
||||
export async function readContentAssetRange(
|
||||
projectId: string,
|
||||
sha256: string,
|
||||
byteOffset: number,
|
||||
byteLength: number,
|
||||
expectedTotalBytes: number,
|
||||
storage?: StorageManager,
|
||||
): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0 || !Number.isSafeInteger(byteLength) || byteLength <= 0 ||
|
||||
!Number.isSafeInteger(expectedTotalBytes) || expectedTotalBytes <= 0 || byteOffset > expectedTotalBytes - byteLength) {
|
||||
throw new Error("Content-addressed asset range is invalid");
|
||||
}
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const directory = await ensureDirectory(root, `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
|
||||
const file = await (await directory.getFileHandle(sha256)).getFile();
|
||||
if (file.size !== expectedTotalBytes) throw new Error("Content-addressed asset size mismatch");
|
||||
return file.slice(byteOffset, byteOffset + byteLength).arrayBuffer();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
LineBasicMaterial,
|
||||
type Object3D,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { GreasePencilDataIR, GreasePencilFrameIR } from "../../../protocol/grease-pencil";
|
||||
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
||||
import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
|
||||
interface DrawingPreview {
|
||||
drawing: GreasePencilDrawingIR;
|
||||
onion: "NONE" | "PREVIOUS" | "NEXT";
|
||||
}
|
||||
|
||||
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
|
||||
let selected: GreasePencilFrameIR | undefined;
|
||||
for (const candidate of frames) {
|
||||
@@ -18,45 +23,75 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
|
||||
return selected;
|
||||
}
|
||||
|
||||
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
|
||||
const current = activeFrame(layer.frames, frame);
|
||||
if (!current) return [];
|
||||
const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }];
|
||||
if (!layer.onionSkinning) return result;
|
||||
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
|
||||
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame);
|
||||
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" });
|
||||
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" });
|
||||
return result;
|
||||
}
|
||||
|
||||
function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
|
||||
let count = 0;
|
||||
for (const stroke of preview.drawing.strokes) {
|
||||
if (!stroke.points || stroke.points.length < 2) continue;
|
||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let opacity = 0;
|
||||
for (let index = 0; index < pointCount; index++) {
|
||||
const point = stroke.points[index % stroke.points.length];
|
||||
positions[index * 3] = point.position[0];
|
||||
positions[index * 3 + 1] = point.position[2];
|
||||
positions[index * 3 + 2] = -point.position[1];
|
||||
}
|
||||
for (const point of stroke.points) {
|
||||
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
|
||||
red += color[0];
|
||||
green += color[1];
|
||||
blue += color[2];
|
||||
opacity += point.opacity * color[3];
|
||||
}
|
||||
const divisor = stroke.points.length;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
|
||||
const material = new LineBasicMaterial({
|
||||
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
||||
transparent: true,
|
||||
depthWrite: preview.onion === "NONE",
|
||||
});
|
||||
const line = new Line(geometry, material);
|
||||
line.userData.greasePencilOnion = preview.onion;
|
||||
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
|
||||
group.add(line);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function createGreasePencilObject(data: GreasePencilDataIR, frame: number): Object3D | null {
|
||||
if (data.geometryStatus !== "available") return null;
|
||||
const group = new Group();
|
||||
let onionDrawingCount = 0;
|
||||
let currentDrawingCount = 0;
|
||||
for (const layer of data.layers) {
|
||||
if (!layer.visible || layer.opacity <= 0) continue;
|
||||
const drawing = activeFrame(layer.frames, frame)?.drawing;
|
||||
if (!drawing) continue;
|
||||
for (const stroke of drawing.strokes) {
|
||||
if (!stroke.points || stroke.points.length < 2) continue;
|
||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
let blue = 0;
|
||||
let opacity = 0;
|
||||
for (let index = 0; index < pointCount; index++) {
|
||||
const point = stroke.points[index % stroke.points.length];
|
||||
positions[index * 3] = point.position[0];
|
||||
positions[index * 3 + 1] = point.position[2];
|
||||
positions[index * 3 + 2] = -point.position[1];
|
||||
}
|
||||
for (const point of stroke.points) {
|
||||
const color = point.vertexColor ?? [0.2, 0.2, 0.2, 1];
|
||||
red += color[0];
|
||||
green += color[1];
|
||||
blue += color[2];
|
||||
opacity += point.opacity * color[3];
|
||||
}
|
||||
const divisor = stroke.points.length;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const material = new LineBasicMaterial({
|
||||
color: new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor)),
|
||||
transparent: true,
|
||||
});
|
||||
group.add(new Line(geometry, material));
|
||||
for (const preview of layerDrawings(layer, frame)) {
|
||||
const added = addDrawing(group, layer, preview);
|
||||
if (preview.onion === "NONE") currentDrawingCount += added;
|
||||
else onionDrawingCount += added;
|
||||
}
|
||||
}
|
||||
group.userData.greasePencilCurrentStrokeCount = currentDrawingCount;
|
||||
group.userData.greasePencilOnionStrokeCount = onionDrawingCount;
|
||||
return group.children.length > 0 ? group : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
|
||||
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
|
||||
|
||||
function blenderPosition(x: number, y: number, z: number): [number, number, number] {
|
||||
return [x, z, -y];
|
||||
@@ -80,7 +81,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
controlPositions.set(blenderPosition(points[index], points[index + 1], points[index + 2]), index);
|
||||
}
|
||||
controlGeometry.setAttribute("position", new Float32BufferAttribute(controlPositions, 3));
|
||||
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true }));
|
||||
const controls = new Points(controlGeometry, new PointsMaterial({ color: new Color(0x67b7ff), size: 0.09, sizeAttenuation: true, vertexColors: true }));
|
||||
controls.userData.nonMeshDataId = data.id;
|
||||
controls.userData.nonMeshPointIndexMap = Array.from({ length: points.length / 3 }, (_, index) => index);
|
||||
controls.userData.nonMeshPointKindMap = Array.from({ length: points.length / 3 }, () => "CONTROL_POINT" as NonMeshElementKind);
|
||||
@@ -92,7 +93,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
group.add(lines);
|
||||
const pointGeometry = new BufferGeometry();
|
||||
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
|
||||
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true }));
|
||||
const handles = new Points(pointGeometry, new PointsMaterial({ color: new Color(0xd7b8ff), size: 0.1, sizeAttenuation: true, vertexColors: true }));
|
||||
handles.userData.nonMeshDataId = data.id;
|
||||
handles.userData.nonMeshPointIndexMap = handleIndexMap;
|
||||
handles.userData.nonMeshPointKindMap = handleKindMap;
|
||||
@@ -172,3 +173,25 @@ export function applyNonMeshTransform(object: Object3D, node: SceneNodeIR): void
|
||||
child.userData.blenderId = node.id;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshElementSelection): void {
|
||||
root.traverse((object) => {
|
||||
const dataId = object.userData.nonMeshDataId;
|
||||
const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined;
|
||||
const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
|
||||
if (typeof dataId !== "string" || !indexMap || !kindMap || !(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
|
||||
const selectedByKind = selection.get(dataId);
|
||||
const colors = new Float32Array(indexMap.length * 3);
|
||||
for (let index = 0; index < indexMap.length; index++) {
|
||||
const selected = selectedByKind?.get(kindMap[index])?.has(indexMap[index]) ?? false;
|
||||
const color = selected ? [1, 0.4, 0.1] : kindMap[index] === "CONTROL_POINT" ? [0.4, 0.72, 1] : [0.6, 0.48, 1];
|
||||
colors[index * 3] = color[0];
|
||||
colors[index * 3 + 1] = color[1];
|
||||
colors[index * 3 + 2] = color[2];
|
||||
}
|
||||
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
|
||||
object.material.color.set(0xffffff);
|
||||
object.material.vertexColors = true;
|
||||
object.material.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type OffscreenViewportRequest =
|
||||
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
|
||||
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
||||
| { type: "resize"; width: number; height: number; pixelRatio: number }
|
||||
| { type: "selection"; objectIds: string[] }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }> }
|
||||
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
|
||||
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
|
||||
| { type: "pick"; x: number; y: number; additive: boolean }
|
||||
@@ -18,7 +18,7 @@ export type OffscreenViewportRequest =
|
||||
export type OffscreenViewportResponse =
|
||||
| { type: "ready" }
|
||||
| { type: "frame"; visiblePixels: number }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
|
||||
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
|
||||
| { type: "selected"; objectId: string; additive: boolean }
|
||||
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { NonMeshElementKind } from "./nonmesh";
|
||||
export interface ViewportBackend {
|
||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
|
||||
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
|
||||
dispose(): void;
|
||||
@@ -147,8 +147,9 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>): void {
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
@@ -209,6 +210,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
|
||||
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
|
||||
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
|
||||
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
|
||||
}
|
||||
else if (message.type === "textureStatus") {
|
||||
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
|
||||
|
||||
62
web/app/src/three-adapter/paint-hit.ts
Normal file
62
web/app/src/three-adapter/paint-hit.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Matrix3,
|
||||
Mesh,
|
||||
Triangle,
|
||||
Vector2,
|
||||
Vector3,
|
||||
type Intersection,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { PaintHitIR } from "../../../protocol/paint";
|
||||
|
||||
export interface PaintRaycastHitIR extends PaintHitIR {
|
||||
objectId: string;
|
||||
dataId: string;
|
||||
}
|
||||
|
||||
function finiteTuple(values: readonly number[]): boolean {
|
||||
return values.every(Number.isFinite);
|
||||
}
|
||||
|
||||
export function paintHitFromIntersection(intersection: Intersection, pressure = 1): PaintRaycastHitIR | null {
|
||||
const faceIndex = intersection.faceIndex;
|
||||
if (!(intersection.object instanceof Mesh) || faceIndex === undefined || faceIndex === null || faceIndex < 0 || pressure < 0 || pressure > 1) return null;
|
||||
const object = intersection.object;
|
||||
const positionAttribute = object.geometry.getAttribute("position");
|
||||
const indexAttribute = object.geometry.getIndex();
|
||||
if (!positionAttribute) return null;
|
||||
const corner = faceIndex * 3;
|
||||
const vertexA = indexAttribute ? indexAttribute.getX(corner) : corner;
|
||||
const vertexB = indexAttribute ? indexAttribute.getX(corner + 1) : corner + 1;
|
||||
const vertexC = indexAttribute ? indexAttribute.getX(corner + 2) : corner + 2;
|
||||
if ([vertexA, vertexB, vertexC].some((vertex) => vertex < 0 || vertex >= positionAttribute.count)) return null;
|
||||
const a = new Vector3().fromBufferAttribute(positionAttribute, vertexA);
|
||||
const b = new Vector3().fromBufferAttribute(positionAttribute, vertexB);
|
||||
const c = new Vector3().fromBufferAttribute(positionAttribute, vertexC);
|
||||
const localPoint = object.worldToLocal(intersection.point.clone());
|
||||
const barycentric = Triangle.getBarycoord(localPoint, a, b, c, new Vector3());
|
||||
if (!barycentric || !finiteTuple(barycentric.toArray())) return null;
|
||||
const localNormal = intersection.face?.normal?.clone() ?? new Triangle(a, b, c).getNormal(new Vector3());
|
||||
const worldNormal = localNormal.applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld)).normalize();
|
||||
const sourceFaces = object.userData.triangleFaceIndices as number[] | undefined;
|
||||
const result: PaintRaycastHitIR = {
|
||||
objectId: String(object.userData.blenderId ?? ""),
|
||||
dataId: String(object.userData.meshId ?? ""),
|
||||
position: intersection.point.toArray(),
|
||||
normal: worldNormal.toArray(),
|
||||
faceIndex: sourceFaces?.[faceIndex] ?? faceIndex,
|
||||
barycentric: barycentric.toArray(),
|
||||
pressure,
|
||||
};
|
||||
if (!result.objectId || !result.dataId) return null;
|
||||
const uv = object.geometry.getAttribute("uv");
|
||||
if (uv) {
|
||||
const uvA = new Vector2().fromBufferAttribute(uv, vertexA);
|
||||
const uvB = new Vector2().fromBufferAttribute(uv, vertexB);
|
||||
const uvC = new Vector2().fromBufferAttribute(uv, vertexC);
|
||||
result.uv = [
|
||||
uvA.x * barycentric.x + uvB.x * barycentric.y + uvC.x * barycentric.z,
|
||||
uvA.y * barycentric.x + uvB.y * barycentric.y + uvC.y * barycentric.z,
|
||||
];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { GPUTextureStore } from "./texture-assets";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
|
||||
import { applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
|
||||
|
||||
@@ -279,6 +279,7 @@ export class ViewportRenderer {
|
||||
const dataById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
|
||||
let previewCount = 0;
|
||||
let blockedCount = 0;
|
||||
let onionStrokeCount = 0;
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
|
||||
const data = dataById.get(node.dataId);
|
||||
@@ -291,10 +292,12 @@ export class ViewportRenderer {
|
||||
applyGreasePencilTransform(object, node);
|
||||
this.importedRoot.add(object);
|
||||
this.objectByBlenderId.set(node.id, object);
|
||||
onionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
previewCount++;
|
||||
}
|
||||
this.canvas.dataset.greasePencilCount = String(previewCount);
|
||||
this.canvas.dataset.greasePencilBlockedCount = String(blockedCount);
|
||||
this.canvas.dataset.greasePencilOnionStrokeCount = String(onionStrokeCount);
|
||||
}
|
||||
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
|
||||
@@ -350,7 +353,7 @@ export class ViewportRenderer {
|
||||
this.currentSnapshot = next;
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>): void {
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
const visitedInstances = new Set<InstancedMesh>();
|
||||
for (const [objectId, object] of this.objectByBlenderId) {
|
||||
if (object instanceof InstancedMesh) {
|
||||
@@ -370,6 +373,7 @@ export class ViewportRenderer {
|
||||
setPBRMaterialSelected(material, objectIds.has(objectId));
|
||||
}
|
||||
}
|
||||
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
|
||||
2
web/app/src/vendor/blender/web_engine.js
vendored
2
web/app/src/vendor/blender/web_engine.js
vendored
File diff suppressed because one or more lines are too long
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
BIN
web/app/src/vendor/blender/web_engine.wasm
vendored
Binary file not shown.
@@ -1,15 +1,65 @@
|
||||
import { gateSelectionInteraction, parseRaycastSelectionHit, recordSelection, SELECTION_HISTORY_BUDGET, stepSelectionHistory } from "../../../protocol/selection-history";
|
||||
import {
|
||||
gateSelectionInteraction,
|
||||
parseRaycastSelectionHit,
|
||||
parseSelectionHistory,
|
||||
patchSelectionRanges,
|
||||
recordSelection,
|
||||
SELECTION_HISTORY_BUDGET,
|
||||
stepSelectionHistory,
|
||||
} from "../../../protocol/selection-history";
|
||||
|
||||
const empty = { activeObjectId: null, objectIds: [], targets: [] };
|
||||
const selected = {
|
||||
activeObjectId: "object:1",
|
||||
objectIds: ["object:1", "object:2"],
|
||||
targets: [
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", indices: [3, 1] },
|
||||
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", indices: [2] },
|
||||
],
|
||||
};
|
||||
const base = { schemaVersion: 2, revision: 0, cursor: 0, entries: [empty] };
|
||||
|
||||
const empty = { activeObjectId: null, objectIds: [], meshId: null, elementMode: "FACE", elementIndices: [] };
|
||||
const selected = { activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "VERT", elementIndices: [3, 1] };
|
||||
const base = { schemaVersion: 1, revision: 0, cursor: 0, entries: [empty] };
|
||||
self.onmessage = () => {
|
||||
const result: Record<string, unknown> = {};
|
||||
try { const next = recordSelection(base, 0, { ...selected, nonMeshKind: "HANDLE_LEFT" }); const undone = stepSelectionHistory(next, 1, "UNDO"); const redone = stepSelectionHistory(undone, 2, "REDO"); result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].elementIndices, redone.entries[redone.cursor].nonMeshKind]; } catch (error) { result.history = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 4, selected); } catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 0, { ...selected, elementIndices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }); } catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); } catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
|
||||
try { result.handleHit = parseRaycastSelectionHit({ sourceRevision: 3, dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3).nonMeshKind; } catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const next = recordSelection(base, 0, selected);
|
||||
const undone = stepSelectionHistory(next, 1, "UNDO");
|
||||
const redone = stepSelectionHistory(undone, 2, "REDO");
|
||||
result.history = [redone.revision, redone.cursor, redone.entries[redone.cursor].targets.map((target) => [target.dataId, target.indices, target.nonMeshKind])];
|
||||
}
|
||||
catch (error) { result.history = error instanceof Error ? error.message : String(error); }
|
||||
try { recordSelection(base, 4, selected); }
|
||||
catch (error) { result.revision = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
recordSelection(base, 0, { ...selected, targets: [{ ...selected.targets[0], indices: new Array(SELECTION_HISTORY_BUDGET.maxElements + 1).fill(1) }] });
|
||||
}
|
||||
catch (error) { result.budget = error instanceof Error ? error.message : String(error); }
|
||||
try { parseRaycastSelectionHit({ sourceRevision: 2, dataId: "mesh:1", mode: "VERT", index: 0, distance: 1, point: [0, 0, 0] }, 3); }
|
||||
catch (error) { result.raycast = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const hit = parseRaycastSelectionHit({ sourceRevision: 3, objectId: "object:1", dataId: "curve:1", mode: "VERT", index: 2, distance: 1, point: [0, 0, 0], nonMeshKind: "HANDLE_RIGHT" }, 3);
|
||||
result.handleHit = [hit.objectId, hit.nonMeshKind];
|
||||
}
|
||||
catch (error) { result.handleHit = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const patched = patchSelectionRanges(selected, [
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 2, end: 4, selected: true },
|
||||
{ objectId: "object:1", dataId: "curve:1", mode: "VERT", nonMeshKind: "HANDLE_LEFT", start: 3, end: 3, selected: false },
|
||||
{ objectId: "object:2", dataId: "curve:2", mode: "VERT", nonMeshKind: "HANDLE_RIGHT", start: 2, end: 2, selected: false },
|
||||
]);
|
||||
result.rangePatch = patched.targets.map((target) => [target.dataId, target.indices]);
|
||||
}
|
||||
catch (error) { result.rangePatch = error instanceof Error ? error.message : String(error); }
|
||||
try {
|
||||
const migrated = parseSelectionHistory({
|
||||
schemaVersion: 1,
|
||||
revision: 0,
|
||||
cursor: 0,
|
||||
entries: [{ activeObjectId: "object:1", objectIds: ["object:1"], meshId: "mesh:1", elementMode: "FACE", elementIndices: [4] }],
|
||||
});
|
||||
result.migrated = [migrated.schemaVersion, migrated.entries[0].targets[0].dataId];
|
||||
}
|
||||
catch (error) { result.migrated = error instanceof Error ? error.message : String(error); }
|
||||
result.gates = [gateSelectionInteraction("RAYCAST").status, gateSelectionInteraction("HISTORY").status, gateSelectionInteraction("GIZMO").status];
|
||||
self.postMessage(result);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageAssetRecord, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationRecord, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
|
||||
import { parseLODCacheRecord, type LODCacheRecord } from "../../../protocol/lod";
|
||||
import { parseSimulationCacheManifest, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { parseSimulationCacheManifest, selectSimulationCacheFrame, simulationCacheKey, SimulationCacheValidationError, verifySimulationCache, verifySimulationCacheFrame, type SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import { STORAGE_DATABASE_NAME, STORAGE_SCHEMA_VERSION, STORAGE_STORES, upgradeStorageSchema } from "../storage/migrations";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
import { deleteLodCache, ensureProjectLayout, projectLayout, readContentAsset, readContentAssetRange, readLodCache, readProjectBlend, recoverProjectBlend, validateSha256, writeContentAsset, writeLodCache, writeProjectBlend, type ProjectSaveFault } from "../storage/opfs-files";
|
||||
|
||||
const scope = self as unknown as {
|
||||
onmessage: ((event: MessageEvent<StorageRequest>) => void) | null;
|
||||
@@ -724,6 +724,36 @@ async function readSimulationCache(projectId: string, cacheKey: string): Promise
|
||||
return { projectId, cacheKey, persisted: true, manifest, path: row.path, data: asset.data };
|
||||
}
|
||||
|
||||
async function readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
projectLayout(projectId);
|
||||
const row = await readSimulationRow(projectId, cacheKey);
|
||||
if (!row) throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache manifest is missing");
|
||||
const manifest = parseSimulationCacheManifest(row.manifest);
|
||||
if (simulationCacheKey(manifest) !== cacheKey) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation cache manifest key is inconsistent");
|
||||
}
|
||||
const selected = selectSimulationCacheFrame(manifest, frame);
|
||||
const asset = await readAssetRow(projectId, manifest.cacheSha256);
|
||||
if (!asset || asset.bytes !== manifest.byteLength) {
|
||||
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache payload is missing or truncated");
|
||||
}
|
||||
const data = asset.buffer ?
|
||||
asset.buffer.slice(selected.byteOffset, selected.byteOffset + selected.byteLength) :
|
||||
await readContentAssetRange(projectId, manifest.cacheSha256, selected.byteOffset, selected.byteLength, manifest.byteLength);
|
||||
await verifySimulationCacheFrame(manifest, frame, data);
|
||||
return {
|
||||
projectId,
|
||||
cacheKey,
|
||||
persisted: true,
|
||||
manifest,
|
||||
path: row.path,
|
||||
frame,
|
||||
byteOffset: selected.byteOffset,
|
||||
byteLength: selected.byteLength,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
projectLayout(projectId);
|
||||
const db = await openDatabase();
|
||||
@@ -773,6 +803,7 @@ scope.onmessage = async (event) => {
|
||||
else if (command.type === "pruneLOD") result = await pruneLOD(command.projectId, command.maxBytes);
|
||||
else if (command.type === "putSimulationCache") result = await withProjectTransaction(command.projectId, () => putSimulationCache(command.projectId, command.manifest, command.data));
|
||||
else if (command.type === "readSimulationCache") result = await readSimulationCache(command.projectId, command.cacheKey);
|
||||
else if (command.type === "readSimulationCacheFrame") result = await readSimulationCacheFrame(command.projectId, command.cacheKey, command.frame);
|
||||
else if (command.type === "listSimulationCaches") result = await listSimulationCaches(command.projectId);
|
||||
else throw new Error("Unknown storage command");
|
||||
if (result && "data" in result && result.data instanceof ArrayBuffer) scope.postMessage({ requestId: event.data.requestId, ok: true, result }, [result.data]);
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
setPBRMaterialSelected,
|
||||
} from "../three-adapter/pbr";
|
||||
import { GPUTextureStore } from "../three-adapter/texture-assets";
|
||||
import { applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject } from "../three-adapter/nonmesh";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "../three-adapter/grease-pencil";
|
||||
|
||||
const workerScope = self as unknown as {
|
||||
@@ -276,6 +276,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
const greasePencilsById = new Map((snapshot.greasePencils ?? []).map((data) => [data.id, data]));
|
||||
let greasePencilCount = 0;
|
||||
let greasePencilBlockedCount = 0;
|
||||
let greasePencilOnionStrokeCount = 0;
|
||||
for (const node of snapshot.nodes) {
|
||||
if (node.type !== "GREASE_PENCIL" || !node.visible || !node.dataId) continue;
|
||||
const data = greasePencilsById.get(node.dataId);
|
||||
@@ -288,9 +289,10 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
applyGreasePencilTransform(object, node);
|
||||
root.add(object);
|
||||
objectById.set(node.id, object);
|
||||
greasePencilOnionStrokeCount += Number(object.userData.greasePencilOnionStrokeCount ?? 0);
|
||||
greasePencilCount++;
|
||||
}
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount });
|
||||
post({ type: "snapshotStatus", nonMeshCount, nonMeshBlockedCount, greasePencilCount, greasePencilBlockedCount, greasePencilOnionStrokeCount });
|
||||
const world = snapshot.worlds.find((candidate) => candidate.id === snapshot.scenes[0]?.worldId) ?? snapshot.worlds[0];
|
||||
const sceneDefinition = snapshot.scenes.find((candidate) => candidate.id === snapshot.sceneId) ?? snapshot.scenes[0];
|
||||
scene.background = world ? new Color().setRGB(...world.color) : new Color(0x25272b);
|
||||
@@ -311,7 +313,7 @@ function setSnapshot(snapshot: SceneSnapshotIR, buffers: MeshGeometryBuffer[], n
|
||||
render();
|
||||
}
|
||||
|
||||
function setSelection(ids: string[]): void {
|
||||
function setSelection(ids: string[], elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>): void {
|
||||
const selected = new Set(ids);
|
||||
const visited = new Set<Object3D>();
|
||||
for (const [id, object] of objectById) {
|
||||
@@ -329,6 +331,15 @@ function setSelection(ids: string[]): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
const selection = new Map<string, Map<NonMeshElementKind, Set<number>>>();
|
||||
for (const element of elements) {
|
||||
const kinds = selection.get(element.dataId) ?? new Map<NonMeshElementKind, Set<number>>();
|
||||
const indices = kinds.get(element.kind) ?? new Set<number>();
|
||||
indices.add(element.index);
|
||||
kinds.set(element.kind, indices);
|
||||
selection.set(element.dataId, kinds);
|
||||
}
|
||||
if (root) applyNonMeshElementSelection(root, selection);
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -411,7 +422,7 @@ workerScope.onmessage = (event): void => {
|
||||
else if (message.type === "snapshot") setSnapshot(message.snapshot, message.geometryBuffers, message.nonMeshGeometryBuffers);
|
||||
else if (message.type === "textureAssets") applyTextureAssets(message.assets);
|
||||
else if (message.type === "resize") resize(message.width, message.height, message.pixelRatio);
|
||||
else if (message.type === "selection") setSelection(message.objectIds);
|
||||
else if (message.type === "selection") setSelection(message.objectIds, message.elements);
|
||||
else if (message.type === "interaction") {
|
||||
editMode = message.editMode;
|
||||
selectionMode = message.selectionMode;
|
||||
|
||||
@@ -366,6 +366,30 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
for (const field of ["useTemperature", "castsShadow"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Light ${field} must be boolean`);
|
||||
return;
|
||||
}
|
||||
case "setCameraProperties": {
|
||||
const camera = currentSnapshot?.cameras.find((candidate) => candidate.id === payload.dataId);
|
||||
const properties = payload.properties;
|
||||
if (!camera || !validPropertyKeys(properties, ["projection", "lensMm", "sensorWidthMm", "sensorHeightMm", "sensorFit", "shift", "near", "far", "orthoScale", "depthOfField"])) throw report("RENDER_PROPERTY_INVALID", "Camera target or properties are invalid");
|
||||
if (properties.projection !== undefined && !["PERSPECTIVE", "ORTHOGRAPHIC"].includes(properties.projection)) throw report("RENDER_PROPERTY_INVALID", "Camera projection is invalid");
|
||||
for (const [field, minimum, maximum] of [["lensMm", 0.1, 10_000], ["sensorWidthMm", 0.1, 10_000], ["sensorHeightMm", 0.1, 10_000], ["near", 0.0001, 1e9], ["far", 0.0002, 1e12], ["orthoScale", 0.0001, 1e9]] as const) {
|
||||
const value = properties[field];
|
||||
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera ${field} is outside the bounded range`);
|
||||
}
|
||||
if (properties.near !== undefined && properties.far !== undefined && properties.far <= properties.near) throw report("RENDER_PROPERTY_INVALID", "Camera far clip must exceed near clip");
|
||||
if (properties.sensorFit !== undefined && ![0, 1, 2].includes(properties.sensorFit)) throw report("RENDER_PROPERTY_INVALID", "Camera sensorFit is invalid");
|
||||
if (properties.shift !== undefined && (!Array.isArray(properties.shift) || properties.shift.length !== 2 || properties.shift.some((value) => !boundedNumber(value, -1000, 1000)))) throw report("RENDER_PROPERTY_INVALID", "Camera shift is invalid");
|
||||
if (properties.depthOfField !== undefined) {
|
||||
const dof = properties.depthOfField;
|
||||
if (!validPropertyKeys(dof, ["enabled", "focusDistance", "apertureFStop", "apertureBlades", "apertureRotation", "apertureRatio"])) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField is invalid");
|
||||
if (dof.enabled !== undefined && typeof dof.enabled !== "boolean") throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.enabled must be boolean");
|
||||
for (const [field, minimum, maximum] of [["focusDistance", 0, 1e9], ["apertureFStop", 0.01, 1000], ["apertureRotation", -Math.PI * 2, Math.PI * 2], ["apertureRatio", 0.01, 100]] as const) {
|
||||
const value = dof[field];
|
||||
if (value !== undefined && !boundedNumber(value, minimum, maximum)) throw report("RENDER_PROPERTY_INVALID", `Camera depthOfField.${field} is outside the bounded range`);
|
||||
}
|
||||
if (dof.apertureBlades !== undefined && (!Number.isSafeInteger(dof.apertureBlades) || dof.apertureBlades < 0 || dof.apertureBlades > 64)) throw report("RENDER_PROPERTY_INVALID", "Camera depthOfField.apertureBlades is outside the bounded range");
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "setWorldProperties": {
|
||||
const world = currentSnapshot?.worlds.find((candidate) => candidate.id === payload.dataId);
|
||||
const properties = payload.properties;
|
||||
|
||||
Reference in New Issue
Block a user