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 dc040c0..c326044 100644 --- a/src/interaction/controller.ts +++ b/src/interaction/controller.ts @@ -2,9 +2,11 @@ import type { Graphics } from "pixi.js"; import { boundaryPoint, buildEdgeSiblingIndex, + isRoundFootprint, type Pt, quadPoints, resolveEdgeGeometry, + supportsSilhouette, } from "../render/geometry"; import { NAMEPLATE_BACKGROUND_CSS, @@ -316,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) { @@ -1396,12 +1414,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/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/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 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); + }); +});