parser and editor use same code for figuring out what is connected to what

This commit is contained in:
Joeri Exelmans 2025-10-17 14:37:26 +02:00
parent 6dc7a2e9a7
commit b55cba198e
6 changed files with 228 additions and 149 deletions

View file

@ -1,5 +1,5 @@
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "../VisualEditor/geometry";
import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "../VisualEditor/parameters";
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox, subtractV2D } from "../VisualEditor/geometry";
import { ARROW_SNAP_THRESHOLD, HISTORY_RADIUS, TEXT_SNAP_THRESHOLD } from "../VisualEditor/parameters";
import { sides } from "../VisualEditor/VisualEditor";
export type Rountangle = {
@ -115,3 +115,19 @@ export function findRountangle(point: Vec2D, candidates: Rountangle[]): Rountang
}
}
}
export function findNearestHistory(point: Vec2D, candidates: History[]): History | undefined {
let best;
let bestDistance = Infinity;
for (const h of candidates) {
const diff = subtractV2D(point, {x: h.topLeft.x+HISTORY_RADIUS, y: h.topLeft.y+HISTORY_RADIUS});
const euclideanDistance = Math.hypot(diff.x, diff.y) - HISTORY_RADIUS;
if (euclideanDistance < ARROW_SNAP_THRESHOLD) {
if (euclideanDistance < bestDistance) {
best = h;
bestDistance = euclideanDistance;
}
}
}
return best;
}

View file

@ -0,0 +1,84 @@
import { findNearestArrow, findNearestHistory, findNearestSide, findRountangle, RountanglePart, VisualEditorState } from "./concrete_syntax";
export type Connections = {
arrow2SideMap: Map<string,[{ uid: string; part: RountanglePart; } | undefined, { uid: string; part: RountanglePart; } | undefined]>,
side2ArrowMap: Map<string, Set<["start"|"end", string]>>,
text2ArrowMap: Map<string,string>,
arrow2TextMap: Map<string,string[]>,
arrow2HistoryMap: Map<string,string>,
text2RountangleMap: Map<string, string>,
rountangle2TextMap: Map<string, string[]>,
history2ArrowMap: Map<string, string[]>,
}
export function detectConnections(state: VisualEditorState): Connections {
// detect what is 'connected'
const arrow2SideMap = new Map<string,[{ uid: string; part: RountanglePart; } | undefined, { uid: string; part: RountanglePart; } | undefined]>();
const side2ArrowMap = new Map<string, Set<["start"|"end", string]>>();
const text2ArrowMap = new Map<string,string>();
const arrow2TextMap = new Map<string,string[]>();
const arrow2HistoryMap = new Map<string,string>();
const text2RountangleMap = new Map<string, string>();
const rountangle2TextMap = new Map<string, string[]>();
const history2ArrowMap = new Map<string, string[]>();
// arrow <-> (rountangle | diamond)
for (const arrow of state.arrows) {
// snap to history:
const historyTarget = findNearestHistory(arrow.end, state.history);
if (historyTarget) {
arrow2HistoryMap.set(arrow.uid, historyTarget.uid);
history2ArrowMap.set(historyTarget.uid, [...(history2ArrowMap.get(historyTarget.uid) || []), arrow.uid]);
}
// snap to rountangle/diamon side:
const sides = [...state.rountangles, ...state.diamonds];
const startSide = findNearestSide(arrow, "start", sides);
const endSide = historyTarget ? undefined : findNearestSide(arrow, "end", sides);
if (startSide || endSide) {
arrow2SideMap.set(arrow.uid, [startSide, endSide]);
}
if (startSide) {
const arrowConns = side2ArrowMap.get(startSide.uid + '/' + startSide.part) || new Set();
arrowConns.add(["start", arrow.uid]);
side2ArrowMap.set(startSide.uid + '/' + startSide.part, arrowConns);
}
if (endSide) {
const arrowConns = side2ArrowMap.get(endSide.uid + '/' + endSide.part) || new Set();
arrowConns.add(["end", arrow.uid]);
side2ArrowMap.set(endSide.uid + '/' + endSide.part, arrowConns);
}
}
// text <-> arrow
for (const text of state.texts) {
const nearestArrow = findNearestArrow(text.topLeft, state.arrows);
if (nearestArrow) {
// prioritize text belonging to arrows:
text2ArrowMap.set(text.uid, nearestArrow.uid);
const textsOfArrow = arrow2TextMap.get(nearestArrow.uid) || [];
textsOfArrow.push(text.uid);
arrow2TextMap.set(nearestArrow.uid, textsOfArrow);
}
else {
// text <-> rountangle
const rountangle = findRountangle(text.topLeft, state.rountangles);
if (rountangle) {
text2RountangleMap.set(text.uid, rountangle.uid);
const texts = rountangle2TextMap.get(rountangle.uid) || [];
texts.push(text.uid);
rountangle2TextMap.set(rountangle.uid, texts);
}
}
}
return {
arrow2SideMap,
side2ArrowMap,
text2ArrowMap,
arrow2TextMap,
arrow2HistoryMap,
text2RountangleMap,
rountangle2TextMap,
history2ArrowMap,
};
}

View file

