PlayingSpace.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2024, Arnaud Roques
 *
 * Project Info:  https://plantuml.com
 * 
 * If you like this project or if you find it useful, you can support us at:
 * 
 * https://plantuml.com/patreon (only 1$ per month!)
 * https://plantuml.com/paypal
 * 
 * This file is part of PlantUML.
 *
 * PlantUML is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PlantUML distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
 * License for more details.
 *
 * You should have received a copy of the GNU General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
 * USA.
 *
 *
 * Original Author:  Arnaud Roques
 * 
 *
 */
package net.sourceforge.plantuml.sequencediagram.teoz;

import java.util.ArrayList;
import java.util.List;

import net.sourceforge.plantuml.klimt.drawing.LimitFinder;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.real.Real;
import net.sourceforge.plantuml.real.RealUtils;
import net.sourceforge.plantuml.sequencediagram.Event;
import net.sourceforge.plantuml.sequencediagram.LinkAnchor;
import net.sourceforge.plantuml.sequencediagram.Message;
import net.sourceforge.plantuml.sequencediagram.MessageExo;
import net.sourceforge.plantuml.sequencediagram.SequenceDiagram;
import net.sourceforge.plantuml.style.ISkinParam;

public class PlayingSpace implements Bordered {

	private final double startingY = 8;
	private final Real min;
	private final Real max;
	private final boolean isShowFootbox;

	private final List<Tile> tiles = new ArrayList<>();
	private final LivingSpaces livingSpaces;
	private final List<LinkAnchor> linkAnchors;
	private final ISkinParam skinParam;
	private final SequenceDiagram diagram;
	private final StringBounder stringBounder;

	public PlayingSpace(SequenceDiagram diagram, Dolls dolls, TileArguments tileArguments) {

		this.diagram = diagram;
		this.livingSpaces = tileArguments.getLivingSpaces();
		this.linkAnchors = diagram.getLinkAnchors();
		this.skinParam = diagram.getSkinParam();
		this.stringBounder = tileArguments.getStringBounder();

		final List<Real> min2 = new ArrayList<>();
		final List<Real> max2 = new ArrayList<>();

		min2.add(tileArguments.getXOrigin());
		max2.add(tileArguments.getXOrigin());

		if (dolls.size() > 0) {
			min2.add(dolls.getMinX(tileArguments.getStringBounder()));
			max2.add(dolls.getMaxX(tileArguments.getStringBounder()));
		}

		// Gauge chain origin: everything chains off this so gauge coordinates line
		// up with the diagram's own drawing space (startingY of headroom above
		// the first tile).
		final YGauge ycurrent = YGauge.create(tileArguments.getYOrigin().addFixed(startingY), 0);

		tiles.addAll(TileBuilder.buildSeveral(diagram.events().iterator(), tileArguments, null, ycurrent));

		for (Tile tile : tiles) {
			min2.add(tile.getMinX());
			max2.add(tile.getMaxX());
		}

		for (LivingSpace livingSpace : livingSpaces.values()) {
			max2.add(livingSpace.getPosD(tileArguments.getStringBounder()));
			max2.add(livingSpace.getPosC2(tileArguments.getStringBounder()));
		}

		this.min = RealUtils.min(min2);
		this.max = RealUtils.max(max2);

		this.isShowFootbox = diagram.isShowFootbox();
	}

	public void drawBackground(UGraphic ug) {
		final UGraphicInterceptorTile interceptor = new UGraphicInterceptorTile(ug, true);
		drawUInternal(interceptor, false);
	}

	public void drawForeground(UGraphic ug) {
		final UGraphicInterceptorTile interceptor = new UGraphicInterceptorTile(ug, false);
		drawUInternal(interceptor, false);
	}

	private double drawUInternal(UGraphic ug, boolean trace) {
		final StringBounder stringBounder = ug.getStringBounder();
		final List<CommonTile> local = new ArrayList<>();
		final List<CommonTile> full = new ArrayList<>();
		GroupingTile.fillPositionelTiles(stringBounder, tiles, local, full);
		// Each tile draws itself in ABSOLUTE coordinates, translating itself by its
		// own gauge min (the "self-translate prologue" every drawU() starts with),
		// so no external dy() translation is applied here
		for (CommonTile tile : local)
			tile.drawU(ug);
		for (LinkAnchor linkAnchor : linkAnchors) {
			final CommonTile ytile1 = getFromAnchor(full, linkAnchor.getAnchor1());
			final CommonTile ytile2 = getFromAnchor(full, linkAnchor.getAnchor2());
			if (ytile1 != null && ytile2 != null)
				linkAnchor.drawAnchor(ug, ytile1, ytile2, skinParam);

		}
		// Final height of this level: the last top-level tile's gauge already
		// dominates everything before it (see YGauge.createParallel's
		// max.ensureBiggerThan(currentY.getMax())), so its max IS the total height.
		// Falls back to startingY for an empty diagram.
		if (tiles.isEmpty())
			return startingY;

		return tiles.get(tiles.size() - 1).getYGauge().getMax().getCurrentValue();
	}

