Initial commit

This commit is contained in:
izawartka 2025-05-29 13:11:22 +02:00
commit d78e72fc49
41 changed files with 18892 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

23
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

20
public/index.html Normal file
View 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
View 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
View 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;

View 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>
);
}

View 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
View 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>
)
}

View 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;

View 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>
)
}

View File

@ -0,0 +1,11 @@
.zoom-pan-wrapper {
overflow: hidden;
}
.zoom-pan-wrapper svg {
cursor: grab;
}
.zoom-pan-wrapper svg:active {
cursor: grabbing;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 */
}

View 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>
);
}

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
const MainContext = createContext();
export default MainContext;

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
const SettingsContext = createContext();
export default SettingsContext;

30
src/helpers/constants.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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;
}
);
}
}

View 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
View 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
View 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
View 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
View 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()