diff --git a/modules/masonry/docs/technical-specification/Algorithm_Tree.md b/modules/masonry/docs/technical-specification/Algorithm_Tree.md new file mode 100644 index 00000000..33356102 --- /dev/null +++ b/modules/masonry/docs/technical-specification/Algorithm_Tree.md @@ -0,0 +1,179 @@ +# Tower Parsing & Layout Algorithm + +This document describes a **stack-based post-order traversal** that computes each brick’s SVG path, +bounding box, and notch‐connection points **after** all of its children have been measured. It +handles arbitrarily deep nesting, distinguishes **expression** vs. **simple** vs. **compound** +bricks, and avoids JavaScript call‐stack limits. + +--- + +## Data Structures + +```ts +// Represents one node in the tree (only UUID is needed here) +type TreeNode = { + uuid: string +} + +// Holds the computed metrics for a brick +type Metrics = { + path: string + bbox: { w: number; h: number } + connectionPoints: ConnectionPoints +} + +// Frame in our explicit stack +type Frame = { + node: TreeNode + visited: boolean +} + +// Work‐in‐progress structures +const metricsMap: Map = new Map() +const stack: Frame[] = [] +``` + +The model must expose: + +```ts +getRoots(): string[] + +getBrickType(uuid: string): 'expression' | 'simple' | 'compound' + +getExpressionArgs(uuid: string): string[] + +getNestedBlocks(uuid: string): string[] + +// Optionally for simple/compound +type StatementChildren = { top: string; bottom: string } +getStatementChildren(uuid: string): StatementChildren + +getBrickProps(uuid: string): { + label: string + type: 'expression' | 'simple' | 'compound' + strokeWidth: number + scale: number + topNotch: boolean + bottomNotch: boolean + fontSize: number +} +``` + +Shared utilities: + +```ts +generatePath(config): string +getBoundingBox(config): { w: number; h: number } +deriveNotches(path, bbox): ConnectionPoints +measureLabel(text, fontSize): { w: number; h: number; ascent: number; descent: number } +``` + +--- + +## Algorithm Steps + +1. **Initialize Work Structures** + + - Empty metrics map + + ```pseudo + metricsMap ← {} + stack ← [] + ``` + + - Seed roots + + ```pseudo + for each rootUuid in model.getRoots(): + stack.push({ node: { uuid: rootUuid }, visited: false }) + ``` + + We mark `visited = false` on first encounter (“children not yet handled”) and will re-push the +same node with `visited = true` (“ready to compute”) after its children. + +2. **Process Frames Until Done** + + ```pseudo + while stack is not empty: + (currentNode, visited) ← stack.pop() + + if visited == false: + // --- First encounter: enqueue children, defer parent --- + stack.push({ node: currentNode, visited: true }) + + // 1) Determine children based on brick type + type ← model.getBrickType(currentNode.uuid) + if type == 'expression': + children ← [] + else if type == 'simple': + children ← model.getExpressionArgs(currentNode.uuid) + + model.getStatementChildren(currentNode.uuid) + else if type == 'compound': + children ← model.getNestedBlocks(currentNode.uuid) + + model.getExpressionArgs(currentNode.uuid) + + model.getStatementChildren(currentNode.uuid) + + // 2) Push children so they come off *before* the parent’s second visit + for each childUuid in reverse(children): + stack.push({ node: { uuid: childUuid }, visited: false }) + + else: + // --- Second encounter: all children are ready, compute metrics --- + // 1) Gather child bounding boxes + childBBoxes ← [] + for each cUuid in model.getExpressionArgs(currentNode.uuid) + + model.getNestedBlocks(currentNode.uuid) + + model.getStatementChildren(currentNode.uuid): + childBBoxes.push(metricsMap[cUuid].bbox) + + // 2) Fetch brick props and measure its label + props ← model.getBrickProps(currentNode.uuid) + labelBox ← measureLabel(props.label, props.fontSize) + + // 3) Assemble config + config = { + type: props.type, + strokeWidth: props.strokeWidth, + scaleFactor: props.scale, + bBoxLabel: { w: labelBox.w, h: labelBox.h }, + bBoxArgs: childBBoxes, + hasNotchAbove: props.topNotch, + hasNotchBelow: props.bottomNotch + } + + // 4) Compute path, bbox, notch‐coords + path ← generatePath(config) + bbox ← getBoundingBox(config) + connectionPoints ← deriveNotches(path, bbox) + + // 5) Store into metricsMap + metricsMap[currentNode.uuid] = { path, bbox, connectionPoints } + ``` + + **Key invariant**: When a node’s frame is popped with `visited = true`, all of its children (and +their entire subtrees) have already been computed and stored in `metricsMap`. + +3. **Completion** + + When the stack empties, `metricsMap` contains the final layout data for every brick: + + - SVG outline (`path`) + - Dimensions (`bbox`) + - Notch coordinates (`connectionPoints`) + + You can now feed these into your React components or canvas renderer in a single, child‐first +batch. + +--- + +## Why This Approach + +- **Post‐order traversal** ensures each statement/compound block sizes itself around fully measured +plugged‐in bricks. +- **Explicit stack** avoids recursion limits—safe for arbitrary nesting depth. +- **Type‐aware child selection** respects the two “directions” of plug‐ins: + - Expression bricks never act as parents (no children). + - Simple bricks only host expression‐slot arguments (and top/bottom chain). + - Compound bricks first nest entire inner‐blocks, then expression args, then statement chaining. +- **No extra bookkeeping**: the “two‐push visited‐flag” trick implicitly tracks when children are +done without counters or complex state. diff --git a/modules/masonry/src/brick/@types/brick.d.ts b/modules/masonry/src/brick/@types/brick.d.ts index 45ab4bd9..a098dd2f 100644 --- a/modules/masonry/src/brick/@types/brick.d.ts +++ b/modules/masonry/src/brick/@types/brick.d.ts @@ -65,6 +65,8 @@ export type TBrickRenderPropsCompound = TBrickRenderProps & { isFolded: boolean; }; +import { TConnectionPoints } from '../../tree/model/model'; + /** * @interface * Type definition of a brick (any type). @@ -75,6 +77,7 @@ export interface IBrick { get type(): TBrickType; set scale(value: number); get boundingBox(): TExtent; + connectionPoints: TConnectionPoints; get visualState(): TVisualState; set visualState(value: TVisualState); diff --git a/modules/masonry/src/brick/model/model.ts b/modules/masonry/src/brick/model/model.ts index 3b150964..87a3994f 100644 --- a/modules/masonry/src/brick/model/model.ts +++ b/modules/masonry/src/brick/model/model.ts @@ -12,6 +12,9 @@ import type { IBrickCompound, TBrickRenderPropsCompound, } from '../@types/brick'; +import type { TConnectionPoints as TCP } from '../../tree/model/model'; +import { generateBrickData } from '../utils/path'; +import type { TInputUnion } from '../utils/path'; export abstract class BrickModel implements IBrick { protected _uuid: string; @@ -31,6 +34,9 @@ export abstract class BrickModel implements IBrick { protected _isActionMenuOpen = false; protected _isVisible = true; + protected _connectionPoints: TCP = { right: [] }; + protected _boundingBox: TExtent = { w: 0, h: 0 }; + constructor(params: { uuid: string; name: string; @@ -57,9 +63,11 @@ export abstract class BrickModel implements IBrick { this._shadow = params.shadow; this._tooltip = params.tooltip; this._bboxArgs = params.bboxArgs; + + this.boundingBox = { w: 0, h: 0 }; + this.connectionPoints = { right: [] }; } - // IBrick interface get uuid() { return this._uuid; } @@ -91,6 +99,20 @@ export abstract class BrickModel implements IBrick { this._isVisible = value; } + public get boundingBox(): TExtent { + return this._boundingBox; + } + public set boundingBox(box: TExtent) { + this._boundingBox = box; + } + + public get connectionPoints(): TCP { + return this._connectionPoints; + } + public set connectionPoints(points: TCP) { + this._connectionPoints = points; + } + protected getCommonRenderProps(): TBrickRenderProps { return { path: '', @@ -110,23 +132,18 @@ export abstract class BrickModel implements IBrick { }; } - /** Must compute the overall bounding box. */ - public abstract get boundingBox(): TExtent; - /** Must assemble the full render props for this brick. */ public abstract get renderProps(): TBrickRenderProps; } /** * @class - * Final concrete class for Simple-statement bricks., + * Final concrete class for Simple-statement bricks. */ export class SimpleBrick extends BrickModel implements IBrickSimple { private _topNotch: boolean; private _bottomNotch: boolean; - private _boundingBox: TExtent = { w: 0, h: 0 }; - constructor(params: { uuid: string; name: string; @@ -138,7 +155,6 @@ export class SimpleBrick extends BrickModel implements IBrickSimple { shadow: boolean; tooltip?: string; scale: number; - bboxArgs: TExtent[]; topNotch: boolean; bottomNotch: boolean; @@ -160,23 +176,33 @@ export class SimpleBrick extends BrickModel implements IBrickSimple { this._topNotch = params.topNotch; this._bottomNotch = params.bottomNotch; + this.updateGeometry(); + } + + public updateGeometry() { + const config: TInputUnion = { + type: 'type1', + strokeWidth: this._strokeWidth, + scaleFactor: this._scale, + bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxArgs: this._bboxArgs, + hasNotchAbove: this._topNotch, + hasNotchBelow: this._bottomNotch, + }; + const data = generateBrickData(config); + + // use the public setters + this.connectionPoints = data.connectionPoints; + this.boundingBox = data.boundingBox; } public get topNotch(): boolean { return this._topNotch; } - public get bottomNotch(): boolean { return this._bottomNotch; } - public get boundingBox(): TExtent { - return this._boundingBox; - } - public set boundingBox(box: TExtent) { - this._boundingBox = box; - } - public override get renderProps(): TBrickRenderPropsSimple { return { ...this.getCommonRenderProps(), @@ -194,8 +220,6 @@ export class ExpressionBrick extends BrickModel implements IBrickExpression { private _value?: boolean | number | string; private _isValueSelectOpen: boolean; - private _boundingBox: TExtent = { w: 0, h: 0 }; - constructor(params: { uuid: string; name: string; @@ -227,13 +251,26 @@ export class ExpressionBrick extends BrickModel implements IBrickExpression { }); this._value = params.value; - this._isValueSelectOpen = params.isValueSelectOpen ?? false; + this._isValueSelectOpen = params.isValueSelectOpen || false; + this.updateGeometry(); + } + + public updateGeometry() { + const config: TInputUnion = { + type: 'type2', + strokeWidth: this._strokeWidth, + scaleFactor: this._scale, + bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxArgs: this._bboxArgs, + }; + const data = generateBrickData(config); + this.connectionPoints = data.connectionPoints; + this.boundingBox = data.boundingBox; } public get value(): boolean | number | string | undefined { return this._value; } - public get isValueSelectOpen(): boolean { return this._isValueSelectOpen; } @@ -241,13 +278,6 @@ export class ExpressionBrick extends BrickModel implements IBrickExpression { this._isValueSelectOpen = open; } - public get boundingBox(): TExtent { - return this._boundingBox; - } - public set boundingBox(box: TExtent) { - this._boundingBox = box; - } - public override get renderProps(): TBrickRenderPropsExpression { return { ...this.getCommonRenderProps(), @@ -267,8 +297,6 @@ export default class CompoundBrick extends BrickModel implements IBrickCompound private _isFolded: boolean; private _bboxNest: TExtent[]; - private _boundingBox: TExtent = { w: 0, h: 0 }; - constructor(params: { uuid: string; name: string; @@ -280,13 +308,9 @@ export default class CompoundBrick extends BrickModel implements IBrickCompound shadow: boolean; tooltip?: string; scale: number; - bboxArgs: TExtent[]; - bboxNest: TExtent[]; - isFolded?: boolean; - topNotch: boolean; bottomNotch: boolean; }) { @@ -308,39 +332,46 @@ export default class CompoundBrick extends BrickModel implements IBrickCompound this._topNotch = params.topNotch; this._bottomNotch = params.bottomNotch; this._bboxNest = params.bboxNest; - this._isFolded = params.isFolded ?? false; + this._isFolded = params.isFolded || false; + this.updateGeometry(); + } + + public updateGeometry() { + const config: TInputUnion = { + type: 'type3', + strokeWidth: this._strokeWidth, + scaleFactor: this._scale, + bBoxLabel: { w: this._label.length * 8, h: 20 }, + bBoxArgs: this._bboxArgs, + hasNotchAbove: this._topNotch, + hasNotchBelow: this._bottomNotch, + bBoxNesting: this._bboxNest, + secondaryLabel: true, + }; + const data = generateBrickData(config); + this.connectionPoints = data.connectionPoints; + this.boundingBox = data.boundingBox; } public get topNotch(): boolean { return this._topNotch; } - public get bottomNotch(): boolean { return this._bottomNotch; } - public get bboxNest(): TExtent[] { return this._bboxNest; } - public get isFolded(): boolean { return this._isFolded; } public set isFolded(v: boolean) { this._isFolded = v; } - public setBoundingBoxNest(extents: TExtent[]): void { this._bboxNest = extents; } - public get boundingBox(): TExtent { - return this._boundingBox; - } - public set boundingBox(box: TExtent) { - this._boundingBox = box; - } - public override get renderProps(): TBrickRenderPropsCompound { return { ...this.getCommonRenderProps(), diff --git a/modules/masonry/src/brick/utils/path.ts b/modules/masonry/src/brick/utils/path.ts index 77ee127e..405411c1 100644 --- a/modules/masonry/src/brick/utils/path.ts +++ b/modules/masonry/src/brick/utils/path.ts @@ -55,7 +55,7 @@ function _generateNotchTop(): string[] { } // function for Bottom Notch generation -function _generateNotchBottom(strokeWidth: number): string[] { +function _generateNotchBottom(_strokeWidth: number): string[] { return [ //, 'h -1', @@ -87,7 +87,7 @@ function _generateTop(config: { strokeWidth: number; bBoxLabel: TBBox; }): string[] { - const { type, hasNotch, strokeWidth, bBoxLabel, hasArgs } = config; + const { type: _type, hasNotch, strokeWidth, bBoxLabel, hasArgs } = config; // Corner Radius + Offset + Notch + Variable width + Corner Radius = Stroke Width/2 + LabelBounding Box width + Stroke Width/2 if (hasArgs) { @@ -191,26 +191,26 @@ function _generateLeft(config: { hasNotch: boolean; strokeWidth: number; rightVertical: number; -}): string[] { - const { type, hasNotch, rightVertical } = config; +}): { path: string[]; leftEdge: number } { + const { type: _type, hasNotch, rightVertical } = config; const path: string[] = []; - let leftEdge = rightVertical; + let _leftEdge = rightVertical; // For vertical leg — match exact vertical height from right - if (type === 'type2') { - leftEdge -= CONN_NOTCH_WIDTH + CORNER_RADIUS; - path.push(`v -${leftEdge}`); + if (_type === 'type2') { + _leftEdge -= CONN_NOTCH_WIDTH + CORNER_RADIUS; + path.push(`v -${_leftEdge}`); if (hasNotch) { path.push(..._generateNotchLeft()); } } else { - leftEdge -= CORNER_RADIUS; - path.push(`v -${leftEdge.toFixed(2)}`); + _leftEdge -= CORNER_RADIUS; + path.push(`v -${_leftEdge.toFixed(2)}`); } - return path; + return { path, leftEdge: _leftEdge }; } // function to generate the nested path for type3 bricks @@ -341,9 +341,10 @@ function _generateBottom(config: { // ----------------------------------------------------------- -// Main function to generate the path based on the configuration -export function generatePath(config: TInputType1 | TInputType2 | TInputType3): { +// function to generate the path based on the configuration +function generatePath(config: TInputType1 | TInputType2 | TInputType3): { path: string; + leftEdge: number; } { const hasNotchTop = config.type !== 'type2' && config.hasNotchAbove; @@ -385,29 +386,27 @@ export function generatePath(config: TInputType1 | TInputType2 | TInputType3): { bBoxNesting: bBoxNesting, }); - const left = _generateLeft({ + const leftResult = _generateLeft({ type: config.type, hasNotch: true, strokeWidth: config.strokeWidth, - rightVertical: rightVertical, + rightVertical, }); + const left = leftResult.path; + const leftEdge = leftResult.leftEdge; + const segments = [...top, ...right, ...bottom, ...left]; return { path: ['M 0,0', ...segments].join(' '), + leftEdge: leftEdge, }; } - -// function to calculate the bounding box values -export function getBoundingBox(config: TInputUnion): TBBox { - const { - strokeWidth, - bBoxLabel, - bBoxArgs, - type, - } = config; +// function to calculate the bounding box values +function getBoundingBox(config: TInputUnion): TBBox { + const { strokeWidth, bBoxLabel, bBoxArgs, type } = config; const hasArgs = bBoxArgs.length > 0; const labelWidth = Math.max(MIN_LABEL_WIDTH, bBoxLabel.w); @@ -423,12 +422,11 @@ export function getBoundingBox(config: TInputUnion): TBBox { CORNER_RADIUS; // Base width as per _generateTop and _generateBottom logic - const baseWidth = - CORNER_RADIUS + OFFSET_NOTCH_TOP + WIDTH_NOTCH_TOP + variableTopWidth; + const baseWidth = CORNER_RADIUS + OFFSET_NOTCH_TOP + WIDTH_NOTCH_TOP + variableTopWidth; const width = hasArgs - ? baseWidth + OFFSET_NOTCH_RIGHT + CORNER_RADIUS + strokeWidth/2 - : baseWidth + CORNER_RADIUS +strokeWidth/2; + ? baseWidth + OFFSET_NOTCH_RIGHT + CORNER_RADIUS + strokeWidth / 2 + : baseWidth + CORNER_RADIUS + strokeWidth / 2; // Get rightVertical from _generateRight const { vertical: rightVertical } = _generateRight({ @@ -438,13 +436,10 @@ export function getBoundingBox(config: TInputUnion): TBBox { bBoxArgs, }); - let height = rightVertical + CORNER_RADIUS + (type !== 'type3' ? strokeWidth/2 : 0); + let height = rightVertical + CORNER_RADIUS + (type !== 'type3' ? strokeWidth / 2 : 0); if (type === 'type3') { - const { - bBoxNesting, - secondaryLabel, - } = config as TInputType3; + const { bBoxNesting, secondaryLabel } = config as TInputType3; // Reuse nested path logic let nestingHeight = bBoxNesting.reduce((sum, box) => sum + box.h, 0); @@ -455,10 +450,13 @@ export function getBoundingBox(config: TInputUnion): TBBox { ? strokeWidth / 2 + labelHeight + strokeWidth / 2 - CORNER_RADIUS * 2 : 4; - const nestedTotal = OUTER_CORNER_RADIUS + + const nestedTotal = + OUTER_CORNER_RADIUS + (nestingHeight - (strokeWidth / 2 + OUTER_CORNER_RADIUS * 2 + strokeWidth / 2)) + - OUTER_CORNER_RADIUS + CORNER_RADIUS + - labelAreaHeight + CORNER_RADIUS; + OUTER_CORNER_RADIUS + + CORNER_RADIUS + + labelAreaHeight + + CORNER_RADIUS; height += nestedTotal; } @@ -468,3 +466,153 @@ export function getBoundingBox(config: TInputUnion): TBBox { h: height, }; } + +// functions to calculate coordinates of the connection points + +type TCentroid = { x: number; y: number }; + +// Centroid calculation for Top Notch +function getTopCentroid(config: TInputUnion): TCentroid | undefined { + if (config.type === 'type2' || !config.hasNotchAbove) return undefined; + + return { + x: CORNER_RADIUS + OFFSET_NOTCH_TOP + WIDTH_NOTCH_TOP / 2, + y: 1, + }; +} + +// Centroid calculation for Bottom Notch +function getBottomCentroid( + config: TInputUnion, + boundingBox: TBBox, + _leftEdge: number, +): TCentroid | undefined { + if (config.type === 'type2' || !config.hasNotchBelow) return undefined; + + if (config.type !== 'type3') { + return { + x: CORNER_RADIUS + OFFSET_NOTCH_BOTTOM + WIDTH_NOTCH_BOTTOM / 2, + y: boundingBox.h - 1, // Place at the bottom edge + }; + } + + return { + x: + CORNER_RADIUS + + OFFSET_NOTCH_TOP + + WIDTH_NOTCH_TOP / 2 + + OFFSET_NOTCH_BOTTOM + + WIDTH_NOTCH_BOTTOM / 2, //used the logic for top notch centroid + y: boundingBox.h - 1, // Place at the bottom edge for type3 as well + }; +} + +// Calculate centroids for right connector notch +function getRightCentroids(config: TInputUnion, boundingBox: TBBox): TCentroid[] { + const { bBoxArgs, bBoxLabel, strokeWidth } = config; + + // No argument = no right notches + if (!bBoxArgs.length) return []; + + const centroids: TCentroid[] = []; + + const labelHeight = Math.max(MIN_LABEL_HEIGHT, bBoxLabel.h); + const requiredMinimum = strokeWidth / 2 + labelHeight + strokeWidth / 2; + const argHeightsSum = bBoxArgs.reduce((sum, arg) => sum + arg.h, 0); + + const extra = Math.max(0, requiredMinimum - argHeightsSum); + + let verticalOffset = CORNER_RADIUS; // top-right corner arc + + for (let i = 0; i < bBoxArgs.length; i++) { + const extraPerArg = extra / bBoxArgs.length; + const argBox = bBoxArgs[i]; + + // v4, v-3, v10, v-3, v4 = total 12 + const fixedNotchHeight = 12; + + const variableLength = Math.max( + 0, + argBox.h + + extraPerArg - + (strokeWidth / 2 + + CORNER_RADIUS + + HEIGHT_NOTCH_RIGHT + + CORNER_RADIUS + + strokeWidth / 2 + + CORNER_RADIUS + + strokeWidth + + CORNER_RADIUS), + ); + + // Centroid placed in the middle of v10 + const centroidY = verticalOffset + 6; + + centroids.push({ + x: boundingBox.w - 6, // notch is 12 wide, so center is at 6 from right edge + y: centroidY, + }); + + verticalOffset += fixedNotchHeight; // 12 units fixed + + if (variableLength > 0) { + verticalOffset += variableLength; + } + + if (i < bBoxArgs.length - 1) { + verticalOffset += CORNER_RADIUS + strokeWidth + CORNER_RADIUS; // 4 + 2 + 4 = 10 units + } + } + + return centroids; +} + +// Calculate centroid for left connector notch +function getLeftCentroid(config: TInputUnion, _boundingBox: TBBox): TCentroid | undefined { + if (config.type !== 'type2') return undefined; + + return { + x: -7, // notch is made of h -6, h -2 → center = -7 + y: CORNER_RADIUS + CONN_NOTCH_WIDTH / 2, + }; +} + +function getConnectionPoints( + config: TInputUnion, + boundingBox: TBBox, + leftEdge: number, +): { + top?: TCentroid; + right: TCentroid[]; + bottom?: TCentroid; + left?: TCentroid; +} { + return { + top: getTopCentroid(config), + right: getRightCentroids(config, boundingBox), + bottom: getBottomCentroid(config, boundingBox, leftEdge), + left: getLeftCentroid(config, boundingBox), + }; +} + +// single export function to return brick data +export function generateBrickData(config: TInputType1 | TInputType2 | TInputType3): { + path: string; + boundingBox: TBBox; + connectionPoints: { + top?: TCentroid; + right: TCentroid[]; + bottom?: TCentroid; + left?: TCentroid; + }; +} { + const { path, leftEdge } = generatePath(config); + const boundingBox = getBoundingBox(config); + const connectionPoints = getConnectionPoints(config, boundingBox, leftEdge); + + return { + path, + boundingBox, + connectionPoints, + }; +} diff --git a/modules/masonry/src/brick/utils/spec/path.spec.ts b/modules/masonry/src/brick/utils/spec/path.spec.ts index 3e1285d5..7f7604ac 100644 --- a/modules/masonry/src/brick/utils/spec/path.spec.ts +++ b/modules/masonry/src/brick/utils/spec/path.spec.ts @@ -1,4 +1,4 @@ -import { generatePath } from '../path'; +import { generateBrickData } from '../path'; import type { TInputUnion } from '../path'; describe('Masonry: Brick > Path Generation', () => { @@ -118,10 +118,46 @@ describe('Masonry: Brick > Path Generation', () => { ]; testCases.forEach(({ name, input }) => { - it(`generates path correctly: ${name}`, () => { - const result = generatePath(input as TInputUnion); - expect(typeof result.path).toBe('string'); - expect(result.path.length).toBeGreaterThan(0); + it(`generates path, bounding box, and connection points correctly: ${name}`, () => { + const { path, boundingBox, connectionPoints } = generateBrickData(input as TInputUnion); + + // Validate the path + expect(typeof path).toBe('string'); + expect(path.length).toBeGreaterThan(0); + + // Validate the bounding box + expect(typeof boundingBox).toBe('object'); + expect(boundingBox).toHaveProperty('w'); + expect(boundingBox).toHaveProperty('h'); + expect(typeof boundingBox.w).toBe('number'); + expect(typeof boundingBox.h).toBe('number'); + expect(boundingBox.w).toBeGreaterThan(0); + expect(boundingBox.h).toBeGreaterThan(0); + + // Validate connection points + expect(connectionPoints).toHaveProperty('right'); + expect(Array.isArray(connectionPoints.right)).toBe(true); + connectionPoints.right.forEach((pt) => { + expect(pt).toHaveProperty('x'); + expect(pt).toHaveProperty('y'); + expect(typeof pt.x).toBe('number'); + expect(typeof pt.y).toBe('number'); + }); + + if (connectionPoints.left) { + expect(typeof connectionPoints.left.x).toBe('number'); + expect(typeof connectionPoints.left.y).toBe('number'); + } + + if (connectionPoints.top) { + expect(typeof connectionPoints.top.x).toBe('number'); + expect(typeof connectionPoints.top.y).toBe('number'); + } + + if (connectionPoints.bottom) { + expect(typeof connectionPoints.bottom.x).toBe('number'); + expect(typeof connectionPoints.bottom.y).toBe('number'); + } }); }); }); diff --git a/modules/masonry/src/brick/view/components/compound.tsx b/modules/masonry/src/brick/view/components/compound.tsx index bd5fe127..f0d5e894 100644 --- a/modules/masonry/src/brick/view/components/compound.tsx +++ b/modules/masonry/src/brick/view/components/compound.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import type { TBrickRenderPropsCompound } from '../../@types/brick'; -import { generatePath, getBoundingBox } from '../../utils/path'; +import { generateBrickData } from '../../utils/path'; const FONT_HEIGHT = 16; @@ -35,7 +35,18 @@ function measureLabel(label: string, fontSize: number) { }; } -export const CompoundBrickView: React.FC = (props) => { +type TConnectionPoints = { + top?: { x: number; y: number }; + right: { x: number; y: number }[]; + bottom?: { x: number; y: number }; + left?: { x: number; y: number }; +}; + +type PropsWithMetrics = TBrickRenderPropsCompound & { + RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; +}; + +export const CompoundBrickView: React.FC = (props) => { const { label, labelType, @@ -54,10 +65,15 @@ export const CompoundBrickView: React.FC = (props) => bottomNotch, bboxNest, isFolded, + RenderMetrics, } = props; - const { w: labelW, h: labelH, ascent } = measureLabel(label, FONT_HEIGHT); - const bBoxLabel = { w: labelW, h: labelH }; + // Memoize bBoxLabel to prevent unnecessary recalculations + const bBoxLabel = useMemo(() => { + const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); + return { w: labelW, h: labelH }; + }, [label]); + const bBoxNesting = bboxNest; const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { @@ -72,9 +88,12 @@ export const CompoundBrickView: React.FC = (props) => bBoxNesting, secondaryLabel: !isFolded, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - return { path, w, h }; + const brickData = generateBrickData(cfg); + return { + path: brickData.path, + w: brickData.boundingBox.w, + h: brickData.boundingBox.h, + }; }); useEffect(() => { @@ -89,10 +108,23 @@ export const CompoundBrickView: React.FC = (props) => bBoxNesting, secondaryLabel: !isFolded, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - setShape({ path, w, h }); - }, [label, strokeWidth, scale, bboxArgs, topNotch, bottomNotch, bboxNest, isFolded]); + const brickData = generateBrickData(cfg); + if (RenderMetrics) { + RenderMetrics(brickData.boundingBox, brickData.connectionPoints); + } + setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); + }, [ + label, + strokeWidth, + scale, + bboxArgs, + topNotch, + bottomNotch, + bboxNest, + isFolded, + bBoxLabel, + bBoxNesting, + ]); if (!isVisible) return null; @@ -122,7 +154,7 @@ export const CompoundBrickView: React.FC = (props) => {labelType === 'text' && ( = (props) => { +type TConnectionPoints = { + top?: { x: number; y: number }; + right: { x: number; y: number }[]; + bottom?: { x: number; y: number }; + left?: { x: number; y: number }; +}; + +type PropsWithMetrics = TBrickRenderPropsExpression & { + RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; +}; + +export const ExpressionBrickView: React.FC = (props) => { const { label, labelType, @@ -49,11 +60,15 @@ export const ExpressionBrickView: React.FC = (props visualState, isActionMenuOpen, isVisible, + RenderMetrics, // value, isValueSelectOpen, // if needed later } = props; - const { w: labelW, h: labelH, ascent } = measureLabel(label, FONT_HEIGHT); - const bBoxLabel = { w: labelW, h: labelH }; + // Memoize bBoxLabel to prevent unnecessary recalculations + const bBoxLabel = useMemo(() => { + const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); + return { w: labelW, h: labelH }; + }, [label]); const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { const cfg = { @@ -63,9 +78,12 @@ export const ExpressionBrickView: React.FC = (props bBoxLabel, bBoxArgs: bboxArgs, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - return { path, w, h }; + const brickData = generateBrickData(cfg); + return { + path: brickData.path, + w: brickData.boundingBox.w, + h: brickData.boundingBox.h, + }; }); useEffect(() => { @@ -76,10 +94,12 @@ export const ExpressionBrickView: React.FC = (props bBoxLabel, bBoxArgs: bboxArgs, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - setShape({ path, w, h }); - }, [label, strokeWidth, scale, bboxArgs]); + const brickData = generateBrickData(cfg); + if (RenderMetrics) { + RenderMetrics(brickData.boundingBox, brickData.connectionPoints); + } + setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); + }, [label, strokeWidth, scale, bboxArgs, bBoxLabel]); if (!isVisible) return null; @@ -109,7 +129,7 @@ export const ExpressionBrickView: React.FC = (props {labelType === 'text' && ( = (props) => { +type TConnectionPoints = { + top?: { x: number; y: number }; + right: { x: number; y: number }[]; + bottom?: { x: number; y: number }; + left?: { x: number; y: number }; +}; + +type PropsWithMetrics = TBrickRenderPropsSimple & { + RenderMetrics?: (bbox: { w: number; h: number }, connectionPoints: TConnectionPoints) => void; +}; + +export const SimpleBrickView: React.FC = (props) => { const { label, labelType, @@ -51,10 +62,14 @@ export const SimpleBrickView: React.FC = (props) => { isVisible, topNotch, bottomNotch, + RenderMetrics, } = props; - const { w: labelW, h: labelH, ascent } = measureLabel(label, FONT_HEIGHT); - const bBoxLabel = { w: labelW, h: labelH }; + // Memoize bBoxLabel to prevent unnecessary recalculations + const bBoxLabel = useMemo(() => { + const { w: labelW, h: labelH } = measureLabel(label, FONT_HEIGHT); + return { w: labelW, h: labelH }; + }, [label]); const [shape, setShape] = useState<{ path: string; w: number; h: number }>(() => { const cfg = { @@ -66,9 +81,12 @@ export const SimpleBrickView: React.FC = (props) => { hasNotchAbove: topNotch, hasNotchBelow: bottomNotch, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - return { path, w, h }; + const brickData = generateBrickData(cfg); + return { + path: brickData.path, + w: brickData.boundingBox.w, + h: brickData.boundingBox.h, + }; }); useEffect(() => { @@ -81,10 +99,12 @@ export const SimpleBrickView: React.FC = (props) => { hasNotchAbove: topNotch, hasNotchBelow: bottomNotch, }; - const { path } = generatePath(cfg); - const { w, h } = getBoundingBox(cfg); - setShape({ path, w, h }); - }, [label, strokeWidth, scale, bboxArgs, topNotch, bottomNotch]); + const brickData = generateBrickData(cfg); + if (RenderMetrics) { + RenderMetrics(brickData.boundingBox, brickData.connectionPoints); + } + setShape({ path: brickData.path, w: brickData.boundingBox.w, h: brickData.boundingBox.h }); + }, [label, strokeWidth, scale, bboxArgs, topNotch, bottomNotch, bBoxLabel]); if (!isVisible) return null; @@ -111,7 +131,7 @@ export const SimpleBrickView: React.FC = (props) => { {labelType === 'text' && ( ; +}; + +// Tree structure representing connected bricks +export type TTree = { + id: string; + nodes: Map; + connections: TBrickConnection[]; +}; + +// Connection validation result +export type TConnectionValidation = { + isValid: boolean; + reason?: string; +}; + +// Main tree manager class +export default class BrickTreeManager { + private trees: TTree[] = []; + private nextTreeId = 1; + + constructor() {} + + /** + * Finds the ID of a notch on a brick based on connection point coordinates. + * @param brick The brick to search on. + * @param point The connection point coordinates. + * @returns The notch ID (e.g., 'left', 'right_0') or null if not found. + */ + private findNotchId(brick: IBrick, point: TConnectionPoint): string | null { + const { connectionPoints } = brick; + const tolerance = 1.5; // Use a slightly larger tolerance + + if ( + connectionPoints.top && + Math.hypot(connectionPoints.top.x - point.x, connectionPoints.top.y - point.y) < + tolerance + ) { + return 'top'; + } + if ( + connectionPoints.bottom && + Math.hypot(connectionPoints.bottom.x - point.x, connectionPoints.bottom.y - point.y) < + tolerance + ) { + return 'bottom'; + } + if ( + connectionPoints.left && + Math.hypot(connectionPoints.left.x - point.x, connectionPoints.left.y - point.y) < + tolerance + ) { + return 'left'; + } + for (let i = 0; i < connectionPoints.right.length; i++) { + const rightPoint = connectionPoints.right[i]; + if (Math.hypot(rightPoint.x - point.x, rightPoint.y - point.y) < tolerance) { + return `right_${i}`; + } + } + return null; + } + + /** + * Creates a new tree with a single brick + */ + public createTree(brick: IBrick, position: TPoint): TTree { + const treeId = `tree_${this.nextTreeId++}`; + const node: TTreeNode = { + brick, + position, + parent: null, + connectedNotches: new Set(), + }; + const tree: TTree = { + id: treeId, + nodes: new Map([[brick.uuid, node]]), + connections: [], + }; + this.trees.push(tree); + return tree; + } + + /** + * Adds a brick to an existing tree + */ + public addBrickToTree(treeId: string, brick: IBrick, parentBrickId: string, position: TPoint) { + const tree = this.trees.find((t) => t.id === treeId); + if (!tree) return; + + const parentNode = tree.nodes.get(parentBrickId); + if (!parentNode) return; + + const node: TTreeNode = { + brick, + position, + parent: parentNode, + connectedNotches: new Set(), + }; + tree.nodes.set(brick.uuid, node); + } + + /** + * Connects two trees or bricks with validation + */ + public connectBricks( + fromBrickId: string, + toBrickId: string, + fromPoint: TConnectionPoint, + toPoint: TConnectionPoint, + connectionType: TNotchType, + ): string | null { + const fromBrickNode = this.getBrickNode(fromBrickId); + const toBrickNode = this.getBrickNode(toBrickId); + + if (!fromBrickNode || !toBrickNode) return null; + + // Validate that both bricks exist and can be connected + // The connection points represent the specific notches where bricks will connect + + const fromNotchId = this.findNotchId(fromBrickNode.brick, fromPoint); + const toNotchId = this.findNotchId(toBrickNode.brick, toPoint); + + // Find the specific notch IDs for both bricks based on their connection points + // These IDs are used to track which notches are occupied + + if (!fromNotchId || !toNotchId) { + console.error('Could not determine notch IDs for connection'); + return null; + } + + // Check if the notches are already connected to other bricks + // A notch can only be connected to one other notch at a time + + if ( + fromBrickNode.connectedNotches.has(fromNotchId) || + toBrickNode.connectedNotches.has(toNotchId) + ) { + console.error('One or both notches are already connected'); + return null; + } + + // Mark both notches as connected to prevent future connections + fromBrickNode.connectedNotches.add(fromNotchId); + toBrickNode.connectedNotches.add(toNotchId); + + const fromTree = this.findTreeByBrickId(fromBrickId); + const toTree = this.findTreeByBrickId(toBrickId); + + if (!fromTree || !toTree) return null; + + const connection: TBrickConnection = { + from: fromBrickId, + to: toBrickId, + fromNotchId, + toNotchId, + type: connectionType, + }; + + if (fromTree.id === toTree.id) { + // Both bricks are already in the same tree, just add the new connection + fromTree.connections.push(connection); + this.updateParentChildRelationships(fromBrickId, toBrickId, connection.type); + return fromTree.id; + } + + // Bricks are in different trees, merge them into a single tree + const mergedTree = this.mergeTrees(fromTree, toTree, connection); + this.updateParentChildRelationships(fromBrickId, toBrickId, connection.type); + return mergedTree.id; + } + + private mergeTrees(tree1: TTree, tree2: TTree, connection: TBrickConnection): TTree { + tree2.nodes.forEach((node, brickId) => { + tree1.nodes.set(brickId, node); + }); + tree2.connections.forEach((conn) => { + tree1.connections.push(conn); + }); + tree1.connections.push(connection); + this.trees = this.trees.filter((t) => t.id !== tree2.id); + return tree1; + } + + /** + * Updates parent-child relationships based on connection type + */ + private updateParentChildRelationships( + fromBrickId: string, + toBrickId: string, + notchType: TNotchType, + ) { + const fromNode = this.getBrickNode(fromBrickId); + const toNode = this.getBrickNode(toBrickId); + if (!fromNode || !toNode) return; + + if (notchType === 'top-bottom' || notchType === 'right-left') { + toNode.parent = fromNode; + } else if (notchType === 'left-right') { + fromNode.parent = toNode; + } + } + + /** + * Disconnects a brick from its tree + * Handles hierarchical relationships like a folder structure: + * - If disconnecting a parent, all children remain connected to it + * - If disconnecting a child, it becomes a separate tree + */ + public disconnectBrick(brickId: string): { + removedConnections: TBrickConnection[]; + newTreeIds: string[]; + } { + const brickNode = this.getBrickNode(brickId); + if (!brickNode) return { removedConnections: [], newTreeIds: [] }; + + const originalTree = this.findTreeByBrickId(brickId); + if (!originalTree) return { removedConnections: [], newTreeIds: [] }; + + // Step 1: Collect all descendant nodes that will move with the disconnected brick + // This includes the brick itself and all its children (hierarchical behavior) + const nodesToMove = new Map(); + const stack: TTreeNode[] = [brickNode]; + const visited = new Set([brickNode.brick.uuid]); + + while (stack.length > 0) { + const currentNode = stack.pop()!; + nodesToMove.set(currentNode.brick.uuid, currentNode); + + // Add all children of the current node to the stack for processing + this.getBrickChildren(currentNode.brick.uuid).forEach((child) => { + if (!visited.has(child.brick.uuid)) { + visited.add(child.brick.uuid); + stack.push(child); + } + }); + } + + // Step 2: Identify connections that need to be removed from the original tree + // Remove connections where: + // - Both nodes are moving to the new tree (internal connections) + // - One node is moving and the other stays (cross-tree connections) + const connectionsToRemove = originalTree.connections.filter((conn) => { + const fromInNewTree = nodesToMove.has(conn.from); + const toInNewTree = nodesToMove.has(conn.to); + + // Remove connections where both nodes are moving to the new tree + // OR connections where one node is moving and the other stays in original tree + const shouldRemove = + (fromInNewTree && toInNewTree) || + (fromInNewTree && !toInNewTree) || + (!fromInNewTree && toInNewTree); + + return shouldRemove; + }); + + if (connectionsToRemove.length === 0) return { removedConnections: [], newTreeIds: [] }; + + // Step 3: Remove connections and nodes from the original tree + this.removeConnections(originalTree, connectionsToRemove); + nodesToMove.forEach((node, brickId) => { + originalTree.nodes.delete(brickId); + }); + + // Step 4: Create a new tree with the disconnected brick as root + const newTree = this.createTree(brickNode.brick, brickNode.position); + + // Step 5: Add all descendant nodes to the new tree + nodesToMove.forEach((node, brickId) => { + if (brickId !== brickNode.brick.uuid) { + // Don't add the root twice + newTree.nodes.set(brickId, node); + } + }); + + // Step 6: Move internal connections to the new tree + const connectionsToMove = connectionsToRemove.filter( + (conn) => nodesToMove.has(conn.from) && nodesToMove.has(conn.to), + ); + newTree.connections = connectionsToMove; + + // Step 7: Update parent relationships for the new tree + // The disconnected brick becomes the root (no parent) + brickNode.parent = null; + + // All other nodes in the new tree should have their parent relationships preserved + // but only if their parent is also in the new tree + nodesToMove.forEach((node, brickId) => { + if (brickId !== brickNode.brick.uuid && node.parent) { + if (!nodesToMove.has(node.parent.brick.uuid)) { + // If parent is not in the new tree, this node becomes a direct child of the root + node.parent = brickNode; + } + } + }); + + // Step 8: Clean up original tree if it's empty + if (originalTree.nodes.size === 0) { + this.trees = this.trees.filter((t) => t.id !== originalTree.id); + } + + return { removedConnections: connectionsToRemove, newTreeIds: [newTree.id] }; + } + + private removeConnections(tree: TTree, connectionsToRemove: TBrickConnection[]) { + connectionsToRemove.forEach((conn) => { + const fromNode = this.getBrickNode(conn.from); + const toNode = this.getBrickNode(conn.to); + if (fromNode && conn.fromNotchId) fromNode.connectedNotches.delete(conn.fromNotchId); + if (toNode && conn.toNotchId) toNode.connectedNotches.delete(conn.toNotchId); + if (toNode && toNode.parent?.brick.uuid === conn.from) toNode.parent = null; + if (fromNode && fromNode.parent?.brick.uuid === conn.to) fromNode.parent = null; + }); + tree.connections = tree.connections.filter((c) => !connectionsToRemove.includes(c)); + } + + /** + * Gets a tree by ID + */ + public getTree(treeId: string): TTree | undefined { + return this.trees.find((t) => t.id === treeId); + } + + /** + * Gets all trees + */ + public getAllTrees(): TTree[] { + return this.trees; + } + + /** + * Gets all brick IDs in a tree + */ + public getBricksInTree(treeId: string): IBrick[] { + const tree = this.getTree(treeId); + return tree ? Array.from(tree.nodes.values()).map((n) => n.brick) : []; + } + + /** + * Checks if two bricks are connected + */ + public areBricksConnected(brickId1: string, brickId2: string): boolean { + for (const tree of this.trees) { + if ( + tree.connections.some( + (c) => + (c.from === brickId1 && c.to === brickId2) || + (c.from === brickId2 && c.to === brickId1), + ) + ) { + return true; + } + } + return false; + } + + /** + * Gets all connections for a brick + */ + public getBrickConnections(brickId: string): TBrickConnection[] { + const tree = this.getTree(brickId); + if (!tree) return []; + + return tree.connections.filter((conn) => conn.from === brickId || conn.to === brickId); + } + + /** + * Moves a brick within its tree + */ + public moveBrick(brickId: string, newPosition: { x: number; y: number }): boolean { + const tree = this.getTree(brickId); + if (!tree) return false; + + const node = tree.nodes.get(brickId); + if (!node) return false; + + node.position = newPosition; + + return true; + } + + /** + * Gets the path from root to a specific brick + */ + public getPathToBrick(brickId: string): string[] { + const tree = this.getTree(brickId); + if (!tree) return []; + + const path: string[] = []; + let currentBrickId = brickId; + + while (currentBrickId) { + path.unshift(currentBrickId); + const node = tree.nodes.get(currentBrickId); + currentBrickId = node?.parent?.brick.uuid || ''; + } + + return path; + } + + /** + * Gets all children of a brick + */ + public getBrickChildren(brickId: string): TTreeNode[] { + const children: TTreeNode[] = []; + for (const tree of this.trees) { + for (const node of tree.nodes.values()) { + if (node.parent?.brick.uuid === brickId) { + children.push(node); + } + } + } + return children; + } + + /** + * Gets the parent of a brick + */ + public getBrickParent(brickId: string): string | undefined { + const node = this.getBrickNode(brickId); + return node?.parent?.brick.uuid; + } + + /** + * Clears all trees + */ + public clear(): void { + this.trees = []; + this.nextTreeId = 1; + } + + private getBrickNode(brickId: string): TTreeNode | undefined { + for (const tree of this.trees) { + const node = tree.nodes.get(brickId); + if (node) return node; + } + return undefined; + } + + private findTreeByBrickId(brickId: string): TTree | undefined { + return this.trees.find((tree) => tree.nodes.has(brickId)); + } +} diff --git a/modules/masonry/src/tree/model/spec/model.spec.ts b/modules/masonry/src/tree/model/spec/model.spec.ts new file mode 100644 index 00000000..db84ed97 --- /dev/null +++ b/modules/masonry/src/tree/model/spec/model.spec.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import BrickTreeManager from '../model.js'; +import { SimpleBrick, ExpressionBrick } from '../../../brick/model/model'; +import CompoundBrick from '../../../brick/model/model'; +import { IBrick } from '../../../brick/@types/brick'; + +describe('BrickTreeManager', () => { + let treeManager: BrickTreeManager; + let simpleBrick1: IBrick; + let simpleBrick2: IBrick; + let expressionBrick: IBrick; + let expressionBrick2: IBrick; + let compoundBrick: IBrick; + + beforeEach(() => { + treeManager = new BrickTreeManager(); + + simpleBrick1 = new SimpleBrick({ + uuid: 'simple1', + name: 'Simple 1', + label: 's1', + labelType: 'text', + colorBg: 'red', + colorFg: 'white', + strokeColor: 'black', + shadow: false, + scale: 1, + bboxArgs: [{ w: 10, h: 20 }], // Add bboxArgs to generate right connection points + topNotch: true, + bottomNotch: true, + }); + + // Use SimpleBrick for top-bottom tests + simpleBrick2 = new SimpleBrick({ + uuid: 'simple2', + name: 'Simple 2', + label: 's2', + labelType: 'text', + colorBg: 'green', + colorFg: 'white', + strokeColor: 'black', + shadow: false, + scale: 1, + bboxArgs: [{ w: 10, h: 20 }], + topNotch: true, + bottomNotch: true, + }); + + // Use ExpressionBrick for right-to-left tests + expressionBrick = new ExpressionBrick({ + uuid: 'expr1', + name: 'Expression 1', + label: 'e1', + labelType: 'text', + colorBg: 'blue', + colorFg: 'white', + strokeColor: 'black', + shadow: false, + scale: 1, + bboxArgs: [], // Expression bricks have a left notch by default + }); + expressionBrick2 = new ExpressionBrick({ + uuid: 'expr2', + name: 'Expression 2', + label: 'e2', + labelType: 'text', + colorBg: 'orange', + colorFg: 'white', + strokeColor: 'black', + shadow: false, + scale: 1, + bboxArgs: [], + }); + + compoundBrick = new CompoundBrick({ + uuid: 'comp1', + name: 'Compound 1', + label: 'c1', + labelType: 'text', + colorBg: 'purple', + colorFg: 'white', + strokeColor: 'black', + shadow: false, + scale: 1, + bboxArgs: [{ w: 10, h: 20 }], // Add bboxArgs to generate right connection points + bboxNest: [], + topNotch: true, + bottomNotch: true, + }); + }); + + describe('Tree Creation', () => { + it('should create a new tree with a single brick', () => { + const tree = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); + expect(tree.id).toBe('tree_1'); + }); + }); + + describe('Hierarchical Tree Behavior (Folder-like Structure)', () => { + it('should disconnect a parent and keep children', () => { + const tree = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); + treeManager.addBrickToTree(tree.id, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); + treeManager.connectBricks( + simpleBrick1.uuid, + simpleBrick2.uuid, + simpleBrick1.connectionPoints.bottom!, + simpleBrick2.connectionPoints.top!, + 'top-bottom', + ); + const result = treeManager.disconnectBrick(simpleBrick2.uuid); + expect(result.newTreeIds).toHaveLength(1); + expect(result.removedConnections).toHaveLength(1); + }); + }); + + describe('Right-to-Left "Puzzle" Connections', () => { + it('should connect two bricks with a right-to-left connection', () => { + treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); + treeManager.createTree(expressionBrick, { x: 120, y: 0 }); + const newTreeId = treeManager.connectBricks( + simpleBrick1.uuid, + expressionBrick.uuid, + simpleBrick1.connectionPoints.right[0], + expressionBrick.connectionPoints.left!, + 'right-left', + ); + const tree = treeManager.getTree(newTreeId!); + expect(tree?.connections[0].fromNotchId).toBe('right_0'); + expect(tree?.connections[0].toNotchId).toBe('left'); + expect(treeManager.getBrickParent(expressionBrick.uuid)).toBe(simpleBrick1.uuid); + }); + + it('should not connect if a notch is already in use', () => { + const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; + treeManager.addBrickToTree(treeId, expressionBrick, simpleBrick1.uuid, { + x: 120, + y: 0, + }); + treeManager.connectBricks( + simpleBrick1.uuid, + expressionBrick.uuid, + simpleBrick1.connectionPoints.right[0], + expressionBrick.connectionPoints.left!, + 'right-left', + ); + treeManager.createTree(expressionBrick2, { x: 120, y: 50 }); + const result = treeManager.connectBricks( + simpleBrick1.uuid, + expressionBrick2.uuid, + simpleBrick1.connectionPoints.right[0], + expressionBrick2.connectionPoints.left!, + 'right-left', + ); + expect(result).toBeNull(); + }); + + it('should disconnect a right-to-left connection', () => { + treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); + treeManager.createTree(expressionBrick, { x: 120, y: 0 }); + treeManager.connectBricks( + simpleBrick1.uuid, + expressionBrick.uuid, + simpleBrick1.connectionPoints.right[0], + expressionBrick.connectionPoints.left!, + 'right-left', + ); + const result = treeManager.disconnectBrick(expressionBrick.uuid); + expect(result.removedConnections).toHaveLength(1); + expect(result.newTreeIds).toHaveLength(1); + expect(treeManager.areBricksConnected(simpleBrick1.uuid, expressionBrick.uuid)).toBe( + false, + ); + }); + }); + + // Re-introducing Tower Model Test Cases + describe('Tower Model Test Cases', () => { + it('Test Case 1: should connect multiple bricks in a chain', () => { + const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; + treeManager.addBrickToTree(treeId, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); + treeManager.addBrickToTree(treeId, compoundBrick, simpleBrick2.uuid, { x: 0, y: 100 }); + + treeManager.connectBricks( + simpleBrick1.uuid, + simpleBrick2.uuid, + simpleBrick1.connectionPoints.bottom!, + simpleBrick2.connectionPoints.top!, + 'top-bottom', + ); + treeManager.connectBricks( + simpleBrick2.uuid, + compoundBrick.uuid, + simpleBrick2.connectionPoints.bottom!, + (compoundBrick as IBrick).connectionPoints.top!, + 'top-bottom', + ); + expect(treeManager.getAllTrees()).toHaveLength(1); + expect(treeManager.getBricksInTree('tree_1')).toHaveLength(3); + }); + + it('Test Case 2: should disconnect a brick from the middle of a chain', () => { + const treeId = treeManager.createTree(simpleBrick1, { x: 0, y: 0 }).id; + treeManager.addBrickToTree(treeId, simpleBrick2, simpleBrick1.uuid, { x: 0, y: 50 }); + treeManager.addBrickToTree(treeId, compoundBrick, simpleBrick2.uuid, { x: 0, y: 100 }); + treeManager.connectBricks( + simpleBrick1.uuid, + simpleBrick2.uuid, + simpleBrick1.connectionPoints.bottom!, + simpleBrick2.connectionPoints.top!, + 'top-bottom', + ); + treeManager.connectBricks( + simpleBrick2.uuid, + compoundBrick.uuid, + simpleBrick2.connectionPoints.bottom!, + (compoundBrick as IBrick).connectionPoints.top!, + 'top-bottom', + ); + + const result = treeManager.disconnectBrick(simpleBrick2.uuid); + expect(result.removedConnections).toHaveLength(2); + expect(result.newTreeIds).toHaveLength(1); // Disconnected brick forms its own tree + }); + + it('Test Case 3: should not connect when notch is already connected', () => { + treeManager.createTree(simpleBrick1, { x: 0, y: 0 }); + treeManager.createTree(simpleBrick2, { x: 0, y: 50 }); + treeManager.connectBricks( + simpleBrick1.uuid, + simpleBrick2.uuid, + simpleBrick1.connectionPoints.bottom!, + simpleBrick2.connectionPoints.top!, + 'top-bottom', + ); + treeManager.createTree(compoundBrick, { x: 0, y: 100 }); + const result = treeManager.connectBricks( + simpleBrick1.uuid, + compoundBrick.uuid, + simpleBrick1.connectionPoints.bottom!, + (compoundBrick as IBrick).connectionPoints.top!, + 'top-bottom', + ); + expect(result).toBeNull(); + }); + }); +});