	private CommonTile getFromAnchor(List<CommonTile> positionedTiles, String anchor) {
		for (CommonTile ytile : positionedTiles)
			if (ytile.matchAnchor(anchor))
				return ytile;

		return null;
	}

	public double getPreferredHeight(StringBounder stringBounder) {
		final LimitFinder limitFinder = LimitFinder.create(stringBounder, true);
		final UGraphicInterceptorTile interceptor = new UGraphicInterceptorTile(limitFinder, false);
		final double finalY = drawUInternal(interceptor, false);
		final double result = Math.max(limitFinder.getMinMax().getDimension().getHeight(), finalY) + 10;
		// System.err.println("MainTile::getPreferredHeight=" + result);
		return result;
	}

	public void addConstraints() {
		for (Tile tile : tiles)
			tile.addConstraints();

		addParallelSiblingDisjointConstraints();
	}

	// A `&`-parallel sibling shares its YGauge with whichever top-level tile(s)
	// precede it (see YGauge.createParallel()), so the two can be visible on
	// the very same rows. Nothing else keeps their FOOTPRINTS
	// (getMinX()/getMaxX() -- for a GroupingTile this already includes its own
	// frame margin) from overlapping in X: addConstraints() above only makes
	// each tile internally consistent with its own participants. A
	// *sequential* pair never needs this (different Y rows, never both
	// visible at once), but a parallel pair can end up drawn on top of one
	// another -- exactly what happened with a self-message's loop/label
	// running into a `&`-parallel group's frame margin (see the `altpar_001`/
	// `altpar_007` investigation): the self-message correctly reserved room
	// against its own neighbour's lifeline, but had no way to know the
	// group's frame would additionally claim its own margin further left, and
	// no one else was reserving THAT space either.
	//
	// Clustering rule mirrors GroupingTile.computeBodyHeight()'s pending-list
	// grouping (a `&` pulls the whole preceding run into its own cluster) --
	// same shape, applied to X disjointness instead of Y height.
	private void addParallelSiblingDisjointConstraints() {
		final List<Tile> cluster = new ArrayList<>();
		for (Tile tile : tiles) {
			if (GroupingTile.isParallel(tile)) {
				for (Tile other : cluster)
					ensureDisjoint(other, tile);

				cluster.add(tile);
			} else {
				cluster.clear();
				cluster.add(tile);
			}
		}
	}

