Initial commit
This commit is contained in:
commit
d78e72fc49
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
11
README.md
Normal file
11
README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Visualizer2
|
||||||
|
|
||||||
|
Narzędzie do wizualizacji plików scenerii Train Driver 2.
|
||||||
|
Przed załadowaniem pliku scenerii, najpierw przekonwertuj go do lżejszego formatu używając skryptu `tools/scp.py`.
|
||||||
|
|
||||||
|
A tool for visualizing Train Driver 2 scenery files.
|
||||||
|
Before loading a scenery file, make sure to first convert it to lightweight format using `tools/scp.py` script.
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
masuo / izawartka
|
||||||
17558
package-lock.json
generated
Normal file
17558
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "visualizer2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"-": "^0.0.1",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-scripts": "^5.0.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
20
public/index.html
Normal file
20
public/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<title>Visualizer2</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin=""/>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
src/components/App.css
Normal file
15
src/components/App.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.App {
|
||||||
|
display: flex;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu-split {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
34
src/components/App.js
Normal file
34
src/components/App.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Map from './map/Map';
|
||||||
|
import Toolbar from './toolbar/Toolbar';
|
||||||
|
import MainContext from '../contexts/MainContext';
|
||||||
|
import './App.css';
|
||||||
|
import SideMenu from './sidemenu/SideMenu';
|
||||||
|
import SettingsManager from './SettingsManager';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [scenery, setScenery] = useState(null);
|
||||||
|
const [sideMenuOpen, setSideMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsManager>
|
||||||
|
<MainContext.Provider value={{
|
||||||
|
scenery, setScenery,
|
||||||
|
isLoading, setIsLoading,
|
||||||
|
sideMenuOpen, setSideMenuOpen
|
||||||
|
}}>
|
||||||
|
<div className="App">
|
||||||
|
<Toolbar />
|
||||||
|
<div className='side-menu-split'>
|
||||||
|
<Map />
|
||||||
|
<SideMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainContext.Provider>
|
||||||
|
</SettingsManager>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
30
src/components/SettingsManager.js
Normal file
30
src/components/SettingsManager.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import SettingsContext from "../contexts/SettingsContext";
|
||||||
|
import Constants from "../helpers/constants";
|
||||||
|
|
||||||
|
export default function SettingsManager(props) {
|
||||||
|
const [ layers, setLayers ] = useState([]);
|
||||||
|
const [ settingsLoaded, setSettingsLoaded ] = useState(false);
|
||||||
|
|
||||||
|
const loadLayersSettings = useCallback(() => {
|
||||||
|
setLayers(Constants.layers.filter(layer => layer.default === true).map(layer => layer.id).reduce((acc, layerId) => {
|
||||||
|
acc[layerId] = true;
|
||||||
|
return acc;
|
||||||
|
}, {}));
|
||||||
|
}, [setLayers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLayersSettings();
|
||||||
|
setSettingsLoaded(true);
|
||||||
|
}, [loadLayersSettings]);
|
||||||
|
|
||||||
|
if (!settingsLoaded) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{
|
||||||
|
layers, setLayers
|
||||||
|
}}>
|
||||||
|
{ props.children }
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/map/Map.css
Normal file
36
src/components/map/Map.css
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.map {
|
||||||
|
flex: 1;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg path.track {
|
||||||
|
stroke-width: 1.44px;
|
||||||
|
stroke: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg text {
|
||||||
|
font-size: 2px;
|
||||||
|
text-shadow: 0 0 16px #000;
|
||||||
|
fill: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg text.switch-name {
|
||||||
|
fill: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg text.isolation-id {
|
||||||
|
fill: #ffa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg g.track-object circle {
|
||||||
|
fill: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg g.track-object text {
|
||||||
|
fill: #eee;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map svg g.track-object:hover text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
31
src/components/map/Map.js
Normal file
31
src/components/map/Map.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useContext, useMemo } from 'react';
|
||||||
|
import MainContext from '../../contexts/MainContext';
|
||||||
|
import ZoomPanWrapper from './ZoomPanWrapper';
|
||||||
|
import './Map.css';
|
||||||
|
import SceneryRoot from './SceneryRoot';
|
||||||
|
|
||||||
|
export default function Map(props) {
|
||||||
|
const {scenery} = useContext(MainContext);
|
||||||
|
|
||||||
|
const sceneryRoot = useMemo(() => {
|
||||||
|
if (!scenery) return null;
|
||||||
|
const bounds = scenery.getBounds();
|
||||||
|
const width = (bounds.maxX - bounds.minX);
|
||||||
|
const height = (bounds.maxZ - bounds.minZ);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ZoomPanWrapper
|
||||||
|
contentWidth={width}
|
||||||
|
contentHeight={height}
|
||||||
|
>
|
||||||
|
<SceneryRoot />
|
||||||
|
</ZoomPanWrapper>
|
||||||
|
);
|
||||||
|
}, [scenery]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='map'>
|
||||||
|
{sceneryRoot}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/map/ObjectsRenderQueue.js
Normal file
34
src/components/map/ObjectsRenderQueue.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import TrackRenderer from './object-renderers/TrackRenderer';
|
||||||
|
import SwitchNameRenderer from './object-renderers/SwitchNameRenderer';
|
||||||
|
import IsolationIdRenderer from './object-renderers/IsolationIdRenderer';
|
||||||
|
import TrackObjectRenderer from './object-renderers/TrackObjectRenderer';
|
||||||
|
|
||||||
|
const ObjectRendererQueue = [
|
||||||
|
{
|
||||||
|
'name': 'tracks',
|
||||||
|
'category': 'tracks',
|
||||||
|
'renderer': TrackRenderer,
|
||||||
|
'cond': (settings) => settings.layers['tracks']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'isolations-ids',
|
||||||
|
'category': 'tracks',
|
||||||
|
'renderer': IsolationIdRenderer,
|
||||||
|
'cond': (settings) => settings.layers['isolations-ids']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'switches-names',
|
||||||
|
'category': 'switches',
|
||||||
|
'type': 'Switch',
|
||||||
|
'renderer': SwitchNameRenderer,
|
||||||
|
'cond': (settings) => settings.layers['switches-names']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'track-objects',
|
||||||
|
'category': 'track-objects',
|
||||||
|
'renderer': TrackObjectRenderer,
|
||||||
|
'cond': (settings) => settings.layers['track-objects']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export default ObjectRendererQueue;
|
||||||
34
src/components/map/SceneryRoot.js
Normal file
34
src/components/map/SceneryRoot.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
import MainContext from "../../contexts/MainContext";
|
||||||
|
import ObjectsRenderQueue from "./ObjectsRenderQueue";
|
||||||
|
import SettingsContext from "../../contexts/SettingsContext";
|
||||||
|
|
||||||
|
export default function SceneryRoot(props) {
|
||||||
|
const { scenery } = useContext(MainContext);
|
||||||
|
const settings = useContext(SettingsContext);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if(!scenery) return null;
|
||||||
|
|
||||||
|
return ObjectsRenderQueue.map((queueItem) => {
|
||||||
|
const { name, category, type, types, cond} = queueItem;
|
||||||
|
const RendererComponent = queueItem.renderer;
|
||||||
|
if(cond && !cond(settings)) return [null];
|
||||||
|
|
||||||
|
const objects = scenery.objects[category] ?? null;
|
||||||
|
if(!objects) return [null];
|
||||||
|
|
||||||
|
return Object.values(objects).map((object) => {
|
||||||
|
if((type !== undefined && type !== object.type) || (types !== undefined && !types.includes(object.type))) return null;
|
||||||
|
|
||||||
|
return <RendererComponent key={`${name}-${object.id}`} object={object} />;
|
||||||
|
});
|
||||||
|
}).flat();
|
||||||
|
}, [scenery, settings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className='scenery-root'>
|
||||||
|
{content}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/map/ZoomPanWrapper.css
Normal file
11
src/components/map/ZoomPanWrapper.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.zoom-pan-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-pan-wrapper svg {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-pan-wrapper svg:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
79
src/components/map/ZoomPanWrapper.js
Normal file
79
src/components/map/ZoomPanWrapper.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import './ZoomPanWrapper.css';
|
||||||
|
import Constants from '../../helpers/constants';
|
||||||
|
|
||||||
|
export default function ZoomPanWrapper({
|
||||||
|
contentWidth = 1000,
|
||||||
|
contentHeight = 1000,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: contentWidth, h: contentHeight });
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
const isPanning = useRef(false);
|
||||||
|
const panStart = useRef({ x: 0, y: 0 });
|
||||||
|
const vbStart = useRef(viewBox);
|
||||||
|
|
||||||
|
const onWheel = (e) => {
|
||||||
|
const { x, y, w, h } = viewBox;
|
||||||
|
const svgRect = svgRef.current.getBoundingClientRect();
|
||||||
|
const offsetX = e.clientX - svgRect.left;
|
||||||
|
const offsetY = e.clientY - svgRect.top;
|
||||||
|
|
||||||
|
// mouse position in svg coordinates
|
||||||
|
const mx = (offsetX / svgRect.width) * w + x;
|
||||||
|
const my = (offsetY / svgRect.height) * h + y;
|
||||||
|
|
||||||
|
// zoom factor
|
||||||
|
const scale = 1.0 + e.deltaY * Constants.map.zoomSensitivity;
|
||||||
|
const newW = w * scale;
|
||||||
|
const newH = h * scale;
|
||||||
|
|
||||||
|
// adjust x,y so zoom centers at mouse
|
||||||
|
const newX = mx - (offsetX / svgRect.width) * newW;
|
||||||
|
const newY = my - (offsetY / svgRect.height) * newH;
|
||||||
|
|
||||||
|
setViewBox({ x: newX, y: newY, w: newW, h: newH });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isPanning.current = true;
|
||||||
|
panStart.current = { x: e.clientX, y: e.clientY };
|
||||||
|
vbStart.current = viewBox;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!isPanning.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = ((e.clientX - panStart.current.x) / svgRef.current.clientWidth) * vbStart.current.w;
|
||||||
|
const dy = ((e.clientY - panStart.current.y) / svgRef.current.clientHeight) * vbStart.current.h;
|
||||||
|
setViewBox({
|
||||||
|
x: vbStart.current.x - dx,
|
||||||
|
y: vbStart.current.y - dy,
|
||||||
|
w: vbStart.current.w,
|
||||||
|
h: vbStart.current.h,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
isPanning.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="zoom-pan-wrapper">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
|
||||||
|
onWheel={onWheel}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/map/object-renderers/IsolationIdRenderer.js
Normal file
24
src/components/map/object-renderers/IsolationIdRenderer.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export default function IsolationIdRenderer(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
const x1 = object.points?.x1;
|
||||||
|
const z1 = -object.points?.z1;
|
||||||
|
const x2 = object.points?.x2 ?? x1;
|
||||||
|
const z2 = -object.points?.z2 ?? z1;
|
||||||
|
const x = (x1 + x2) / 2;
|
||||||
|
const z = (z1 + z2) / 2;
|
||||||
|
const text = object.id_isolation ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={z}
|
||||||
|
textAnchor='middle'
|
||||||
|
id={`isolation-id-${object.id}`}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
className="isolation-id"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/map/object-renderers/SwitchNameRenderer.js
Normal file
20
src/components/map/object-renderers/SwitchNameRenderer.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export default function SwitchNameRenderer(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
const x = object.x;
|
||||||
|
const z = -object.z;
|
||||||
|
const text = object.id_switch;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={z}
|
||||||
|
textAnchor='middle'
|
||||||
|
id={`switch-${object.id}`}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
className="switch-name"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/map/object-renderers/TrackObjectRenderer.js
Normal file
26
src/components/map/object-renderers/TrackObjectRenderer.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
export default function TrackObjectRenderer(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
const x = object.x;
|
||||||
|
const z = -object.z;
|
||||||
|
const text = `${object.prefab_name};${object.name}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className="track-object">
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={z}
|
||||||
|
r='1px'
|
||||||
|
></circle>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={z}
|
||||||
|
id={`track-object-${object.id}`}
|
||||||
|
style={{ userSelect: "none" }}
|
||||||
|
enableBackground="new 0 0 100 100"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/map/object-renderers/TrackRenderer.js
Normal file
74
src/components/map/object-renderers/TrackRenderer.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
import Constants from "../../../helpers/constants";
|
||||||
|
|
||||||
|
export default function TrackRenderer(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
switch (object.type) {
|
||||||
|
case "StandardTrack":
|
||||||
|
case "PointTrack":
|
||||||
|
return <StandardTrackR {...props} />;
|
||||||
|
case "BezierTrack":
|
||||||
|
return <BezierTrackR {...props} />;
|
||||||
|
default:
|
||||||
|
console.warn(`No renderer component for track type: ${object.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function StandardTrackR(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
const x1 = object.points.x1;
|
||||||
|
const z1 = -object.points.z1;
|
||||||
|
const x2 = object.points.x2;
|
||||||
|
const z2 = -object.points.z2;
|
||||||
|
const ra = Math.abs(object.r);
|
||||||
|
|
||||||
|
let d;
|
||||||
|
if (object.r < 0) {
|
||||||
|
d = `M${x1},${z1} A${ra} ${ra} 0 0 1 ${x2},${z2}`;
|
||||||
|
} else if (object.r > 0) {
|
||||||
|
d = `M${x2},${z2} A${ra} ${ra} 0 0 1 ${x1},${z1}`;
|
||||||
|
} else {
|
||||||
|
d = `M${x1},${z1} L${x2},${z2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = Constants.map.useTrackColors ? (
|
||||||
|
object.r === 0 ? "rgb(255, 255, 255)" : "rgb(255, 0, 255)"
|
||||||
|
): undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
id={`track-${object.id}`}
|
||||||
|
stroke={color}
|
||||||
|
className="track"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BezierTrackR(props) {
|
||||||
|
const { object } = props;
|
||||||
|
|
||||||
|
const x1 = object.points.x1;
|
||||||
|
const z1 = -object.points.z1;
|
||||||
|
const cx1 = object.points.cx1+object.points.x1;
|
||||||
|
const cz1 = -object.points.cz1-object.points.z1;
|
||||||
|
const cx2 = object.points.cx2+object.points.x2;
|
||||||
|
const cz2 = -object.points.cz2-object.points.z2;
|
||||||
|
const x2 = object.points.x2;
|
||||||
|
const z2 = -object.points.z2;
|
||||||
|
|
||||||
|
const d = `M${x1},${z1} C${cx1},${cz1} ${cx2},${cz2} ${x2},${z2}`;
|
||||||
|
const color = Constants.map.useTrackColors ? "rgb(0, 255, 0)" : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
id={`btrack-${object.id}`}
|
||||||
|
stroke={color}
|
||||||
|
className="track"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/sidemenu/LayersMenu.js
Normal file
34
src/components/sidemenu/LayersMenu.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import SettingsContext from "../../contexts/SettingsContext";
|
||||||
|
import Constants from "../../helpers/constants";
|
||||||
|
|
||||||
|
export default function LayersMenu(props) {
|
||||||
|
const { layers, setLayers } = useContext(SettingsContext);
|
||||||
|
|
||||||
|
const toggleLayer = (layer) => {
|
||||||
|
setLayers((prevLayers) => ({
|
||||||
|
...prevLayers,
|
||||||
|
[layer]: !prevLayers[layer]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layers-menu">
|
||||||
|
<h3>Layers</h3>
|
||||||
|
<ul>
|
||||||
|
{ Constants.layers.map((layer) => (
|
||||||
|
<li key={layer.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={layers[layer.id] || false}
|
||||||
|
onChange={() => toggleLayer(layer.id)}
|
||||||
|
/>
|
||||||
|
{layer.name}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
)) }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/sidemenu/SideMenu.css
Normal file
13
src/components/sidemenu/SideMenu.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.side-menu-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-menu {
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
padding: 10px;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #111;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
20
src/components/sidemenu/SideMenu.js
Normal file
20
src/components/sidemenu/SideMenu.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {useContext} from 'react';
|
||||||
|
import MainContext from '../../contexts/MainContext';
|
||||||
|
import './SideMenu.css';
|
||||||
|
import LayersMenu from './LayersMenu';
|
||||||
|
|
||||||
|
export default function SideMenu() {
|
||||||
|
const { sideMenuOpen } = useContext(MainContext);
|
||||||
|
|
||||||
|
if (!sideMenuOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='side-menu-wrapper'>
|
||||||
|
<div className='side-menu'>
|
||||||
|
<LayersMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/toolbar/FileSelect.js
Normal file
31
src/components/toolbar/FileSelect.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import MainContext from "../../contexts/MainContext";
|
||||||
|
import Scenery from "../../model/scenery";
|
||||||
|
|
||||||
|
export default function FileSelect() {
|
||||||
|
const {setScenery, setIsLoading} = useContext(MainContext);
|
||||||
|
|
||||||
|
const handleFileChange = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsText(file);
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setIsLoading(false);
|
||||||
|
if(!e.target || !e.target.result) {
|
||||||
|
console.error("File reading failed or no content found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenery = Scenery.fromText(e.target.result);
|
||||||
|
setScenery(scenery);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='file-select'>
|
||||||
|
<input type='file' id='file-input' onChange={handleFileChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/toolbar/LoadingIndicator.js
Normal file
15
src/components/toolbar/LoadingIndicator.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import MainContext from '../../contexts/MainContext'
|
||||||
|
|
||||||
|
export default function LoadingIndicator() {
|
||||||
|
const { isLoading } = useContext(MainContext);
|
||||||
|
|
||||||
|
const loadingText = isLoading ? 'Loading...' : 'Ready';
|
||||||
|
const className = 'loading-indicator ' + (isLoading ? 'loading' : 'ready');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{loadingText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/toolbar/SideMenuToggle.js
Normal file
18
src/components/toolbar/SideMenuToggle.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useContext } from 'react';
|
||||||
|
import MainContext from '../../contexts/MainContext';
|
||||||
|
|
||||||
|
export default function SideMenuToggle() {
|
||||||
|
const { sideMenuOpen, setSideMenuOpen } = useContext(MainContext);
|
||||||
|
|
||||||
|
const toggleSideMenu = () => {
|
||||||
|
setSideMenuOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='side-menu-toggle'>
|
||||||
|
<button onClick={toggleSideMenu}>
|
||||||
|
{sideMenuOpen ? 'Hide Menu' : 'Show Menu'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/toolbar/Toolbar.css
Normal file
48
src/components/toolbar/Toolbar.css
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-cont {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0% { opacity: 1; pointer-events: auto; }
|
||||||
|
100% { opacity: 0; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator-cont {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
height: fit-content;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1s ease-in-out;
|
||||||
|
z-index: 1000;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator.loading {
|
||||||
|
background-color: rgb(151, 105, 19);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator.ready {
|
||||||
|
background-color: rgb(85, 204, 85);
|
||||||
|
opacity: 1;
|
||||||
|
animation: fadeOut 1s ease-in-out 2s forwards; /* Start fading out after 2 seconds */
|
||||||
|
}
|
||||||
20
src/components/toolbar/Toolbar.js
Normal file
20
src/components/toolbar/Toolbar.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Toolbar.css';
|
||||||
|
import LoadingIndicator from './LoadingIndicator';
|
||||||
|
import FileSelect from './FileSelect';
|
||||||
|
import SideMenuToggle from './SideMenuToggle';
|
||||||
|
|
||||||
|
export default function Toolbar() {
|
||||||
|
return (
|
||||||
|
<div className='toolbar-cont'>
|
||||||
|
<div className='toolbar'>
|
||||||
|
<FileSelect />
|
||||||
|
<div className='toolbar-spacer'></div>
|
||||||
|
<SideMenuToggle />
|
||||||
|
</div>
|
||||||
|
<div className='loading-indicator-cont'>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/contexts/MainContext.js
Normal file
4
src/contexts/MainContext.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
const MainContext = createContext();
|
||||||
|
export default MainContext;
|
||||||
4
src/contexts/SettingsContext.js
Normal file
4
src/contexts/SettingsContext.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
const SettingsContext = createContext();
|
||||||
|
export default SettingsContext;
|
||||||
30
src/helpers/constants.js
Normal file
30
src/helpers/constants.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
const Constants = {
|
||||||
|
map: {
|
||||||
|
useTrackColors: false,
|
||||||
|
zoomSensitivity: 0.002,
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'tracks',
|
||||||
|
name: 'Tracks',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switches-names',
|
||||||
|
name: 'Switches names',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'isolations-ids',
|
||||||
|
name: 'Isolations IDs',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'track-objects',
|
||||||
|
name: 'Track objects',
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Constants;
|
||||||
8
src/index.css
Normal file
8
src/index.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #111;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
9
src/index.js
Normal file
9
src/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './components/App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<App />
|
||||||
|
);
|
||||||
62
src/model/bezier-track.js
Normal file
62
src/model/bezier-track.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import Track from "./track";
|
||||||
|
|
||||||
|
export default class BezierTrack extends Track
|
||||||
|
{
|
||||||
|
type = "BezierTrack";
|
||||||
|
points = {
|
||||||
|
x1: 0, y1: 0, z1: 0, // start
|
||||||
|
cx1: 0, cy1: 0, cz1: 0, // control 1
|
||||||
|
cx2: 0, cy2: 0, cz2: 0, // control 2
|
||||||
|
x2: 0, y2: 0, z2: 0 // end
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(id, x1, y1, z1, cx1, cy1, cz1, x2, y2, z2, cx2, cy2, cz2, rot, len, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed) {
|
||||||
|
super(id, x1, y1, z1, rot, len, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed);
|
||||||
|
|
||||||
|
Object.assign(this.points, {
|
||||||
|
x1, y1, z1, // start
|
||||||
|
cx1, cy1, cz1, // control 1
|
||||||
|
cx2, cy2, cz2, // control 2
|
||||||
|
x2, y2, z2 // end
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromText(text) {
|
||||||
|
const values = text.split(";");
|
||||||
|
const track = new BezierTrack(
|
||||||
|
values[1], // id
|
||||||
|
parseFloat(values[3]), // x1
|
||||||
|
parseFloat(values[4]), // y1
|
||||||
|
parseFloat(values[5]), // z1
|
||||||
|
parseFloat(values[6]), // cx1
|
||||||
|
parseFloat(values[7]), // cy1
|
||||||
|
parseFloat(values[8]), // cz1
|
||||||
|
parseFloat(values[9]), // x2
|
||||||
|
parseFloat(values[10]), // y2
|
||||||
|
parseFloat(values[11]), // z2
|
||||||
|
parseFloat(values[12]), // cx2
|
||||||
|
parseFloat(values[13]), // cy2
|
||||||
|
parseFloat(values[14]), // cz2
|
||||||
|
0, // rot ??
|
||||||
|
0, // len ??
|
||||||
|
0, // r ??
|
||||||
|
values[15], // previd
|
||||||
|
values[16], // nextid
|
||||||
|
values[17], // id_station
|
||||||
|
values[21], // id_isolation
|
||||||
|
parseFloat(values[24]), // maxspeed
|
||||||
|
parseFloat(values[25]) // derailspeed
|
||||||
|
);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderBounds() {
|
||||||
|
return [
|
||||||
|
{ x: this.points.x1, z: this.points.z1 }, // start
|
||||||
|
{ x: this.points.x2, z: this.points.z2 }, // end
|
||||||
|
{ x: this.points.cx1, z: this.points.cz1 }, // control 1
|
||||||
|
{ x: this.points.cx2, z: this.points.cz2 } // control 2
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/model/point-track.js
Normal file
30
src/model/point-track.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Track from './track';
|
||||||
|
|
||||||
|
export default class PointTrack extends Track
|
||||||
|
{
|
||||||
|
type = "PointTrack";
|
||||||
|
points = {
|
||||||
|
x1: 0, y1: 0, z1: 0, // start
|
||||||
|
x2: 0, y2: 0, z2: 0 // end
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(id, x1, y1, z1, x2, y2, z2, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed) {
|
||||||
|
const rot = Math.atan2(x2-x1, z2-z1);
|
||||||
|
// length is unknown
|
||||||
|
super(id, x1, y1, z1, rot, null, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed);
|
||||||
|
|
||||||
|
Object.assign(this.points, {
|
||||||
|
x1, y1, z1,
|
||||||
|
x2, y2, z2
|
||||||
|
});
|
||||||
|
|
||||||
|
this.type="PointTrack";
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderBounds() {
|
||||||
|
return [
|
||||||
|
{ x: this.points.x1, z: this.points.z1 }, // start
|
||||||
|
{ x: this.points.x2, z: this.points.z2 } // end
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/model/scenery.js
Normal file
107
src/model/scenery.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import Switch from './switch';
|
||||||
|
import StandardTrack from './standard-track';
|
||||||
|
import BezierTrack from './bezier-track';
|
||||||
|
import TrackObject from './track-object';
|
||||||
|
|
||||||
|
export default class Scenery
|
||||||
|
{
|
||||||
|
objects = {};
|
||||||
|
bounds = { minX: Infinity, minZ: Infinity, maxX: -Infinity, maxZ: -Infinity };
|
||||||
|
|
||||||
|
getBounds () {
|
||||||
|
return { ...this.bounds };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromText(scText) {
|
||||||
|
const lines = scText.split("\n").map(line => line.trim());
|
||||||
|
const scenery = new Scenery();
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
if(!line?.length) return;
|
||||||
|
const object = Scenery._parseObject(line);
|
||||||
|
if(!object) return; // skip null objects
|
||||||
|
scenery.addObject(object);
|
||||||
|
});
|
||||||
|
|
||||||
|
scenery.applySwitches();
|
||||||
|
console.log(scenery);
|
||||||
|
|
||||||
|
return scenery;
|
||||||
|
}
|
||||||
|
|
||||||
|
addObject(object) {
|
||||||
|
const category = object.category || 'misc';
|
||||||
|
if(!this.objects[category]) {
|
||||||
|
this.objects[category] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.objects[category][object.id] = object;
|
||||||
|
|
||||||
|
if(object.getRenderBounds) {
|
||||||
|
this._updateBounds(object.getRenderBounds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySwitches() {
|
||||||
|
Object.values(this.objects.switches ?? []).forEach(sw => {
|
||||||
|
sw.applySwitch(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getObject(category, id) {
|
||||||
|
if(!this.objects[category]) return null;
|
||||||
|
return this.objects[category][id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _parseObject(text) {
|
||||||
|
const type = text.split(";", 2)[0];
|
||||||
|
switch(type) {
|
||||||
|
case 'Track':
|
||||||
|
return Scenery._parseTrack(text);
|
||||||
|
case 'TrackStructure':
|
||||||
|
return Switch.fromText(text);
|
||||||
|
case 'TrackObject':
|
||||||
|
return Scenery._parseTrackObject(text);
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown object type: ${type}`);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static _parseTrack(text) {
|
||||||
|
/// TODO: remove
|
||||||
|
const values = text.split(";");
|
||||||
|
if(values.includes("none-slp,trans-mat,trans-mat")) return null; // skip invisible tracks
|
||||||
|
|
||||||
|
const trackType = text.split(';', 4)[2];
|
||||||
|
switch(trackType) {
|
||||||
|
case 'Track':
|
||||||
|
return StandardTrack.fromText(text);
|
||||||
|
case 'BTrack':
|
||||||
|
return BezierTrack.fromText(text);
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown track type: ${trackType}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static _parseTrackObject(text) {
|
||||||
|
const prefabName = text.split(";", 4)[2];
|
||||||
|
switch(prefabName) {
|
||||||
|
default:
|
||||||
|
return TrackObject.fromText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateBounds(objBounds) {
|
||||||
|
if(!objBounds || !Array.isArray(objBounds)) return;
|
||||||
|
|
||||||
|
objBounds.forEach(point => {
|
||||||
|
if(point.x < this.bounds.minX) this.bounds.minX = point.x;
|
||||||
|
if(point.z < this.bounds.minZ) this.bounds.minZ = point.z;
|
||||||
|
if(point.x > this.bounds.maxX) this.bounds.maxX = point.x;
|
||||||
|
if(point.z > this.bounds.maxZ) this.bounds.maxZ = point.z;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/model/standard-track.js
Normal file
65
src/model/standard-track.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import Track from "./track";
|
||||||
|
|
||||||
|
export default class StandardTrack extends Track
|
||||||
|
{
|
||||||
|
type = "StandardTrack";
|
||||||
|
points = {
|
||||||
|
x1: 0, z1: 0, // start
|
||||||
|
x2: 0, z2: 0, // end
|
||||||
|
cx: 0, cz: 0 // circle center
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(id, x, y, z, rot, len, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed) {
|
||||||
|
super(id, x, y, z, rot, len, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed);
|
||||||
|
this._calcPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromText(text) {
|
||||||
|
const values = text.split(";");
|
||||||
|
const track = new StandardTrack(
|
||||||
|
values[1], // id
|
||||||
|
parseFloat(values[3]), // x
|
||||||
|
parseFloat(values[4]), // y
|
||||||
|
parseFloat(values[5]), // z
|
||||||
|
parseFloat(values[7]), // rot
|
||||||
|
parseFloat(values[9]), // len
|
||||||
|
parseFloat(values[10]), // r
|
||||||
|
values[11], // previd
|
||||||
|
values[12], // nextid
|
||||||
|
values[13], // id_station
|
||||||
|
values[17], // id_isolation
|
||||||
|
parseFloat(values[20]), // maxspeed
|
||||||
|
parseFloat(values[21]) // derailspeed
|
||||||
|
);
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
_calcPoints() {
|
||||||
|
const rotRad = this.rot * Math.PI / 180;
|
||||||
|
|
||||||
|
this.points = {
|
||||||
|
x1: this.x, // start
|
||||||
|
z1: this.z,
|
||||||
|
x2: this.x + this.len * Math.sin(rotRad), // end
|
||||||
|
z2: this.z + this.len * Math.cos(rotRad),
|
||||||
|
cx: this.x, // circle center (default)
|
||||||
|
cz: this.z // circle center (default)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.r !== 0) {
|
||||||
|
let trp2 = rotRad + Math.PI / 2;
|
||||||
|
this.points.cx = this.x - this.r * Math.sin(trp2); // circle center
|
||||||
|
this.points.cz = this.z - this.r * Math.cos(trp2);
|
||||||
|
this.points.x2 = this.points.cx + this.r * Math.sin(trp2 - this.len / this.r); // end (modify)
|
||||||
|
this.points.z2 = this.points.cz + this.r * Math.cos(trp2 - this.len / this.r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gerRenderBounds() {
|
||||||
|
return [
|
||||||
|
{ x: this.points.x1, z: this.points.z1 }, // start
|
||||||
|
{ x: this.points.x2, z: this.points.z2 }, // end
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/model/switch.js
Normal file
126
src/model/switch.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import PointTrack from "./point-track.js";
|
||||||
|
|
||||||
|
export default class Switch {
|
||||||
|
id;
|
||||||
|
model;
|
||||||
|
x;
|
||||||
|
y;
|
||||||
|
z;
|
||||||
|
rot;
|
||||||
|
data;
|
||||||
|
id_isolation;
|
||||||
|
id_switch;
|
||||||
|
maxspeed;
|
||||||
|
derailspeed;
|
||||||
|
category = "switches";
|
||||||
|
type = "Switch";
|
||||||
|
outs = [];
|
||||||
|
applied = false;
|
||||||
|
trackA;
|
||||||
|
trackB;
|
||||||
|
|
||||||
|
constructor(id, model, x, y, z, rot, data, id_isolation, id_switch, maxspeed, derailspeed) {
|
||||||
|
Object.assign(this, {
|
||||||
|
id, model,
|
||||||
|
x, y, z,
|
||||||
|
rot, data,
|
||||||
|
id_switch, id_isolation,
|
||||||
|
maxspeed, derailspeed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromText(text) {
|
||||||
|
const values = text.split(";");
|
||||||
|
|
||||||
|
const sw = new Switch(
|
||||||
|
values[1], // id
|
||||||
|
values[2], // model
|
||||||
|
parseFloat(values[3]), // x
|
||||||
|
parseFloat(values[4]), // y
|
||||||
|
parseFloat(values[5]), // z
|
||||||
|
parseFloat(values[7]), // rot
|
||||||
|
values[9], // data
|
||||||
|
values[10], // id_isolation
|
||||||
|
values[11], // id_switch
|
||||||
|
parseFloat(values[13]), // maxspeed
|
||||||
|
parseFloat(values[14]) // derailspeed
|
||||||
|
);
|
||||||
|
|
||||||
|
return sw;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySwitch(scenery) {
|
||||||
|
if(this.applied) return; // already applied
|
||||||
|
|
||||||
|
this.outs = Switch.getOutsFromData(this.data);
|
||||||
|
const r = Switch.getRadiusFromModel(this.model);
|
||||||
|
|
||||||
|
this.trackA = this._createSwitchTrack(scenery, this.id+"A", this.outs[0], this.outs[2], 0);
|
||||||
|
this.trackB = this._createSwitchTrack(scenery, this.id+"B", this.outs[1], this.outs[3], r);
|
||||||
|
|
||||||
|
if(!this.trackA || !this.trackB) {
|
||||||
|
console.error("Couldn't create switch tracks for switch #"+this.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scenery.addObject(this.trackA);
|
||||||
|
scenery.addObject(this.trackB);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getOutsFromData(data) {
|
||||||
|
const cts = data.split(",");
|
||||||
|
|
||||||
|
const tou = [];
|
||||||
|
cts.forEach((ct, i) => {
|
||||||
|
cts[i] = ct.split(":");
|
||||||
|
if(cts[i][1] === "") cts[i][1] = cts[i][2];
|
||||||
|
tou.push(cts[i][1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
tou[0],
|
||||||
|
tou[1] || tou[0],
|
||||||
|
tou[2] || tou[3] || tou[5],
|
||||||
|
tou[4] || tou[6] || tou[3]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRadiusFromModel(model) {
|
||||||
|
if(/Rkpd|Crossing/.test(model)) return 0;
|
||||||
|
|
||||||
|
const radiusText = model.replace(/(Rz 60E1-)|-1_.*/g, "");
|
||||||
|
const radius = parseInt(radiusText) || 0;
|
||||||
|
const side = model.split(",")[0];
|
||||||
|
const isRight = side[side.length-1] === "R";
|
||||||
|
|
||||||
|
return isRight ? -radius : radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createSwitchTrack(scenery, name, from, to, r) {
|
||||||
|
const startTrack = scenery.getObject("tracks", from);
|
||||||
|
const endTrack = scenery.getObject("tracks", to);
|
||||||
|
|
||||||
|
if(!startTrack || !endTrack) {
|
||||||
|
console.error("Couldn't create track from #"+from+" to #"+to+". One of them doesn't exist!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTrackEnd = startTrack.getCloserEnd(this.x, this.y, this.z);
|
||||||
|
const endTrackEnd = endTrack.getCloserEnd(this.x, this.y, this.z);
|
||||||
|
|
||||||
|
let trackObj = new PointTrack(
|
||||||
|
name,
|
||||||
|
...startTrackEnd,
|
||||||
|
...endTrackEnd,
|
||||||
|
r,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
this.id_switch,
|
||||||
|
this.id_isolation,
|
||||||
|
this.maxspeed,
|
||||||
|
this.derailspeed
|
||||||
|
);
|
||||||
|
|
||||||
|
return trackObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/model/track-object.js
Normal file
47
src/model/track-object.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export default class TrackObject {
|
||||||
|
id;
|
||||||
|
prefab_name;
|
||||||
|
x;
|
||||||
|
y;
|
||||||
|
z;
|
||||||
|
rot;
|
||||||
|
track_id;
|
||||||
|
name;
|
||||||
|
category = "track-objects";
|
||||||
|
type = "TrackObject";
|
||||||
|
track;
|
||||||
|
|
||||||
|
constructor(id, prefab_name, x, y, z, rot, track_id, name) {
|
||||||
|
Object.assign(this, {
|
||||||
|
id, prefab_name,
|
||||||
|
x, y, z,
|
||||||
|
rot, track_id, name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromText(text) {
|
||||||
|
const values = text.split(";");
|
||||||
|
const obj = new TrackObject(
|
||||||
|
values[1], // id
|
||||||
|
values[2], // prefab_name
|
||||||
|
parseFloat(values[3]), // x
|
||||||
|
parseFloat(values[4]), // y
|
||||||
|
parseFloat(values[5]), // z
|
||||||
|
parseFloat(values[7]), // rot
|
||||||
|
values[9], // track_id
|
||||||
|
values[11] // name
|
||||||
|
);
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyObject(scenery) {
|
||||||
|
/* TODO */
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderBounds() {
|
||||||
|
return [
|
||||||
|
{ x: this.x, z: this.z }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/model/track.js
Normal file
44
src/model/track.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export default class Track {
|
||||||
|
id;
|
||||||
|
x;
|
||||||
|
y;
|
||||||
|
z;
|
||||||
|
rot;
|
||||||
|
len;
|
||||||
|
r;
|
||||||
|
id_station;
|
||||||
|
id_isolation;
|
||||||
|
maxspeed;
|
||||||
|
derailspeed;
|
||||||
|
category = "tracks";
|
||||||
|
type = "Track";
|
||||||
|
outs = [];
|
||||||
|
|
||||||
|
constructor(id, x, y, z, rot, len, r, previd, nextid, id_station, id_isolation, maxspeed, derailspeed) {
|
||||||
|
Object.assign(this, {
|
||||||
|
id,
|
||||||
|
x, y, z,
|
||||||
|
rot, len, r,
|
||||||
|
id_station, id_isolation,
|
||||||
|
maxspeed, derailspeed
|
||||||
|
});
|
||||||
|
|
||||||
|
this.outs = [previd, nextid];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCloserEnd(x, y, z) {
|
||||||
|
function pow2(val) {
|
||||||
|
return val*val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist1 = pow2(x-this.points.x1)+pow2(z-this.points.z1);
|
||||||
|
const dist2 = pow2(x-this.points.x2)+pow2(z-this.points.z2);
|
||||||
|
return (dist1 < dist2) ? [this.points.x1, y, this.points.z1] : [this.points.x2, y, this.points.z2];
|
||||||
|
}
|
||||||
|
|
||||||
|
getRenderBounds() {
|
||||||
|
return [
|
||||||
|
{ x: this.x, z: this.z }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
tools/scp.py
Normal file
54
tools/scp.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
#
|
||||||
|
# [ TD2 SCENERY PROCESSOR ]
|
||||||
|
# by masuo
|
||||||
|
# v1.2
|
||||||
|
#
|
||||||
|
|
||||||
|
BAD_WORDS = ['Misc', 'Fence', 'TerrainPoint', 'Wires']
|
||||||
|
|
||||||
|
def should_exclude(line: str) -> bool:
|
||||||
|
return any(bad_word in line for bad_word in BAD_WORDS)
|
||||||
|
|
||||||
|
def process_file(file_path: str):
|
||||||
|
if not file_path.endswith(".sc"):
|
||||||
|
print(f"Skipping non-.sc file: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = file_path[:-3] + ".lite.sc"
|
||||||
|
print(f"Processing file: {file_path} -> {output_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, encoding="utf8") as infile, open(output_path, 'w', encoding="utf8") as outfile:
|
||||||
|
for line in infile:
|
||||||
|
if not should_exclude(line):
|
||||||
|
outfile.write(line)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {file_path}: {e}")
|
||||||
|
|
||||||
|
def process_directory(directory: str):
|
||||||
|
print(f"Processing directory: {directory}")
|
||||||
|
for entry in os.listdir(directory):
|
||||||
|
full_path = os.path.join(directory, entry)
|
||||||
|
if os.path.isfile(full_path):
|
||||||
|
process_file(full_path)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python scp.py <file_or_directory> [<file_or_directory> ...]")
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in sys.argv[1:]:
|
||||||
|
if os.path.isdir(path):
|
||||||
|
process_directory(path)
|
||||||
|
elif os.path.isfile(path):
|
||||||
|
process_file(path)
|
||||||
|
else:
|
||||||
|
print(f"Invalid path: {path}")
|
||||||
|
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user