feat(fls): import a .fls topology into the editor model
importProject builds what the simulation core runs from Java's view of a
file: nodes with kinds and ports, links between port ids, device and
interface IP configurations, documentation items with Swift's derived
ids. The core gains the model types it needs (node, link, graph,
documentation item), ported from filius-ipad.
Checked against the Swift import, which the Swift oracle now writes in
full (corpus.swift.json, "state"), on the 13 files Swift reads
completely. Two differences are intended and named in the test:
- a device the file leaves unnamed has Java's default name
("Switch / WLAN"), not Swift's;
- multicast_server.fls: useIPAsName refers to a shared Boolean that
Swift cannot follow; Java shows the IP address, and so do we.
All 23 files import with as many devices and cables as Java has, and an
imported example pings across its router in the core.
Port counts of switches and routers follow Java's lists instead of
Swift's guess from references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// ios/FiliusPad/TopologyEditor/Model/TopologyDocumentation.swift
|
||||
// Java counterpart: filius.gui.netzwerksicht.GUIDocuItem (RECT = 1, TEXT = 2).
|
||||
// Rectangles and text boxes drawn over the network.
|
||||
|
||||
import { compareIds, type Id } from '../ids.js';
|
||||
|
||||
export type DocumentationItemKind = 'text' | 'rectangle';
|
||||
|
||||
/** Components 0…1, as Swift keeps them. */
|
||||
export interface DocumentationColor {
|
||||
readonly red: number;
|
||||
readonly green: number;
|
||||
readonly blue: number;
|
||||
readonly alpha: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface DocumentationItem {
|
||||
readonly id: Id;
|
||||
kind: DocumentationItemKind;
|
||||
frame: Rect;
|
||||
text: string;
|
||||
color: DocumentationColor;
|
||||
fontName: string;
|
||||
fontSize: number;
|
||||
isBold: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export const DOCUMENTATION_DEFAULT_TEXT_SIZE = { width: 220, height: 96 } as const;
|
||||
export const DOCUMENTATION_DEFAULT_RECTANGLE_SIZE = { width: 240, height: 140 } as const;
|
||||
export const DOCUMENTATION_MINIMUM_SIZE = { width: 32, height: 24 } as const;
|
||||
/** The editor's coordinate range; keeps Java `int` export and rendering safe. */
|
||||
export const DOCUMENTATION_WORLD_BOUNDS: Rect = { x: -10_000, y: -10_000, width: 20_000, height: 20_000 };
|
||||
|
||||
function clampUnit(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(Math.max(value, 0), 1);
|
||||
}
|
||||
|
||||
export function documentationColor(red: number, green: number, blue: number, alpha = 1): DocumentationColor {
|
||||
return { red: clampUnit(red), green: clampUnit(green), blue: clampUnit(blue), alpha: clampUnit(alpha) };
|
||||
}
|
||||
|
||||
export const DOCUMENTATION_BLACK = documentationColor(0, 0, 0);
|
||||
export const DOCUMENTATION_PALE_YELLOW = documentationColor(255 / 255, 255 / 255, 227 / 255, 0.82);
|
||||
|
||||
/** CGRect.standardized: a negative width or height moves the origin instead. */
|
||||
function standardized(frame: Rect): Rect {
|
||||
const x = frame.width < 0 ? frame.x + frame.width : frame.x;
|
||||
const y = frame.height < 0 ? frame.y + frame.height : frame.y;
|
||||
return { x, y, width: Math.abs(frame.width), height: Math.abs(frame.height) };
|
||||
}
|
||||
|
||||
/** At least the minimum size, at most the world, moved inside the world. */
|
||||
export function normalizedFrame(frame: Rect): Rect {
|
||||
const world = DOCUMENTATION_WORLD_BOUNDS;
|
||||
const fallback = { x: world.x, y: world.y, ...DOCUMENTATION_MINIMUM_SIZE };
|
||||
if (![frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) return fallback;
|
||||
const rect = standardized(frame);
|
||||
const width = Math.min(Math.max(rect.width, DOCUMENTATION_MINIMUM_SIZE.width), world.width);
|
||||
const height = Math.min(Math.max(rect.height, DOCUMENTATION_MINIMUM_SIZE.height), world.height);
|
||||
const x = Math.min(Math.max(rect.x, world.x), world.x + world.width - width);
|
||||
const y = Math.min(Math.max(rect.y, world.y), world.y + world.height - height);
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/** Positive size, entirely inside the world. */
|
||||
export function isSafeFrame(frame: Rect): boolean {
|
||||
const world = DOCUMENTATION_WORLD_BOUNDS;
|
||||
if (![frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) return false;
|
||||
if (frame.width <= 0 || frame.height <= 0 || frame.width > world.width || frame.height > world.height) return false;
|
||||
return (
|
||||
frame.x >= world.x &&
|
||||
frame.y >= world.y &&
|
||||
frame.x + frame.width <= world.x + world.width &&
|
||||
frame.y + frame.height <= world.y + world.height
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizedFontSize(value: number): number {
|
||||
if (!Number.isFinite(value)) return 14;
|
||||
return Math.min(Math.max(value, 8), 72);
|
||||
}
|
||||
|
||||
/** A documentation item as Swift's initializer normalizes it. */
|
||||
export function documentationItem(fields: {
|
||||
id: Id;
|
||||
kind: DocumentationItemKind;
|
||||
frame: Rect;
|
||||
text?: string;
|
||||
color: DocumentationColor;
|
||||
fontName?: string;
|
||||
fontSize?: number;
|
||||
isBold?: boolean;
|
||||
order: number;
|
||||
}): DocumentationItem {
|
||||
const fontName = (fields.fontName ?? 'System').trim();
|
||||
return {
|
||||
id: fields.id,
|
||||
kind: fields.kind,
|
||||
frame: normalizedFrame(fields.frame),
|
||||
text: fields.kind === 'text' ? (fields.text ?? '') : '',
|
||||
color: fields.color,
|
||||
fontName: fontName === '' ? 'System' : fontName,
|
||||
fontSize: normalizedFontSize(fields.fontSize ?? 14),
|
||||
isBold: fields.isBold ?? false,
|
||||
order: Math.max(0, fields.order),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rectangles below text, then by order, then by id. */
|
||||
export function inDeterministicRenderOrder(items: readonly DocumentationItem[]): DocumentationItem[] {
|
||||
const layer = (item: DocumentationItem): number => (item.kind === 'rectangle' ? 0 : 1);
|
||||
return [...items].sort(
|
||||
(lhs, rhs) => layer(lhs) - layer(rhs) || lhs.order - rhs.order || compareIds(lhs.id, rhs.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function nextDocumentationOrder(items: readonly DocumentationItem[]): number {
|
||||
return Math.max(-1, ...items.map((item) => item.order)) + 1;
|
||||
}
|
||||
|
||||
const UINT64 = (1n << 64n) - 1n;
|
||||
const FNV_PRIME = 0x100000001b3n;
|
||||
|
||||
/**
|
||||
* Swift's `deterministicDocumentationUUID(seed:)`: two 64-bit FNV-1a hashes
|
||||
* of the seed's UTF-8 bytes (the second with the length mixed in) as the
|
||||
* UUID's sixteen bytes, upper case like `uuidString`.
|
||||
*/
|
||||
export function deterministicDocumentationUuid(seed: string): Id {
|
||||
const bytes = new TextEncoder().encode(seed);
|
||||
const hash = (offset: bigint): bigint => {
|
||||
let value = offset;
|
||||
for (const byte of bytes) value = ((value ^ BigInt(byte)) * FNV_PRIME) & UINT64;
|
||||
return value;
|
||||
};
|
||||
const high = hash(0xcbf29ce484222325n);
|
||||
const low = hash(0x84222325cbf29ce4n) ^ BigInt(bytes.length);
|
||||
const hex = high.toString(16).padStart(16, '0') + low.toString(16).padStart(16, '0');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// ios/FiliusPad/TopologyEditor/Model/TopologyGraph.swift
|
||||
// The nodes and the cables between them. Queries follow with the editor.
|
||||
|
||||
import type { TopologyLink } from './link.js';
|
||||
import type { TopologyNode } from './node.js';
|
||||
|
||||
export interface TopologyGraph {
|
||||
nodes: TopologyNode[];
|
||||
links: TopologyLink[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Ported from filius-ipad (GPLv2/GPLv3):
|
||||
// ios/FiliusPad/TopologyEditor/Model/TopologyLink.swift
|
||||
// A cable: from a port of one node to a port of another.
|
||||
|
||||
import type { Id } from '../ids.js';
|
||||
|
||||
export interface TopologyLink {
|
||||
readonly id: Id;
|
||||
readonly sourceNodeId: Id;
|
||||
readonly sourcePortId: Id;
|
||||
readonly targetNodeId: Id;
|
||||
readonly targetPortId: Id;
|
||||
}
|
||||
@@ -100,3 +100,48 @@ export function defaultPortLabels(kind: NodeKind): string[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
export interface TopologyNode {
|
||||
readonly id: Id;
|
||||
kind: NodeKind;
|
||||
displayName: string;
|
||||
hostLabelPolicy: HostLabelPolicy;
|
||||
position: Position;
|
||||
ports: PortMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A node as Swift's `TopologyNode.init` builds it: only a PC or notebook
|
||||
* shows its IP or MAC address as its label, and a new node gets the ports of
|
||||
* its kind.
|
||||
*
|
||||
* Unlike Swift, a given name is kept as it is — also empty: Java FILIUS
|
||||
* files leave routers deliberately unnamed (the Internet examples), and Java
|
||||
* shows them without a label. Only a node without a name gets the default.
|
||||
*/
|
||||
export function topologyNode(fields: {
|
||||
id: Id;
|
||||
kind: NodeKind;
|
||||
displayName?: string;
|
||||
hostLabelPolicy?: HostLabelPolicy;
|
||||
position: Position;
|
||||
ports?: PortMetadata[];
|
||||
/** Makes the ids of the default ports; the core has no randomness of its own. */
|
||||
newId: () => Id;
|
||||
}): TopologyNode {
|
||||
return {
|
||||
id: fields.id,
|
||||
kind: fields.kind,
|
||||
displayName: fields.displayName ?? defaultDisplayName(fields.kind),
|
||||
hostLabelPolicy: isPcClassEndpoint(fields.kind) ? (fields.hostLabelPolicy ?? 'manual') : 'manual',
|
||||
position: fields.position,
|
||||
ports:
|
||||
fields.ports ??
|
||||
defaultPortLabels(fields.kind).map((label) => ({ id: fields.newId(), label, isOccupied: false })),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user