Simplify viewer. Move old one to editor

This commit is contained in:
Diego Marcos Segura
2025-06-02 02:55:51 -07:00
parent 6a5b361181
commit 1051e066af
7 changed files with 1037 additions and 642 deletions
+695
View File
@@ -0,0 +1,695 @@
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>Forge • Viewer</title>
<style>
body {
margin: 0;
}
#canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
outline: none; /* Remove default focus outline */
}
#main-gui {
position: absolute;
top: 5px;
right: 5px;
}
#second-gui {
position: absolute;
top: 5px;
left: 5px;
}
#progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: rgba(0, 0, 0, 0.1);
z-index: 1000;
display: none;
}
#progress-fill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
width: 0%;
transition: width 0.3s ease;
}
</style>
</head>
<body>
<div id="progress-bar">
<div id="progress-fill"></div>
</div>
<script type="importmap">
{
"imports": {
"three": "/examples/js/vendor/three/build/three.module.js",
"three/addons/": "/examples/js/vendor/three/examples/jsm/",
"@forge-gfx/forge": "/dist/forge.module.js",
"lil-gui": "../node_modules/lil-gui/dist/lil-gui.esm.js"
}
}
</script>
<canvas id="canvas" tabindex="0"></canvas>
<div id="main-gui"></div>
<div id="second-gui"></div>
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat" multiple="true" style="display: none;" />
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "lil-gui";
import { constructGrid, ForgeControls, ForgeRenderer, SplatMesh, textSplats, dyno, transcodeSpz } from "@forge-gfx/forge";
import { getAssetFileURL } from "/examples/js/get-asset-url.js";
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 1);
const canvas = document.getElementById("canvas");
const renderer = new THREE.WebGLRenderer({ canvas });
const forge = new ForgeRenderer({ renderer });
function handleResize() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
handleResize();
window.addEventListener("resize", handleResize);
const frame = new THREE.Group();
frame.quaternion.set(1, 0, 0, 0);
scene.add(frame);
// Keep track of original file bytes for each loaded splat mesh
const inputs = [];
const grid = new SplatMesh({
constructSplats: (splats) => constructGrid({
splats,
extents: new THREE.Box3(new THREE.Vector3(-10, -10, -10), new THREE.Vector3(10, 10, 10)),
}),
});
grid.opacity = 0;
scene.add(grid);
const gui = new GUI({
title: "Settings",
container: document.getElementById("main-gui")
}).close();
const secondGui = new GUI({
title: "Splats",
container: document.getElementById("second-gui")
}).close();
const controls = new ForgeControls({ canvas });
// Setup mouse controls to orbit the camera around
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enabled = false;
orbitControls.target.set(0, 0, 0);
orbitControls.minDistance = 0.1;
orbitControls.maxDistance = 10;
const fileInput = document.querySelector("#file-input");
fileInput.onchange = (event) => {
loadFiles([...event.target.files]);
};
const guiOptions = {
highDevicePixel: false,
resetOnLoad: true,
loadOffset: 0,
openCv: true,
autoRotate: true,
orbit: false,
reversePointerDir: false,
reversePointerSlide: false,
openFiles: () => {
fileInput.click();
},
loadFromText: "",
loadFromTextAction: () => {
if (guiOptions.loadFromText.trim()) {
const urls = parseURLsFromText(guiOptions.loadFromText);
if (urls.length > 0) {
loadFiles(urls);
guiOptions.loadFromText = ""; // Clear after loading
} else {
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, or .ksplat");
}
}
},
resetPose: () => {
camera.position.set(0, 0, 1);
camera.quaternion.set(0, 0, 0, 1);
camera.fov = 75;
resetFrameQuaternion();
camera.updateProjectionMatrix();
},
};
function resetFrameQuaternion() {
if (guiOptions.openCv) {
frame.quaternion.set(1, 0, 0, 0);
} else {
frame.quaternion.set(0, 0, 0, 1);
}
}
// Parse URL query parameters for URLs to load
function parseURLsFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
const urls = [];
// Get all "url" parameters
for (const [key, value] of urlParams.entries()) {
if (key === "url") {
urls.push(value);
}
}
return urls;
}
// Parse and apply camera parameters from URL
function applyCameraFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
// Position parameters
const x = urlParams.get('x');
const y = urlParams.get('y');
const z = urlParams.get('z');
if (x !== null || y !== null || z !== null) {
camera.position.set(
x !== null ? parseFloat(x) : camera.position.x,
y !== null ? parseFloat(y) : camera.position.y,
z !== null ? parseFloat(z) : camera.position.z
);
}
// Quaternion parameters
const qx = urlParams.get('qx');
const qy = urlParams.get('qy');
const qz = urlParams.get('qz');
const qw = urlParams.get('qw');
if (qx !== null || qy !== null || qz !== null || qw !== null) {
camera.quaternion.set(
qx !== null ? parseFloat(qx) : camera.quaternion.x,
qy !== null ? parseFloat(qy) : camera.quaternion.y,
qz !== null ? parseFloat(qz) : camera.quaternion.z,
qw !== null ? parseFloat(qw) : camera.quaternion.w
).normalize();
}
}
// Apply camera parameters from URL
guiOptions.resetPose();
applyCameraFromQuery();
const cameraFolder = gui.addFolder("Camera");
cameraFolder.add(camera.position, "x", -10, 10, 0.01).name("X").listen();
cameraFolder.add(camera.position, "y", -10, 10, 0.01).name("Y").listen();
cameraFolder.add(camera.position, "z", -10, 10, 0.01).name("Z").listen();
const rotX = cameraFolder.add(camera.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
const rotY = cameraFolder.add(camera.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
const rotZ = cameraFolder.add(camera.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
cameraFolder.add(camera, "fov", 1, 179, 1).name("Fov Y degrees").listen().onChange((value) => {
camera.updateProjectionMatrix();
});
// Progress bar functions
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
function showProgress() {
progressBar.style.display = "block";
progressFill.style.width = "0%";
}
function updateProgress(progress) {
progressFill.style.width = `${Math.min(100, Math.max(0, progress * 100))}%`;
}
function hideProgress() {
progressBar.style.display = "none";
}
// Calculate progress when total size is unknown
function calculateUnknownProgress(loadedBytes) {
const midpointMB = 10 * 1024 * 1024; // 10 MB in bytes
return loadedBytes / (loadedBytes + midpointMB);
}
async function fetchWithProgress(url) {
try {
const response = await fetch(url, {
mode: "cors",
cache: "default",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const contentLength = response.headers.get('content-length');
const total = contentLength ? parseInt(contentLength, 10) : null;
const reader = response.body.getReader();
const chunks = [];
let loadedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loadedBytes += value.length;
// Calculate progress
let progress;
if (total) {
progress = loadedBytes / total;
} else {
progress = calculateUnknownProgress(loadedBytes);
}
updateProgress(progress);
}
// Combine all chunks into a single Uint8Array
const allChunks = new Uint8Array(loadedBytes);
let offset = 0;
for (const chunk of chunks) {
allChunks.set(chunk, offset);
offset += chunk.length;
}
return allChunks.buffer;
} catch (error) {
// Hide progress bar on error
hideProgress();
// Detect CORS issues
if (error instanceof TypeError && (
error.message.includes('Failed to fetch') ||
error.message.includes('Network request failed') ||
error.message.includes('CORS') ||
error.message.includes('cross-origin')
)) {
alert(`CORS Error: Cannot load file from ${url}\n\nThe server doesn't allow cross-origin requests. The file needs to be:\n• Served from the same domain, or\n• The server must include CORS headers like:\n Access-Control-Allow-Origin: *`);
} else if (error.message.includes('HTTP error! status:')) {
const status = error.message.match(/status: (\d+)/)?.[1];
alert(`HTTP Error ${status}: Cannot load file from ${url}\n\n${error.message}`);
} else {
alert(`Error loading file from ${url}:\n\n${error.message}`);
}
throw error; // Re-throw so loadFiles can handle it
}
}
async function loadFiles(splatFiles) {
if (guiOptions.resetOnLoad) {
const toRemove = frame.children.filter((child) => child instanceof SplatMesh);
for (const child of toRemove) {
frame.remove(child);
}
// Clear original file bytes when resetting
inputs.length = 0;
splatsFolder.foldersRecursive().forEach((child) => child.destroy());
}
guiOptions.openCv = true;
guiOptions.autoRotate = false;
resetFrameQuaternion();
writeOptions.filename = "";
// Show progress bar if loading from URLs
const hasUrls = splatFiles.some(file => typeof file === "string");
if (hasUrls) {
showProgress();
}
let index = 0;
for (const splatFile of splatFiles) {
try {
let fileBytes;
let fileName;
// Check if splatFile is a URL string or a File object
if (typeof splatFile === "string") {
// It's a URL, fetch it
console.log(`Fetching ${splatFile}...`);
fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
// Extract filename from URL
fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";
} else {
// It's a File object
fileBytes = new Uint8Array(await splatFile.arrayBuffer());
fileName = splatFile.name;
}
if (writeOptions.filename === "") {
writeOptions.filename = fileName.split(".")[0];
}
const splatMesh = new SplatMesh({ fileBytes: fileBytes.slice(), fileName });
const translate = guiOptions.loadOffset * index
splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
splatMesh.enableWorldToView = true;
splatMesh.worldModifier = makeWorldModifier(splatMesh);
await splatMesh.initialized;
inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
frame.add(splatMesh);
console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
const splatFolder = splatsFolder.addFolder(fileName).close();
splatFolder.add(splatMesh, "opacity", 0, 1, 0.01).name("Opacity").listen();
splatFolder.add(splatMesh.position, "x", -10, 10, 0.01).name("X").listen();
splatFolder.add(splatMesh.position, "y", -10, 10, 0.01).name("Y").listen();
splatFolder.add(splatMesh.position, "z", -10, 10, 0.01).name("Z").listen();
splatFolder.add(splatMesh.scale, "x", 0.01, 4, 0.01).name("Scale").listen().onChange((value) => {
splatMesh.scale.setScalar(value);
});
splatFolder.add(splatMesh.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
splatFolder.add(splatMesh.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
splatFolder.add(splatMesh.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
splatFolder.add(splatMesh, "maxSh", 0, 3, 1).name("Max SH").listen().onChange(() => {
splatMesh.updateGenerator();
});
} catch (error) {
console.error("Error loading splat file:", error);
}
index += 1;
}
// Hide progress bar when done
if (hasUrls) {
hideProgress();
}
// Restore focus to canvas for keyboard controls
canvas.focus();
}
secondGui.add(guiOptions, "resetOnLoad").name("Reset on load");
secondGui.add(guiOptions, "loadOffset", -2, 2, 0.01).name("Loading offset");
secondGui.add(guiOptions, "openFiles").name("Select Files");
secondGui.add(guiOptions, "loadFromText").name("Paste URL(s) here");
secondGui.add(guiOptions, "loadFromTextAction").name("Load from URL(s)");
cameraFolder.add(guiOptions, "resetPose").name("Reset pose");
cameraFolder.add(guiOptions, "openCv").name("OpenCV coordinates")
.listen()
.onChange(resetFrameQuaternion);
cameraFolder.add(guiOptions, "autoRotate").name("Auto rotate")
.listen()
.onChange((value) => {
if (value) {
frame.rotation.y = 0;
}
});
cameraFolder.add(guiOptions, "orbit").name("Orbit controls")
.listen()
.onChange((value) => {
orbitControls.enabled = value;
canvas.focus();
rotX.enable(!value);
rotY.enable(!value);
rotZ.enable(!value);
});
// .setValue(false);
cameraFolder.add(guiOptions, "reversePointerDir").name("Reverse ptr direction")
.onChange((value) => {
controls.pointerControls.reverseRotate = value;
controls.pointerControls.reverseScroll = value;
});
cameraFolder.add(guiOptions, "reversePointerSlide").name("Reverse ptr slide")
.onChange((value) => {
controls.pointerControls.reverseSlide = value;
controls.pointerControls.reverseSwipe = value;
});
gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
console.log("Render size", canvas.width, canvas.height);
});
gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
const normalColor = dyno.dynoBool(false);
gui.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
const splatsFolder = secondGui.addFolder("Files");
const editFolder = gui.addFolder("Edit Splats").close();
editFolder.add(forge, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
editFolder.add(forge, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
function updateFrameSplats() {
frame.children.forEach((child) => {
if (child instanceof SplatMesh) {
child.updateVersion();
}
});
}
const clipEnable = dyno.dynoBool(false);
const clipMinX = dyno.dynoFloat(-5);
const clipMaxX = dyno.dynoFloat(5);
const clipMinY = dyno.dynoFloat(-5);
const clipMaxY = dyno.dynoFloat(5);
const clipMinZ = dyno.dynoFloat(-5);
const clipMaxZ = dyno.dynoFloat(5);
editFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
editFolder.add(clipMinX, "value", -5, 5, 0.01).name("Min X").onChange(() => updateFrameSplats());
editFolder.add(clipMaxX, "value", -5, 5, 0.01).name("Max X").onChange(() => updateFrameSplats());
editFolder.add(clipMinY, "value", -5, 5, 0.01).name("Min Y").onChange(() => updateFrameSplats());
editFolder.add(clipMaxY, "value", -5, 5, 0.01).name("Max Y").onChange(() => updateFrameSplats());
editFolder.add(clipMinZ, "value", -5, 5, 0.01).name("Min Z").onChange(() => updateFrameSplats());
editFolder.add(clipMaxZ, "value", -5, 5, 0.01).name("Max Z").onChange(() => updateFrameSplats());
function makeWorldModifier(mesh) {
const context = mesh.context;
return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
// Color by world normal if it's enabled
let worldNormal = dyno.gsplatNormal(gsplat);
let { rgb, center, opacity } = dyno.splitGsplat(gsplat).outputs;
// Compute vector from view to object coordinate in gsplat
const worldToView = context.worldToView;
const viewGsplat = worldToView.applyGsplat(gsplat);
const viewCenter = dyno.splitGsplat(viewGsplat).outputs.center;
let normal = dyno.gsplatNormal(viewGsplat);
const dot = dyno.dot(viewCenter, normal);
const sameDir = dyno.greaterThanEqual(dot, dyno.dynoConst("float", 0));
normal = dyno.select(sameDir, dyno.neg(worldNormal), worldNormal);
const normalRgb = dyno.add(dyno.mul(normal, dyno.dynoConst("float", 0.5)), dyno.dynoConst("float", 0.5));
rgb = dyno.select(normalColor, normalRgb, rgb);
// Zero out opacity if outside clip bounds
const { x, y, z } = dyno.split(center).outputs;
const xWithin = dyno.and(dyno.greaterThanEqual(x, clipMinX), dyno.lessThanEqual(x, clipMaxX));
const yWithin = dyno.and(dyno.greaterThanEqual(y, clipMinY), dyno.lessThanEqual(y, clipMaxY));
const zWithin = dyno.and(dyno.greaterThanEqual(z, clipMinZ), dyno.lessThanEqual(z, clipMaxZ));
const within = dyno.and(dyno.and(xWithin, yWithin), zWithin);
const splatEnabled = dyno.or(
dyno.not(clipEnable),
within,
);
opacity = dyno.select(splatEnabled, opacity, dyno.dynoConst("float", 0));
gsplat = dyno.combineGsplat({
gsplat,
rgb,
opacity,
})
return { gsplat };
});
}
const writeFolder = secondGui.addFolder("Write Gsplats").close();
const writeOptions = {
filename: "gsplats",
trimOpacity: true,
trimOpacityThreshold: 0,
maxSh: 3,
fractionalBits: 12,
writeSpz: async () => {
const transcodeInputs = inputs.map((input) => {
return {
fileBytes: new Uint8Array(input.fileBytes),
pathOrUrl: input.pathOrUrl,
transform: {
translate: input.object.position.toArray(),
quaternion: input.object.quaternion.toArray(),
scale: (input.object.scale.x + input.object.scale.y + input.object.scale.z) / 3,
},
};
});
const clipXyz = clipEnable.value ? {
min: [clipMinX.value, clipMinY.value, clipMinZ.value],
max: [clipMaxX.value, clipMaxY.value, clipMaxZ.value],
} : undefined;
const maxSh = writeOptions.maxSh;
const fractionalBits = writeOptions.fractionalBits;
const opacityThreshid = writeOptions.trimOpacity ? writeOptions.trimOpacityThreshold : undefined;
debugger;
const transcode = await transcodeSpz({ inputs: transcodeInputs, maxSh, clipXyz, fractionalBits, opacityThreshold: opacityThreshid });
if (transcode.clippedCount && transcode.clippedCount > 0) {
console.log(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
alert(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
}
const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = writeOptions.filename + ".spz";
a.click();
URL.revokeObjectURL(url);
},
};
writeFolder.add(writeOptions, "filename").name("Filename").listen();
writeFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
writeFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
writeFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
writeFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
writeFolder.add(writeOptions, "writeSpz").name("Create .spz and download");
function makeInstructions() {
const instructions = textSplats({
text: "Drag and Drop\na Gsplat file\nhere to view",
textAlign: "center",
fontSize: 64,
objectScale: 0.1 / 64,
});
instructions.quaternion.set(1, 0, 0, 0);
instructions.enableWorldToView = true;
instructions.worldModifier = makeWorldModifier(instructions);
instructions.updateGenerator();
return instructions;
}
const instructions = makeInstructions();
frame.add(instructions);
// Load URLs from query parameters if any
const urlsToLoad = parseURLsFromQuery();
if (urlsToLoad.length > 0) {
console.log(`Loading ${urlsToLoad.length} URLs from query parameters:`, urlsToLoad);
loadFiles(urlsToLoad);
}
// Focus the canvas initially for keyboard controls
canvas.focus();
// Drag and drop functionality
canvas.addEventListener("dragover", (e) => {
e.preventDefault();
canvas.style.opacity = "0.5";
});
canvas.addEventListener("dragleave", (e) => {
e.preventDefault();
canvas.style.opacity = "1";
});
canvas.addEventListener("drop", async (e) => {
e.preventDefault();
canvas.style.opacity = "1";
// Check for text data first
const textData = e.dataTransfer.getData('text/plain');
if (textData) {
const urls = parseURLsFromText(textData);
if (urls.length > 0) {
console.log(`Found ${urls.length} URLs in dropped text:`, urls);
loadFiles(urls);
return;
}
}
// Fallback to file handling
const files = Array.from(e.dataTransfer.files);
console.log("Dropped files:", files.length, files);
const splatFiles = files.filter((file) =>
file.name.toLowerCase().endsWith('.ply') ||
file.name.toLowerCase().endsWith('.spz') ||
file.name.toLowerCase().endsWith('.splat') ||
file.name.toLowerCase().endsWith('.ksplat')
);
if (splatFiles.length > 0) {
loadFiles(splatFiles);
}
});
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
function parseURLsFromText(text) {
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat"];
const urls = [];
// Split by lines, commas, and semicolons
const parts = text.trim().split(/[\r\n,;]+/);
for (const part of parts) {
const trimmed = part.trim();
// Check if part looks like a URL
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
// Check if it has a supported extension
const hasValidExtension = supportedExtensions.some(ext =>
trimmed.toLowerCase().includes(ext)
);
if (hasValidExtension) {
urls.push(trimmed);
}
}
}
return urls;
}
let lastTime = null;
renderer.setAnimationLoop(function animate(time) {
const deltaTime = time - (lastTime || time);
lastTime = time;
if (guiOptions.autoRotate) {
frame.rotation.y += deltaTime / 5000;
}
if (guiOptions.orbit) {
orbitControls.update();
} else {
controls.update(camera);
}
renderer.render(scene, camera);
});
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5441 0.789888C15.11 0.789888 15.5943 0.991228 15.997 1.39391C16.3997 1.79659 16.6014 2.28124 16.6021 2.84788L16.6021 4.90587C16.6021 5.19742 16.5033 5.44163 16.3057 5.63851C16.1081 5.83539 15.8639 5.93418 15.5731 5.93486C15.2822 5.93555 15.0376 5.83676 14.8394 5.63851C14.6411 5.44026 14.5427 5.19604 14.5441 4.90587L14.5441 2.84788L2.19612 2.84788L2.19612 4.90587C2.19612 5.19741 2.09734 5.44163 1.89977 5.63851C1.70221 5.83539 1.45799 5.93417 1.16713 5.93486C0.876265 5.93555 0.631708 5.83676 0.433455 5.63851C0.235202 5.44026 0.136761 5.19604 0.138133 4.90587L0.138133 2.84788C0.138133 2.28193 0.339473 1.79727 0.742153 1.39391C1.14483 0.990541 1.62949 0.789201 2.19612 0.789887L14.5441 0.789888ZM9.39909 13.2922L11.3285 11.3628C11.5343 11.157 11.7785 11.0582 12.0611 11.0665C12.3437 11.0747 12.5883 11.182 12.7948 11.3885C12.9834 11.5943 13.0822 11.8344 13.0911 12.1088C13.1 12.3832 13.0013 12.6233 12.7948 12.8291L9.09039 16.5335C8.98749 16.6364 8.87601 16.7091 8.75596 16.7517C8.63592 16.7942 8.50729 16.8158 8.37009 16.8165C8.23289 16.8172 8.10427 16.7956 7.98422 16.7517C7.86417 16.7078 7.75269 16.635 7.6498 16.5335L3.94541 12.8291C3.73962 12.6233 3.64083 12.3832 3.64906 12.1088C3.6573 11.8344 3.75608 11.5943 3.94541 11.3885C4.15121 11.1827 4.39543 11.0754 4.67806 11.0665C4.96069 11.0575 5.20525 11.1563 5.41173 11.3628L7.3411 13.2922L7.3411 5.93486C7.3411 5.64331 7.43988 5.39875 7.63745 5.20119C7.83502 5.00362 8.07923 4.90518 8.37009 4.90587C8.66096 4.90655 8.90517 5.00534 9.10274 5.20222C9.3003 5.3991 9.39909 5.64331 9.39909 5.93486L9.39909 13.2922Z" fill="#F46A71"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="55" height="77" viewBox="0 0 55 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.364746 41.3045H54.1078L52.0792 48.4476C34.7792 48.5904 31.004 69.8796 47.6276 71.7945C47.6276 71.7945 47.6276 71.7945 46.6639 76.3802L8.31542 76.4032L8.31542 67.6169C8.31542 67.6169 22.9457 67.2167 20.3691 59.6814C17.7925 52.146 5.23803 57.1973 0.364746 41.3045Z" fill="#C44A50"/>
<path d="M28.0834 0.204662C45.5362 10.0241 33.3218 22.3397 38.5056 24.9169C43.6894 27.4941 43.9288 17.8826 43.9288 17.8826C47.6245 24.2738 48.8106 30.0871 45.4387 37.6978H14.4434C10.7441 32.0044 13.7014 22.8095 20.77 17.7636C29.2993 11.5575 32.6131 7.60866 28.0834 0.204662Z" fill="#C44A50"/>
</svg>

After

Width:  |  Height:  |  Size: 689 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

+314 -642
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.54847 16.1252C1.98252 16.1252 1.49821 15.9239 1.09553 15.5212C0.692847 15.1185 0.491165 14.6339 0.490479 14.0673V12.0093C0.490479 11.7177 0.589262 11.4735 0.786829 11.2766C0.984396 11.0797 1.22861 10.981 1.51947 10.9803C1.81034 10.9796 2.05489 11.0784 2.25315 11.2766C2.4514 11.4749 2.54984 11.7191 2.54847 12.0093V14.0673H14.8964V12.0093C14.8964 11.7177 14.9952 11.4735 15.1928 11.2766C15.3903 11.0797 15.6345 10.981 15.9254 10.9803C16.2163 10.9796 16.4608 11.0784 16.6591 11.2766C16.8573 11.4749 16.9558 11.7191 16.9544 12.0093V14.0673C16.9544 14.6332 16.7531 15.1179 16.3504 15.5212C15.9477 15.9246 15.463 16.1259 14.8964 16.1252H2.54847ZM7.69344 3.62296L5.76408 5.55232C5.55828 5.75812 5.31406 5.8569 5.03143 5.84867C4.7488 5.84044 4.50424 5.73308 4.29776 5.5266C4.10911 5.3208 4.01033 5.0807 4.00141 4.8063C3.99249 4.5319 4.09127 4.2918 4.29776 4.086L8.00214 0.381622C8.10504 0.278722 8.21652 0.206007 8.33656 0.163475C8.45661 0.120943 8.58524 0.0993346 8.72244 0.0986486C8.85964 0.0979626 8.98826 0.119571 9.10831 0.163475C9.22836 0.207379 9.33983 0.280094 9.44273 0.381622L13.1471 4.086C13.3529 4.2918 13.4517 4.5319 13.4435 4.8063C13.4352 5.0807 13.3365 5.3208 13.1471 5.5266C12.9413 5.73239 12.6971 5.83975 12.4145 5.84867C12.1318 5.85759 11.8873 5.75881 11.6808 5.55232L9.75143 3.62296V10.9803C9.75143 11.2718 9.65265 11.5164 9.45508 11.7139C9.25752 11.9115 9.0133 12.0099 8.72244 12.0093C8.43158 12.0086 8.18736 11.9098 7.98979 11.7129C7.79223 11.516 7.69344 11.2718 7.69344 10.9803V3.62296Z" fill="#818181"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -142,6 +142,7 @@
<li><a href="/examples/dynamic-lighting/">Dynamic Lighting</a></li>
<li><a href="/examples/particle-animation/">Particle Animation</a></li>
<li><a href="/examples/particle-simulation/">Particle Simulation</a></li>
<li><a href="/examples/editor/">Editor</a></li>
<li><a href="/examples/viewer/">Viewer</a></li>
</ul>
</body>