Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@dimforge/rapier3d-compat": "^0.19.3",
"@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.4",
"fzstd": "^0.1.1",
"gifenc": "^1.0.3",
"lucide-react": "^1.27.0",
"react": "^19.2.8",
Expand All @@ -32,7 +33,7 @@
"wasmoon": "^1.16.0"
},
"name": "studio",
"version": "2.1.0",
"version": "3.0.0",
"description": "<p align=\"center\"><a href=\"https://laravel.com\" target=\"_blank\"><img src=\"https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg\" width=\"400\" alt=\"Laravel Logo\"></a></p>",
"main": "vite.config.js",
"directories": {
Expand Down
55 changes: 43 additions & 12 deletions resources/js/studio/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { MAX_DIM, MAX_RES, buildVoxels, loadModel, voxelCost } from './model';
import { predict, record } from './estimate';
import { convertRoblox, importSummary } from './roblox';
import { fromProject, isProject, newProjectId, toProject } from './vortexProject';
import { decodeVrtx, encodeVrtx, isVrtx } from './vrtx';
import {
AMBIENT, DEFAULT_LIGHTING, DEFAULT_POINT_LIGHT, DEFAULT_SPOT_LIGHT, SUN, isLightRef,
partLightOf, partLightRef, repairLighting,
Expand Down Expand Up @@ -1021,14 +1022,24 @@ export default function App() {
};

const mapNameFrom = (fileName) => (fileName ?? '')
.replace(/\.json$/i, '')
.replace(/\.(json|vrtx)$/i, '')
.replace(/[^A-Za-z0-9_-]/g, '-')
.slice(0, 64) || 'roblox';

const importRobloxText = (text, fileName) => {
let doc;
try {
doc = JSON.parse(text);
} catch (e) {
flash(`Could not import: ${e.message ?? e}`);
return;
}
importDoc(doc, fileName);
};

const importDoc = (doc, fileName) => {
let result;
try {
const doc = JSON.parse(text);
const room = mapCap - (mapName ? partsRef.current.length : 0);
result = isProject(doc) ? fromProject(doc, room) : convertRoblox(doc, room);
} catch (e) {
Expand Down Expand Up @@ -1079,7 +1090,12 @@ export default function App() {

const importRoblox = async (file) => {
try {
importRobloxText(await file.text(), file.name);
const bytes = new Uint8Array(await file.arrayBuffer());
if (isVrtx(bytes)) {
importDoc(decodeVrtx(bytes), file.name);
} else {
importRobloxText(new TextDecoder().decode(bytes), file.name);
}
} catch (e) {
flash(`Could not read ${file.name}: ${e.message ?? e}`);
}
Expand All @@ -1096,20 +1112,35 @@ export default function App() {
if (text) importRobloxText(text, null);
};

const saveBlob = (blob, fileName) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
// Firefox cancels the download if the URL is revoked in the same tick.
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
};

const currentProject = () => toProject(
parts, groups, projectId.current ?? newProjectId(), lighting,
);

// The default: the format the current Studio saves and opens.
const download = () => {
if (!mapName) return;
const text = JSON.stringify(toProject(parts, groups, projectId.current ?? newProjectId(), lighting));
const bytes = encodeVrtx(currentProject());
saveBlob(new Blob([bytes], { type: 'application/octet-stream' }), `${mapName}.vrtx`);
};

// Kept for the older Studio and for the game, which still read the JSON.
const downloadJson = () => {
if (!mapName) return;
const text = JSON.stringify(currentProject());
if (text.length > ENGINE_MAX_BYTES) {
flash(`This map is ${(text.length / 1048576).toFixed(1)} MB, over the 10 MB the game `
+ 'accepts. Rebuild it with a lower Detail or a higher Merge angle.');
}
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${mapName}.json`;
a.click();
// Firefox cancels the download if the URL is revoked in the same tick.
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
saveBlob(new Blob([text], { type: 'application/json' }), `${mapName}.json`);
};

const createNew = (name, teamId = null) => {
Expand Down Expand Up @@ -1520,7 +1551,7 @@ export default function App() {
hasMap={!!mapName} canEdit={canEdit}
hasSelection={!!selected} hasClipboard={!!clipboard.current}
onSave={save} canSave={canSaveToServer}
onDownload={download} canDownload={!!mapName && canEdit}
onDownload={download} onDownloadJson={downloadJson} canDownload={!!mapName && canEdit}
onImportRoblox={importRoblox} onPasteRoblox={pasteRoblox}
canImport={!mapName || canEdit}
onUndo={undo} onRedo={redo}
Expand Down
9 changes: 5 additions & 4 deletions resources/js/studio/MenuBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const ClosePop = ({ onClose }) => (

export default function MenuBar({
hasMap, canEdit, hasSelection, hasClipboard,
onSave, canSave, onDownload, canDownload,
onSave, canSave, onDownload, onDownloadJson, canDownload,
onImportRoblox, onPasteRoblox, canImport,
onUndo, onRedo, onCopy, onPaste, onDuplicate, onDelete,
onSelectAll, onGroup, onUngroup,
Expand Down Expand Up @@ -109,7 +109,8 @@ export default function MenuBar({
const MENUS = [
['File', [
{ label: 'Save', shortcut: 'Ctrl+S', onClick: onSave, disabled: !canSave },
{ label: 'Download .json', onClick: onDownload, disabled: !canDownload },
{ label: 'Download .vrtx', onClick: onDownload, disabled: !canDownload },
{ label: 'Download .json (legacy)', onClick: onDownloadJson, disabled: !canDownload },
SEP,
...(mobile ? [] : [
{ label: 'Import Roblox place...', onClick: () => robloxRef.current?.click(), disabled: !canImport },
Expand Down Expand Up @@ -179,7 +180,7 @@ export default function MenuBar({
<input
ref={robloxRef}
type="file"
accept=".json,application/json"
accept=".json,.vrtx,application/json,application/octet-stream"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
Expand Down Expand Up @@ -385,7 +386,7 @@ export default function MenuBar({
<ul>
<li>Saved maps live in your anonymous session for 24 hours, counted from your last save</li>
<li>Every save also keeps a copy in this browser, listed under <b>On this device</b> on the start screen</li>
<li>Use <b>Download</b> to keep a .json copy, and upload it back anytime</li>
<li>Use <b>Download</b> to keep a .vrtx copy (or .json for older tools), and upload either back anytime</li>
</ul>
</div>
</>
Expand Down
8 changes: 5 additions & 3 deletions resources/js/studio/StartScreen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import MoveMap from './MoveMap';
import UserMenu from './UserMenu';
import Clothing from './clothing/Clothing';
import { fromProject, isProject } from './vortexProject';
import { decodeVrtx, isVrtx } from './vrtx';
import { setTheme, useTheme } from './theme';
import { APP_VERSION } from './version';

Expand Down Expand Up @@ -145,7 +146,8 @@ export default function StartScreen({
e.target.value = '';
if (!file) return;
try {
const doc = JSON.parse(await file.text());
const bytes = new Uint8Array(await file.arrayBuffer());
const doc = isVrtx(bytes) ? decodeVrtx(bytes) : JSON.parse(new TextDecoder().decode(bytes));
let parts = doc;
let groups = null;
let carried = null;
Expand All @@ -157,7 +159,7 @@ export default function StartScreen({
throw new Error('not a map: expected a Vortex project or a JSON array of parts');
}
const name = file.name
.replace(/\.json$/i, '')
.replace(/\.(json|vrtx)$/i, '')
.replace(/[^A-Za-z0-9_-]/g, '-')
.slice(0, 64) || 'uploaded';
onUpload(name, parts, groups, carried);
Expand Down Expand Up @@ -529,7 +531,7 @@ export default function StartScreen({
<input
ref={fileRef}
type="file"
accept=".json,application/json"
accept=".json,.vrtx,application/json,application/octet-stream"
style={{ display: 'none' }}
onChange={onFile}
/>
Expand Down
5 changes: 3 additions & 2 deletions resources/js/studio/vortexProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,9 @@ function lightingToProject(lighting) {
sun_color: colorTo(lit.sun_color),
sun_illuminance: num(lit.sun_illuminance, DEFAULT_ILLUMINANCE),
sun_shadow_maps_enabled: lit.sun_shadow_maps_enabled !== false,
// Where the sun comes from is ours and has no field here, and a document with a field the
// format does not know is refused whole, so it stays out and lives in the map instead.
// Since Studio v0.2.1 the sun direction is a field of its own, a quaternion, so it rides
// in the document now instead of only living in the map.
sun_rotation: quatFrom(lit.sun_rotation ?? DEFAULT_LIGHTING.sun_rotation),
};
}

Expand Down
Loading