@ -1,9 +1,9 @@
import { AbstractState, ConcreteState, OrState, PseudoState, Statechart, Transition } from "./abstract_syntax";
import { findNearestArrow, findNearestSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax";
import { ConcreteState, OrState, PseudoState, Statechart, Transition } from "./abstract_syntax";
import { Rountangle, VisualEditorState } from "./concrete_syntax";
import { isEntirelyWithin } from "../VisualEditor/geometry";
import { Action, EventTrigger, Expression, ParsedText } from "./label_ast";
import { parse as parseLabel, SyntaxError } from "./label_parser";
import { Connections } from "./detect_connections";
export type TraceableError = {
shapeUid: string;
@ -29,7 +29,7 @@ function addEvent(events: EventTrigger[], e: EventTrigger, textUid: string) {
}
}
export function parseStatechart(state: VisualEditorState): [Statechart, TraceableError[]] {
export function parseStatechart(state: VisualEditorState, conns: Connections): [Statechart, TraceableError[]] {
const errors: TraceableError[] = [];
// implicitly, the root is always an Or-state
@ -120,9 +120,8 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl
const uid2Transition = new Map<string, Transition>();
for (const arr of state.arrows) {
const sides = [...state.rountangles, ...state.diamonds];
const srcUID = findNearestSide(arr, "start", sides)?.uid;
const tgtUID = findNearestSide(arr, "end", sides)?.uid;
const srcUID = conns.arrow2SideMap.get(arr.uid)?.[0]?.uid;
const tgtUID = conns.arrow2SideMap.get(arr.uid)?.[1]?.uid;
if (!srcUID) {
if (!tgtUID) {
// dangling edge
@ -222,73 +221,71 @@ export function parseStatechart(state: VisualEditorState): [Statechart, Traceabl
throw e;
}
}
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
if (belongsToArrow) {
const belongsToTransition = uid2Transition.get(belongsToArrow.uid);
if (belongsToTransition) {
const {src} = belongsToTransition;
belongsToTransition.label.push(parsed);
if (parsed.kind === "transitionLabel") {
// collect events
// triggers
if (parsed.trigger.kind === "event") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have event trigger"});
const belongsToArrowUID = conns.text2ArrowMap.get(text.uid);
const belongsToTransition = uid2Transition.get(belongsToArrowUID!);
if (belongsToTransition) {
const {src} = belongsToTransition;
belongsToTransition.label.push(parsed);
if (parsed.kind === "transitionLabel") {
// collect events
// triggers
if (parsed.trigger.kind === "event") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have event trigger"});
}
else {
const {event} = parsed.trigger;
if (event.startsWith("_")) {
errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid));
}
else {
const {event} = parsed.trigger;
if (event.startsWith("_")) {
errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid));
}
else {
errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid));
}
errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid));
}
}
else if (parsed.trigger.kind === "after") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have after-trigger"});
}
else {
src.timers.push(parsed.trigger.durationMs);
src.timers.sort();
}
}
else if (parsed.trigger.kind === "after") {
if (src.kind === "pseudo") {
errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have after-trigger"});
}
else if (["entry", "exit"].includes(parsed.trigger.kind)) {
errors.push({shapeUid: text.uid, message: "entry/exit trigger not allowed on transitions"});
else {
src.timers.push(parsed.trigger.durationMs);
src.timers.sort();
}
else if (parsed.trigger.kind === "triggerless") {
if (src.kind !== "pseudo") {
errors.push({shapeUid: text.uid, message: "triggerless transitions only allowed on pseudo-states"});
}
}
else if (["entry", "exit"].includes(parsed.trigger.kind)) {
errors.push({shapeUid: text.uid, message: "entry/exit trigger not allowed on transitions"});
}
else if (parsed.trigger.kind === "triggerless") {
if (src.kind !== "pseudo") {
errors.push({shapeUid: text.uid, message: "triggerless transitions only allowed on pseudo-states"});
}
}
// // raise-actions
// for (const action of parsed.actions) {
// if (action.kind === "raise") {
// const {event} = action;
// if (event.startsWith("_")) {
// internalEvents.add(event);
// }
// else {
// outputEvents.add(event);
// }
// }
// }
// // raise-actions
// for (const action of parsed.actions) {
// if (action.kind === "raise") {
// const {event} = action;
// if (event.startsWith("_")) {
// internalEvents.add(event);
// }
// else {
// outputEvents.add(event);
// }
// }
// }
// collect variables
variables = variables.union(findVariables(parsed.guard));
for (const action of parsed.actions) {
variables = variables.union(findVariablesAction(action));
}
// collect variables
variables = variables.union(findVariables(parsed.guard));
for (const action of parsed.actions) {
variables = variables.union(findVariablesAction(action));
}
}
}
else {
// text does not belong to transition...
// so it belongs to a rountangle (a state)
const rountangle = findRountangle(text.topLeft, state.rountangles);
const belongsToState = rountangle ? uid2State.get(rountangle.uid)! as ConcreteState : root;
const rountangleUID = conns.text2RountangleMap.get(text.uid);
const belongsToState = uid2State.get(rountangleUID!) as ConcreteState || root;
if (parsed.kind === "transitionLabel") {
// labels belonging to a rountangle (= a state) must by entry/exit actions
// if we cannot find a containing state, then it belong to the root