From 6f05725d44d85ddfe2e63ed9d142a9db24b76744 Mon Sep 17 00:00:00 2001 From: Augusto Date: Fri, 31 Jul 2026 15:57:46 -0300 Subject: [PATCH 1/2] refactor: centralize the pedestal footprint rule Whether a shape stands on a disc or a slab was decided by repeating `kind === "circle" || kind === "icon"` at six independent call sites: edge anchoring, viewport culling, the selection outline, click targeting, and both the per-view and batched render paths. pointInShape held a seventh copy that had already drifted, testing only "circle". Introduce isRoundFootprint as the single definition and route every site through it. The predicate reads an optional silhouette override and otherwise falls back to the previous kind test, so behaviour is unchanged until something writes that field. pedestalBatch is the site that matters most here: it dispatched on kind directly, so any future disagreement between kind and footprint would have drawn a slab in the batched path and a disc in the per-view one. --- src/interaction/controller.ts | 5 +++-- src/render/culling.ts | 3 ++- src/render/geometry.ts | 20 ++++++++++++++++---- src/render/pedestalBatch.ts | 9 +++------ src/render/scene.ts | 3 ++- src/render/shapeView.ts | 8 ++++---- src/state/types.ts | 8 ++++++++ 7 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/interaction/controller.ts b/src/interaction/controller.ts index dc040c0..2785c8f 100644 --- a/src/interaction/controller.ts +++ b/src/interaction/controller.ts @@ -2,6 +2,7 @@ import type { Graphics } from "pixi.js"; import { boundaryPoint, buildEdgeSiblingIndex, + isRoundFootprint, type Pt, quadPoints, resolveEdgeGeometry, @@ -1396,12 +1397,12 @@ export class Controller { const s = doc.board.shapes[id]; if (!s) continue; if (!isShapeInViewport(s, proj, viewport)) continue; - // circle/icon get a ring around the raised disc; rect/image a box around the + // round footprints get a ring around the raised disc; slabs a box around the // raised slab; text a box on the ground. Everything rides the shape's layer // elevation so the outline stays glued to a lifted token. const elev = this.outlineElevation(s); // handles ride this exact plane too let pts: Pt[]; - if (s.kind === "circle" || s.kind === "icon") { + if (isRoundFootprint(s)) { // the disc is a circle of radius min(w,h)/2, so ring that exact circle // (padded) rather than the w×h bounding ellipse — keeps the outline // hugging the disc instead of ballooning into an oval. diff --git a/src/render/culling.ts b/src/render/culling.ts index 5b73637..6fd45a7 100644 --- a/src/render/culling.ts +++ b/src/render/culling.ts @@ -1,4 +1,5 @@ import type { Shape } from "../state/types"; +import { isRoundFootprint } from "./geometry"; import { elevationOf, H_PED } from "./shading"; import { type Projector, projectBoard, unprojectBoardAt } from "./projection"; import type { ShapeBounds } from "./shapeSpatialIndex"; @@ -38,7 +39,7 @@ export function isShapeInViewport( const rx = shape.w / 2; const ry = shape.h / 2; const points: Array<[number, number]> = - shape.kind === "circle" || shape.kind === "icon" + isRoundFootprint(shape) ? [ [cx - rx, cy], [cx + rx, cy], diff --git a/src/render/geometry.ts b/src/render/geometry.ts index d1281b5..517478c 100644 --- a/src/render/geometry.ts +++ b/src/render/geometry.ts @@ -9,19 +9,31 @@ export function center(s: Shape): Pt { return { x: s.x + s.w / 2, y: s.y + s.h / 2 }; } +/** True when the shape stands on a disc rather than a slab. Falls back to the + * kind default whenever the user hasn't picked a silhouette for it. */ +export function isRoundFootprint(s: Shape): boolean { + if (s.silhouette) return s.silhouette === "circle"; + return s.kind === "circle" || s.kind === "icon"; +} + +/** Kinds that raise a pedestal, and so can be reshaped. Text draws no pedestal + * and code draws a titled panel, neither of which has a footprint to swap. */ +export function supportsSilhouette(s: Shape): boolean { + return s.kind === "icon" || s.kind === "circle" || s.kind === "rect" || s.kind === "image"; +} + /** Point on the shape's outline along the ray from its center toward `target`. */ export function boundaryPoint(s: Shape, target: Pt): Pt { const c = center(s); const dx = target.x - c.x; const dy = target.y - c.y; if (dx === 0 && dy === 0) return c; - // circle/icon render as a disc, so anchor edges to that circular rim - if (s.kind === "circle" || s.kind === "icon") { + if (isRoundFootprint(s)) { const r = Math.min(s.w, s.h) / 2; const len = Math.hypot(dx, dy) || 1; return { x: c.x + (dx / len) * r, y: c.y + (dy / len) * r }; } - // rect / image / text / code: intersect ray with the half-extent box + // slab footprints: intersect the ray with the half-extent box const hw = s.w / 2; const hh = s.h / 2; const tx = dx !== 0 ? hw / Math.abs(dx) : Infinity; @@ -31,7 +43,7 @@ export function boundaryPoint(s: Shape, target: Pt): Pt { } export function pointInShape(s: Shape, p: Pt): boolean { - if (s.kind === "circle") { + if (isRoundFootprint(s)) { const rx = s.w / 2; const ry = s.h / 2; const nx = (p.x - (s.x + rx)) / rx; diff --git a/src/render/pedestalBatch.ts b/src/render/pedestalBatch.ts index 32f89d6..b95afb0 100644 --- a/src/render/pedestalBatch.ts +++ b/src/render/pedestalBatch.ts @@ -1,6 +1,6 @@ import { Container, Mesh, MeshGeometry, Texture } from "pixi.js"; import type { Shape } from "../state/types"; -import { hexToNumber, NO_FILL } from "./geometry"; +import { hexToNumber, isRoundFootprint, NO_FILL } from "./geometry"; import { type Projector, projectBoard } from "./projection"; import { elevationOf, H_PED, shade, tint } from "./shading"; @@ -210,11 +210,8 @@ export class PedestalBatch { sorted.sort((a, b) => b.depth - a.depth); for (const { shape, alpha } of sorted) { - if (shape.kind === "circle") { - this.addCircle(groups, shape, proj, alpha); - } else if (shape.kind === "rect") { - this.addRect(groups, shape, proj, alpha); - } + if (isRoundFootprint(shape)) this.addCircle(groups, shape, proj, alpha); + else this.addRect(groups, shape, proj, alpha); } const ordered = this.orderedGroups; diff --git a/src/render/scene.ts b/src/render/scene.ts index cf3917c..134f5db 100644 --- a/src/render/scene.ts +++ b/src/render/scene.ts @@ -15,6 +15,7 @@ import { type EdgeSiblingIndex, type Pt, distToSegment, + isRoundFootprint, quadPoints, resolveEdgeGeometry, } from "./geometry"; @@ -1078,7 +1079,7 @@ class Scene { private shapeTopPoly(s: Shape, proj: Projector): Pt[] { const top = s.kind === "text" ? elevationOf(s) : elevationOf(s) + H_PED; const out: Pt[] = []; - if (s.kind === "circle" || s.kind === "icon") { + if (isRoundFootprint(s)) { const cx = s.x + s.w / 2; const cy = s.y + s.h / 2; const r = Math.min(s.w, s.h) / 2; diff --git a/src/render/shapeView.ts b/src/render/shapeView.ts index e8bcc35..5303310 100644 --- a/src/render/shapeView.ts +++ b/src/render/shapeView.ts @@ -9,7 +9,7 @@ import { } from "pixi.js"; import type { Shape, ShapeKind } from "../state/types"; import { CODE_THEME_DEFAULT, highlightCode } from "./codeHighlight"; -import { hexToNumber, NO_FILL, readableText } from "./geometry"; +import { hexToNumber, isRoundFootprint, NO_FILL, readableText } from "./geometry"; import { drawIcon } from "./icons"; import { ICON_LABEL_FONT_SIZE, @@ -289,10 +289,10 @@ function drawSlab(g: Graphics, s: Shape, proj: Projector): void { } } -/** Faux-3D pedestal: a disc for circle/icon, a rectangular slab for rect/image/code. */ +/** Faux-3D pedestal: a disc or a rectangular slab, per the shape's footprint. */ function drawPedestal(g: Graphics, s: Shape, proj: Projector): void { - if (s.kind === "rect" || s.kind === "image" || s.kind === "code") drawSlab(g, s, proj); - else drawDisc(g, s, proj); + if (isRoundFootprint(s)) drawDisc(g, s, proj); + else drawSlab(g, s, proj); } let measureCtx: CanvasRenderingContext2D | null = null; diff --git a/src/state/types.ts b/src/state/types.ts index bb34529..29aae20 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -2,6 +2,8 @@ export type ID = string; export type ShapeKind = "rect" | "circle" | "icon" | "image" | "text" | "code"; +export type Silhouette = "circle" | "square"; + export interface Shape { id: ID; kind: ShapeKind; @@ -19,6 +21,12 @@ export interface Shape { src?: string; /** label/text font size in world units; scales with the object on resize (defaults per kind) */ fontSize?: number; + /** + * Pedestal footprint, chosen by the user after the shape was placed. Absent + * until they pick one, in which case the footprint falls back to the kind + * default — so boards and share links predating this field are unaffected. + */ + silhouette?: Silhouette; /** * Named floor this shape sits on (0-based index into `Board.layers`). Each * distinct value draws as its own glowing board plane. Elevation base is From 004b15c12d42976138635a3db1a58ac30b3570db Mon Sep 17 00:00:00 2001 From: Augusto Date: Fri, 31 Jul 2026 16:03:31 -0300 Subject: [PATCH 2/2] feat: let users reshape a placed component's silhouette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footprint of a component was fixed at creation: an icon dropped from the palette was always round, and the only way to get a square one was to delete it and rebuild it as a rectangle, losing the artwork, label, floor and every connected edge. Add an optional silhouette override on Shape, written by a new setShapesSilhouette action, and surface it as a Shape control in the style panel and two entries in the right-click menu. It applies to every kind that raises a pedestal — icon, circle, rect and image — and skips text and code, which have no footprint to swap. Dimensions are deliberately untouched, so a non-square box turned round draws a disc across its shorter side and still restores exactly when switched back. Undo, autosave, clipboard and share links need no work: the action bumps the revision the history and autosave already watch, and the field rides along in the shapes the clipboard and encoder copy wholesale. Boards and share links predating the field carry no override and fall back to the kind default, so they render exactly as before. --- README.md | 4 ++ src/interaction/controller.ts | 17 +++++ src/state/actions.ts | 22 +++++- src/styles.css | 40 +++++++++++ src/ui/editor.ts | 37 +++++++++- test/silhouette.test.ts | 123 ++++++++++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 test/silhouette.test.ts diff --git a/README.md b/README.md index 8257676..53b6742 100644 --- a/README.md +++ b/README.md @@ -76,3 +76,7 @@ Double-click empty canvas to drop a text object · double-click a shape/line to its text/label · drop an image file onto the canvas to add it · drag a selected line's center handle to bend it · resize handles keep a locked aspect ratio (square for shapes, natural ratio for images). + +Any placed shape, icon or image can be switched between a square and a round +pedestal after the fact — via the **Shape** control in the style panel or the +right-click menu — without losing its artwork, label, floor or connected edges. diff --git a/src/interaction/controller.ts b/src/interaction/controller.ts index 2785c8f..c326044 100644 --- a/src/interaction/controller.ts +++ b/src/interaction/controller.ts @@ -6,6 +6,7 @@ import { type Pt, quadPoints, resolveEdgeGeometry, + supportsSilhouette, } from "../render/geometry"; import { NAMEPLATE_BACKGROUND_CSS, @@ -317,6 +318,22 @@ export class Controller { { label: "Send Backward", hint: "[", onSelect: () => actions.sendBackward(ids) }, { label: "Send to Back", hint: "-", onSelect: () => actions.sendToBack(ids) }, ]; + const reshapable = ids.map((id) => doc.board.shapes[id]).filter((s) => s && supportsSilhouette(s)); + if (reshapable.length) { + const rounds = reshapable.filter(isRoundFootprint).length; + items.push( + { + label: "Square Shape", + hint: rounds === 0 ? "•" : "", + onSelect: () => actions.setShapesSilhouette(ids, "square"), + }, + { + label: "Round Shape", + hint: rounds === reshapable.length ? "•" : "", + onSelect: () => actions.setShapesSilhouette(ids, "circle"), + }, + ); + } // when the board has named floors, offer to move the selection onto each one const layers = doc.board.layers ?? []; if (layers.length > 1) { diff --git a/src/state/actions.ts b/src/state/actions.ts index 6afc1ae..63f29c8 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -1,4 +1,4 @@ -import { center, type Pt, reanchorBend } from "../render/geometry"; +import { center, type Pt, reanchorBend, supportsSilhouette } from "../render/geometry"; import { measureTextBox } from "../render/measure"; import { scene } from "../render/scene"; import { STACK_STEP } from "../render/shading"; @@ -13,7 +13,7 @@ import { doc, setSelection, } from "./store"; -import type { Board, Edge, ID, LayerDef, Shape, ShapeKind } from "./types"; +import type { Board, Edge, ID, LayerDef, Shape, ShapeKind, Silhouette } from "./types"; export const DEFAULT_SIZE = 110; @@ -261,6 +261,24 @@ export function setShapesStyle( bumpRevision(); } +/** + * Reshape each shape's pedestal. Kinds that raise no pedestal are skipped, so a + * mixed selection reshapes what it can instead of failing. Dimensions are left + * alone: a non-square box turned round draws a disc across its shorter side and + * keeps the box, so switching back restores it exactly. + */ +export function setShapesSilhouette(ids: Iterable, silhouette: Silhouette): void { + let changed = false; + for (const id of ids) { + const s = doc.board.shapes[id]; + if (!s || !supportsSilhouette(s) || s.silhouette === silhouette) continue; + s.silhouette = silhouette; + scene.updateNode(id); + changed = true; + } + if (changed) bumpRevision(); +} + /** * Apply an absolute label/text font size (a size preset) to each shape. Text * objects are content-sized, so they re-measure their box to the new font diff --git a/src/styles.css b/src/styles.css index a9211d5..26d6cdc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -662,6 +662,41 @@ button { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25); } +.shape-picker { + display: inline-flex; + gap: 3px; +} +.shape-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 26px; + padding: 0; + color: var(--muted); + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 7px; + cursor: pointer; +} +.shape-btn svg { + width: 15px; + height: 15px; +} +.shape-btn:hover:not(:disabled) { + color: var(--text); + border-color: var(--accent); +} +.shape-btn.is-active { + color: var(--text); + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25); +} +.shape-btn:disabled { + opacity: 0.4; + cursor: default; +} + /* preset-color popover */ .swatch-pop { position: absolute; @@ -1773,6 +1808,11 @@ button { padding: 0 6px; } + .shape-btn { + width: 28px; + height: 28px; + } + .icon-palette { left: 10px; right: 10px; diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 16ebe8e..4317092 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -1,7 +1,7 @@ import { centerOrigin, fitToContent, getZoom, isFocusOffBoard, zoomBy } from "../interaction/camera"; import { generateDiagramWithOpenAI } from "../ai/openaiDiagram"; import { Controller } from "../interaction/controller"; -import { NO_FILL } from "../render/geometry"; +import { isRoundFootprint, NO_FILL, supportsSilhouette } from "../render/geometry"; import { saveNow, startAutosave, stopAutosave } from "../persistence/autosave"; import { listBoards, saveBoard } from "../persistence/db"; import { shareUrl } from "../persistence/share"; @@ -10,7 +10,7 @@ import * as actions from "../state/actions"; import { $canRedo, $canUndo, disposeHistory, initHistory, redo, undo } from "../state/history"; import { $activeLayer, $camera, $selection, $style, $tool, doc } from "../state/store"; import { TEXT_SIZE_PRESETS } from "../state/style"; -import type { Board, ToolName } from "../state/types"; +import type { Board, Silhouette, ToolName } from "../state/types"; import { generatedGraphToBoard } from "../state/generatedGraph"; import { createStarterBoard } from "../state/starterBoard"; import { emptyBoard } from "../state/store"; @@ -38,6 +38,10 @@ const ICON_CODE = svg( ); const ICON_RECT = svg(''); const ICON_CIRCLE = svg(''); +const SILHOUETTE_OPTIONS: Array<{ value: Silhouette; label: string; icon: string }> = [ + { value: "square", label: "Square", icon: ICON_RECT }, + { value: "circle", label: "Round", icon: ICON_CIRCLE }, +]; const ICON_LINE = svg(''); const ICON_ARROW = svg(''); const ICON_HAND = svg( @@ -457,6 +461,21 @@ export async function mountEditor( ); const sizePicker = h("div", { class: "size-picker" }, ...sizeButtons); + const shapeButtons = SILHOUETTE_OPTIONS.map((option) => + h("button", { + class: "shape-btn", + title: `${option.label} shape`, + "aria-label": `${option.label} shape`, + "aria-pressed": "false", + html: option.icon, + onclick: () => { + actions.setShapesSilhouette($selection.get().shapes, option.value); + syncStyle(); + }, + }), + ); + const shapePicker = h("div", { class: "shape-picker" }, ...shapeButtons); + const deleteBtn = h( "button", { class: "btn btn--danger", title: "Delete selection (⌫)", onclick: () => actions.deleteSelection() }, @@ -468,6 +487,7 @@ export async function mountEditor( { class: "style-panel" }, h("div", { class: "field" }, h("span", null, "Fill"), fillPicker.el), h("div", { class: "field" }, h("span", null, "Size"), sizePicker), + h("div", { class: "field" }, h("span", null, "Shape"), shapePicker), deleteBtn, ); @@ -486,6 +506,19 @@ export async function mountEditor( btn.classList.toggle("is-active", active); btn.setAttribute("aria-pressed", String(active)); }); + const reshapable = [...sel.shapes] + .map((id) => doc.board.shapes[id]) + .filter((shape) => shape && supportsSilhouette(shape)); + const footprints = new Set( + reshapable.map((shape): Silhouette => (isRoundFootprint(shape) ? "circle" : "square")), + ); + const activeSilhouette = footprints.size === 1 ? [...footprints][0] : null; + shapeButtons.forEach((btn, i) => { + const active = SILHOUETTE_OPTIONS[i].value === activeSilhouette; + btn.classList.toggle("is-active", active); + btn.setAttribute("aria-pressed", String(active)); + btn.toggleAttribute("disabled", reshapable.length === 0); + }); const hasSel = sel.shapes.size > 0 || sel.edges.size > 0; deleteBtn.toggleAttribute("disabled", !hasSel); stylePanel.classList.toggle("style-panel--editing", hasSel); diff --git a/test/silhouette.test.ts b/test/silhouette.test.ts new file mode 100644 index 0000000..81ac5e4 --- /dev/null +++ b/test/silhouette.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/render/scene", () => ({ + scene: { + addNode: vi.fn(), + updateNode: vi.fn(), + updateEdge: vi.fn(), + rebuild: vi.fn(), + requestRender: vi.fn(), + }, +})); + +import { createShape, loadBoard, setShapesSilhouette } from "../src/state/actions"; +import { boundaryPoint, isRoundFootprint, supportsSilhouette } from "../src/render/geometry"; +import { decodeBoard, encodeBoard } from "../src/persistence/share"; +import { doc } from "../src/state/store"; +import type { Board, Shape, ShapeKind } from "../src/state/types"; + +const ALL_KINDS: ShapeKind[] = ["rect", "circle", "icon", "image", "text", "code"]; +const ROUND_BY_DEFAULT: ShapeKind[] = ["circle", "icon"]; +const RESHAPABLE: ShapeKind[] = ["icon", "circle", "rect", "image"]; + +function shape(kind: ShapeKind, patch: Partial = {}): Shape { + return { id: kind, kind, x: 0, y: 0, w: 100, h: 100, fill: "#0f2740", text: "", ...patch }; +} + +function emptyBoard(): Board { + return { id: "b", name: "t", shapes: {}, edges: {}, order: [], createdAt: 0, updatedAt: 0 }; +} + +beforeEach(() => { + loadBoard(emptyBoard()); +}); + +describe("isRoundFootprint", () => { + it("falls back to the kind default when no silhouette was chosen", () => { + for (const kind of ALL_KINDS) { + expect(isRoundFootprint(shape(kind))).toBe(ROUND_BY_DEFAULT.includes(kind)); + } + }); + + it("lets an explicit silhouette override the kind default", () => { + for (const kind of ALL_KINDS) { + expect(isRoundFootprint(shape(kind, { silhouette: "circle" }))).toBe(true); + expect(isRoundFootprint(shape(kind, { silhouette: "square" }))).toBe(false); + } + }); +}); + +describe("supportsSilhouette", () => { + it("accepts the kinds that raise a pedestal and rejects the rest", () => { + for (const kind of ALL_KINDS) { + expect(supportsSilhouette(shape(kind))).toBe(RESHAPABLE.includes(kind)); + } + }); +}); + +describe("setShapesSilhouette", () => { + it("reshapes eligible kinds and leaves text and code untouched", () => { + const created = ALL_KINDS.map((kind) => createShape(kind, 0, 0)); + + setShapesSilhouette( + created.map((s) => s.id), + "square", + ); + + created.forEach((s, i) => { + const stored = doc.board.shapes[s.id]; + if (RESHAPABLE.includes(ALL_KINDS[i])) expect(stored.silhouette).toBe("square"); + else expect(stored.silhouette).toBeUndefined(); + }); + }); + + it("keeps dimensions so switching back restores the original box", () => { + const wide = createShape("rect", 0, 0, 400, 100); + + setShapesSilhouette([wide.id], "circle"); + expect(doc.board.shapes[wide.id].w).toBe(400); + expect(doc.board.shapes[wide.id].h).toBe(100); + + setShapesSilhouette([wide.id], "square"); + expect(doc.board.shapes[wide.id]).toMatchObject({ w: 400, h: 100, silhouette: "square" }); + }); + + it("ignores ids that are not on the board", () => { + expect(() => setShapesSilhouette(["missing"], "circle")).not.toThrow(); + }); +}); + +describe("edge anchoring", () => { + it("follows the silhouette rather than the kind", () => { + const target = { x: 500, y: 0 }; + const roundRect = boundaryPoint(shape("rect", { silhouette: "circle" }), target); + const squareIcon = boundaryPoint(shape("icon", { silhouette: "square" }), target); + + expect(roundRect).toEqual(boundaryPoint(shape("circle"), target)); + expect(squareIcon).toEqual(boundaryPoint(shape("rect"), target)); + }); +}); + +describe("share round trip", () => { + it("preserves a chosen silhouette", () => { + const icon = createShape("icon", 0, 0, 100, 100, { icon: "database" }); + setShapesSilhouette([icon.id], "square"); + + const restored = decodeBoard(encodeBoard(doc.board)); + + expect(Object.values(restored!.shapes)[0]).toMatchObject({ + kind: "icon", + icon: "database", + silhouette: "square", + }); + }); + + it("omits the key entirely for shapes that were never reshaped", () => { + const icon = createShape("icon", 0, 0); + + const restored = decodeBoard(encodeBoard(doc.board)); + + expect(restored!.shapes[icon.id]).not.toHaveProperty("silhouette"); + expect(isRoundFootprint(restored!.shapes[icon.id])).toBe(true); + }); +});