	// Pushes whichever footprint starts further right so it clears the other
	// entirely.
	//
	// CRITICAL: the direction check below must NEVER call getCurrentValue()
	// on a.getMinX()/getMaxX()/b.getMinX()/getMaxX() themselves when either
	// side is (or contains) a GroupingTile. Reason: GroupingTile.getMinX()/
	// getMaxX() are addFixed() wrappers around two PERSISTENT fields (`min`,
	// `max`, built once in the constructor via RealUtils.min()/max()), and
	// RealMin/RealMax cache their resolved value the FIRST time
	// getCurrentValue() is read -- permanently, with no invalidation (see
	// RealMin/RealMax's own `cache` field). Reading either one before compile
	// freezes it at its pre-push snapshot forever, silently discarding every
	// later ensureBiggerThan() -- and since GroupingTile.max's own formula
	// folds in `this.min.addFixed(...)` as one of its candidates, reading max
	// alone poisons BOTH fields at once. (The ASCII counterpart in
	// GroupingTile.getAsciiMinX()/getAsciiMaxX() doesn't have this problem: it
	// builds a brand new RealMin/RealMax on every call instead of caching one
	// in a field, so an early read there is harmless.) This is exactly what
	// broke the first cut of this fix: comparing getCurrentValue() the way
	// CommunicationTile.isReverse() safely does for two plain LivingSpace
	// posC's silently froze the enclosing group's frame in place when one
	// side of the pair was a GroupingTile.
	//
	// The safe alternative, mirrored from isReverse(): walk down to an actual
	// LivingSpace and compare its posB -- a genuine chained RealImpl/RealDelta,
	// never backed by a cached RealMin/RealMax, so always safe to read.
	// ensureBiggerThan() itself (called below, on aMin/bMin) is always safe
	// too, on any Real: it only registers a PositiveForce, it never triggers a
	// getCurrentValue() read.
	private void ensureDisjoint(Tile a, Tile b) {
		final Real aMin = a.getMinX();
		final Real aMax = a.getMaxX();
		final Real bMin = b.getMinX();
		final Real bMax = b.getMaxX();
		if (aMin == null || aMax == null || bMin == null || bMax == null)
			return;

		final LivingSpace aAnchor = findAnchorLivingSpace(a);
		final LivingSpace bAnchor = findAnchorLivingSpace(b);
		if (aAnchor == null || bAnchor == null)
			return;

		// Two "&"-parallel tiles that share a participant (e.g. `alice->alice`
		// & `alice->bob`, both anchored on alice) are NOT two independent
		// footprints that need pushing apart: they share the very point the
		// comparison would be based on, so "push whichever starts further
		// right" is meaningless here -- worse, since one side's bounds are
		// themselves DERIVED from that shared living space (e.g. a self-message
		// loop's own getMaxX() already includes alice's posC), asking for
		// disjointness can demand alice.posC >= alice.posC + something, a
		// self-contradictory constraint the solver can never satisfy
		// (RealLine.compile()'s fixed-point loop pushes forever and eventually
		// throws "Infinite Loop?"). Skip whenever the two sides share an
		// anchor; nothing needs reserving against itself.
		if (aAnchor == bAnchor)
			return;

		// Declaration order gives every participant's posB a monotonic initial
		// value (the same property isReverse() relies on), so this reliably
		// tells which side starts further left even before the RealLine solves.
		if (aAnchor.getPosB(stringBounder).getCurrentValue() <= bAnchor.getPosB(stringBounder).getCurrentValue())
			bMin.ensureBiggerThan(aMax);
		else
			aMin.ensureBiggerThan(bMax);
	}

	// Finds ANY LivingSpace this tile (or, for a GroupingTile, one of its
	// descendants) touches -- just needs to be SOME participant referenced by
	// the tile, since it's only used as a safe stand-in for "where is this
	// tile, roughly" (see the caching-safety comment on ensureDisjoint()
	// above). Returns null for tile kinds that don't carry a directly
	// resolvable participant (notes, dividers, references, ...) or an empty
	// group -- callers must treat null as "can't safely tell, skip".
	private LivingSpace findAnchorLivingSpace(Tile tile) {
		final Event event = tile.getEvent();
		if (event instanceof Message)
			return livingSpaces.get(((Message) event).getParticipant1());

		if (event instanceof MessageExo)
			return livingSpaces.get(((MessageExo) event).getParticipant());

		if (tile instanceof GroupingTile)
			for (Tile child : ((GroupingTile) tile).getChildTilesForAnchor()) {
				final LivingSpace found = findAnchorLivingSpace(child);
				if (found != null)
					return found;
			}

		return null;
	}

	public Real getMinX(StringBounder stringBounder) {
		return min;
	}

	public Real getMaxX(StringBounder stringBounder) {
		return max;
	}

	public boolean isShowFootbox() {
		return isShowFootbox;
	}

	public LivingSpaces getLivingSpaces() {
		return livingSpaces;
	}

	public List<Tile> getTiles() {
		return tiles;
	}

	public SequenceDiagram getDiagram() {
		return diagram;
	}

	public double getBorder1() {
		return min.getCurrentValue();
	}

	public double getBorder2() {
		return max.getCurrentValue();
	}

	public List<NewpageTile> getNewpageTiles() {
		final List<NewpageTile> result = new ArrayList<>();
		for (Tile tile : tiles) {
			if (tile instanceof GroupingTile)
				((GroupingTile) tile).addNewpageTiles(result);

			if (tile instanceof NewpageTile)
				result.add((NewpageTile) tile);
		}
		return result;
	}

	public List<Double> yNewPages() {
		final List<Double> yNewPages = new ArrayList<>();
		yNewPages.add(0.0);
		for (NewpageTile newpageTile : getNewpageTiles())
			yNewPages.add(newpageTile.getYGauge().getMin().getCurrentValue());

		yNewPages.add(Double.MAX_VALUE);
		return yNewPages;
	}

	public int getNbPages() {
		return yNewPages().size() - 1;
	}

}