From f549b4cda0f7c2312657d91c5d30a60535eda85b Mon Sep 17 00:00:00 2001 From: miyanokomiya Date: Thu, 28 Nov 2024 12:55:44 +0900 Subject: [PATCH] feat: Add new shape type "spiky_rectangle" --- src/assets/icons/shape_spiky_rectangle.svg | 2 + src/composables/shapeTypes.ts | 5 + .../spikyRectangleSelectedState.ts | 253 ++++++++++++++++++ src/shapes/commonStructs.ts | 2 + src/shapes/polygons/roundedRectangle.ts | 6 +- src/shapes/polygons/spikyRectangle.ts | 152 +++++++++++ src/utils/geometry.ts | 5 + 7 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 src/assets/icons/shape_spiky_rectangle.svg create mode 100644 src/composables/states/appCanvas/spikyRectangle/spikyRectangleSelectedState.ts create mode 100644 src/shapes/polygons/spikyRectangle.ts diff --git a/src/assets/icons/shape_spiky_rectangle.svg b/src/assets/icons/shape_spiky_rectangle.svg new file mode 100644 index 00000000..3cf5062f --- /dev/null +++ b/src/assets/icons/shape_spiky_rectangle.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/composables/shapeTypes.ts b/src/composables/shapeTypes.ts index 6beffe6e..7eef7252 100644 --- a/src/composables/shapeTypes.ts +++ b/src/composables/shapeTypes.ts @@ -9,6 +9,7 @@ import iconCapsule from "../assets/icons/shape_capsule.svg"; import iconCylinder from "../assets/icons/shape_cylinder.svg"; import iconDocumentSymbol from "../assets/icons/shape_document_symbol.svg"; import iconStar from "../assets/icons/shape_star.svg"; +import iconSpikyRectangle from "../assets/icons/shape_spiky_rectangle.svg"; import iconBubble from "../assets/icons/shape_bubble.svg"; import iconOneSidedArrow from "../assets/icons/shape_one_sided_arrow.svg"; import iconTwoSidedArrow from "../assets/icons/shape_two_sided_arrow.svg"; @@ -53,6 +54,7 @@ import { newArcSelectedState } from "./states/appCanvas/arc/arcSelectedState"; import { newDonutSelectedState } from "./states/appCanvas/donut/donutSelectedState"; import { newMoonSelectedState } from "./states/appCanvas/moon/moonSelectedState"; import { newGroupSelectedState } from "./states/appCanvas/group/groupSelectedState"; +import { newSpikyRectangleSelectedState } from "./states/appCanvas/spikyRectangle/spikyRectangleSelectedState"; export type ShapeTypeItem = { type: string; icon: string }; @@ -69,6 +71,7 @@ export const shapeTypeList: ShapeTypeItem[] = [ { type: "cylinder", icon: iconCylinder }, { type: "document_symbol", icon: iconDocumentSymbol }, { type: "star", icon: iconStar }, + { type: "spiky_rectangle", icon: iconSpikyRectangle }, { type: "bubble", icon: iconBubble }, { type: "one_sided_arrow", icon: iconOneSidedArrow }, { type: "two_sided_arrow", icon: iconTwoSidedArrow }, @@ -120,6 +123,8 @@ export function getSingleShapeSelectedStateFn(type: string) { return newHexagonSelectedState; case "star": return newStarSelectedState; + case "spiky_rectangle": + return newSpikyRectangleSelectedState; case "document_symbol": return newDocumentSymbolSelectedState; case "wave": diff --git a/src/composables/states/appCanvas/spikyRectangle/spikyRectangleSelectedState.ts b/src/composables/states/appCanvas/spikyRectangle/spikyRectangleSelectedState.ts new file mode 100644 index 00000000..90f1c2b0 --- /dev/null +++ b/src/composables/states/appCanvas/spikyRectangle/spikyRectangleSelectedState.ts @@ -0,0 +1,253 @@ +import { movingShapeControlState } from "../movingShapeControlState"; +import { getRectShapeRect, getShapeDetransform, getShapeTransform } from "../../../../shapes/rectPolygon"; +import { applyAffine, clamp, IVec2 } from "okageo"; +import { snapNumber } from "../../../../utils/geometry"; +import { + getSpikeParameters, + getSpikyInnerRectangle, + SpikyRectangleShape, +} from "../../../../shapes/polygons/spikyRectangle"; +import { COMMAND_EXAM_SRC } from "../commandExams"; +import { defineSingleSelectedHandlerState } from "../singleSelectedHandlerState"; +import { newSimplePolygonHandler, SimplePolygonHandler } from "../../../shapeHandlers/simplePolygonHandler"; +import { StyleScheme } from "../../../../models"; +import { applyLocalSpace, renderValueLabel } from "../../../../utils/renderer"; +import { applyStrokeStyle } from "../../../../utils/strokeStyle"; + +export const newSpikyRectangleSelectedState = defineSingleSelectedHandlerState< + SpikyRectangleShape, + SimplePolygonHandler, + never +>( + (getters) => { + return { + getLabel: () => "RectangleSelected", + handleEvent: (ctx, event) => { + switch (event.type) { + case "pointerdown": + switch (event.data.options.button) { + case 0: { + const targetShape = getters.getTargetShape(); + const shapeHandler = getters.getShapeHandler(); + + const hitResult = shapeHandler.hitTest(event.data.point, ctx.getScale()); + shapeHandler.saveHitResult(hitResult); + if (hitResult) { + switch (hitResult.type) { + case "rx": + return () => { + let showLabel = !event.data.options.ctrl; + return movingShapeControlState({ + targetId: targetShape.id, + snapType: "custom", + extraCommands: [COMMAND_EXAM_SRC.RESIZE_PROPORTIONALLY], + patchFn: (s, p, movement) => { + const innerRect = getSpikyInnerRectangle(s); + const localP = applyAffine(getShapeDetransform(s), p); + let nextSize = clamp(0, innerRect.width / 2, localP.x - innerRect.x); + if (movement.ctrl) { + showLabel = false; + } else { + nextSize = snapNumber(nextSize, 1); + showLabel = true; + } + return movement.shift ? { rx: nextSize, ry: nextSize } : { rx: nextSize }; + }, + getControlFn: (s, scale) => + applyAffine(getShapeTransform(s), getLocalCornerControl(s, scale)[0]), + renderFn: (ctx, renderCtx, s) => { + renderCornerGuidlinesForRadius( + renderCtx, + ctx.getStyleScheme(), + ctx.getScale(), + s, + showLabel, + ); + }, + }); + }; + case "ry": + return () => { + let showLabel = !event.data.options.ctrl; + return movingShapeControlState({ + targetId: targetShape.id, + snapType: "custom", + extraCommands: [COMMAND_EXAM_SRC.RESIZE_PROPORTIONALLY], + patchFn: (s, p, movement) => { + const innerRect = getSpikyInnerRectangle(s); + const localP = applyAffine(getShapeDetransform(s), p); + let nextSize = clamp(0, innerRect.height / 2, localP.y - innerRect.y); + if (movement.ctrl) { + showLabel = false; + } else { + nextSize = snapNumber(nextSize, 1); + showLabel = true; + } + return movement.shift ? { rx: nextSize, ry: nextSize } : { ry: nextSize }; + }, + getControlFn: (s, scale) => + applyAffine(getShapeTransform(s), getLocalCornerControl(s, scale)[1]), + renderFn: (ctx, renderCtx, s) => { + renderCornerGuidlinesForRadius( + renderCtx, + ctx.getStyleScheme(), + ctx.getScale(), + s, + showLabel, + ); + }, + }); + }; + case "spike": + return () => { + let showLabel = !event.data.options.ctrl; + return movingShapeControlState({ + targetId: targetShape.id, + snapType: "custom", + patchFn: (s, p, movement) => { + const localP = applyAffine(getShapeDetransform(s), p); + let nextWidth = clamp(0, s.width / 2, s.width / 2 - localP.x) * 2; + let nextHeight = clamp(0, s.height / 2, localP.y); + if (movement.ctrl) { + showLabel = false; + } else { + nextWidth = snapNumber(nextWidth, 2); + nextHeight = snapNumber(nextHeight, 1); + showLabel = true; + } + return { spikeSize: { width: nextWidth, height: nextHeight } }; + }, + getControlFn: (s) => + applyAffine(getShapeTransform(s), { + x: (s.width - s.spikeSize.width) / 2, + y: s.spikeSize.height, + }), + renderFn: (ctx, renderCtx, s) => { + renderCornerGuidlinesForSpike( + renderCtx, + ctx.getStyleScheme(), + ctx.getScale(), + s, + showLabel, + ); + }, + }); + }; + default: + return; + } + } + } + } + } + }, + }; + }, + (ctx, target) => + newSimplePolygonHandler({ + getShapeComposite: ctx.getShapeComposite, + targetId: target.id, + getAnchors: () => { + const scale = ctx.getScale(); + const radiusC = getLocalCornerControl(target, scale); + return [ + ["rx", radiusC[0]], + ["ry", radiusC[1]], + ["spike", { x: (target.width - target.spikeSize.width) / 2, y: target.spikeSize.height }], + ]; + }, + }), +); + +function getLocalCornerControl(shape: SpikyRectangleShape, scale: number): [IVec2, IVec2] { + const margin = 16 * scale; + const { rx, ry } = getSpikeParameters(shape); + const rect = getSpikyInnerRectangle(shape); + return [ + { x: rect.x + rx, y: rect.y - margin }, + { x: rect.x - margin, y: rect.y + ry }, + ]; +} + +function renderCornerGuidlinesForRadius( + renderCtx: CanvasRenderingContext2D, + style: StyleScheme, + scale: number, + shape: SpikyRectangleShape, + showLabel = false, +) { + applyLocalSpace(renderCtx, getRectShapeRect(shape), shape.rotation, () => { + const innerRect = getSpikyInnerRectangle(shape); + const [cornerXC, cornerYC] = getLocalCornerControl(shape, scale); + + if (showLabel) { + const margin = 20 * scale; + renderValueLabel( + renderCtx, + shape.rx ?? 0, + { x: innerRect.x, y: innerRect.y - margin }, + -shape.rotation, + scale, + true, + ); + renderValueLabel( + renderCtx, + shape.ry ?? 0, + { x: innerRect.x - margin, y: innerRect.y }, + -shape.rotation, + scale, + true, + ); + } + + applyStrokeStyle(renderCtx, { + color: style.selectionSecondaly, + width: 2 * scale, + dash: "short", + }); + + renderCtx.beginPath(); + renderCtx.rect(innerRect.x, innerRect.y, cornerXC.x - innerRect.x, cornerYC.y - innerRect.y); + renderCtx.stroke(); + }); +} + +function renderCornerGuidlinesForSpike( + renderCtx: CanvasRenderingContext2D, + style: StyleScheme, + scale: number, + shape: SpikyRectangleShape, + showLabel = false, +) { + applyLocalSpace(renderCtx, getRectShapeRect(shape), shape.rotation, () => { + if (showLabel) { + const margin = 20 * scale; + renderValueLabel( + renderCtx, + shape.spikeSize.width / 2, + { x: shape.width / 2 - shape.spikeSize.width / 4, y: -margin }, + -shape.rotation, + scale, + true, + ); + renderValueLabel( + renderCtx, + shape.spikeSize.height, + { x: shape.width / 2 - shape.spikeSize.width / 2 - margin, y: shape.spikeSize.height / 2 }, + -shape.rotation, + scale, + true, + ); + } + + applyStrokeStyle(renderCtx, { + color: style.selectionSecondaly, + width: 2 * scale, + dash: "short", + }); + + renderCtx.beginPath(); + renderCtx.rect((shape.width - shape.spikeSize.width) / 2, 0, shape.spikeSize.width / 2, shape.spikeSize.height); + renderCtx.stroke(); + }); +} diff --git a/src/shapes/commonStructs.ts b/src/shapes/commonStructs.ts index 18b244ae..31475dae 100644 --- a/src/shapes/commonStructs.ts +++ b/src/shapes/commonStructs.ts @@ -20,6 +20,7 @@ import { struct as capsule } from "./polygons/capsule"; import { struct as cylinder } from "./polygons/cylinder"; import { struct as document_symbol } from "./polygons/documentSymbol"; import { struct as star } from "./polygons/star"; +import { struct as spiky_rectangle } from "./polygons/spikyRectangle"; import { struct as bubble } from "./polygons/bubble"; import { struct as one_sided_arrow } from "./oneSidedArrow"; import { struct as two_sided_arrow } from "./twoSidedArrow"; @@ -58,6 +59,7 @@ export const SHAPE_COMMON_STRUCTS: { cylinder, document_symbol, star, + spiky_rectangle, bubble, one_sided_arrow, two_sided_arrow, diff --git a/src/shapes/polygons/roundedRectangle.ts b/src/shapes/polygons/roundedRectangle.ts index 6a229d85..18d7dfa3 100644 --- a/src/shapes/polygons/roundedRectangle.ts +++ b/src/shapes/polygons/roundedRectangle.ts @@ -4,7 +4,7 @@ import { SimplePath, SimplePolygonShape, getStructForSimplePolygon } from "../si import { createBoxPadding, getPaddingRect } from "../../utils/boxPadding"; import { createFillStyle } from "../../utils/fillStyle"; import { createStrokeStyle } from "../../utils/strokeStyle"; -import { getRoundedRectInnerBounds } from "../../utils/geometry"; +import { getBezierControlPaddingForBorderRadius, getRoundedRectInnerBounds } from "../../utils/geometry"; export type RoundedRectangleShape = SimplePolygonShape & { rx: number; @@ -78,8 +78,8 @@ function getPath(shape: RoundedRectangleShape): SimplePath { function getCornerValue(shape: RoundedRectangleShape): IVec2 { const { x: rx, y: ry } = getCornerRadius(shape); - const rate = 0.44772; // Magic value to approximate border-radius via cubic-bezier - return { x: rx * rate, y: ry * rate }; + const [x, y] = getBezierControlPaddingForBorderRadius(rx, ry); + return { x, y }; } function getCornerRadius(shape: RoundedRectangleShape): IVec2 { diff --git a/src/shapes/polygons/spikyRectangle.ts b/src/shapes/polygons/spikyRectangle.ts new file mode 100644 index 00000000..717a7326 --- /dev/null +++ b/src/shapes/polygons/spikyRectangle.ts @@ -0,0 +1,152 @@ +import { IRectangle, IVec2, clamp } from "okageo"; +import { ShapeStruct, createBaseShape } from "../core"; +import { SimplePath, getStructForSimplePolygon } from "../simplePolygon"; +import { createBoxPadding, getPaddingRect } from "../../utils/boxPadding"; +import { createFillStyle } from "../../utils/fillStyle"; +import { createStrokeStyle } from "../../utils/strokeStyle"; +import { getBezierControlPaddingForBorderRadius, getRoundedRectInnerBounds } from "../../utils/geometry"; +import { RoundedRectangleShape } from "./roundedRectangle"; +import { Size } from "../../models"; + +export type SpikyRectangleShape = RoundedRectangleShape & { + spikeSize: Size; +}; + +export const struct: ShapeStruct = { + ...getStructForSimplePolygon(getPath, { outlineSnap: "trbl" }), + label: "SpikyRectangle", + create(arg = {}) { + return { + ...createBaseShape(arg), + type: "spiky_rectangle", + fill: arg.fill ?? createFillStyle(), + stroke: arg.stroke ?? createStrokeStyle(), + width: arg.width ?? 100, + height: arg.height ?? 100, + textPadding: arg.textPadding ?? createBoxPadding([2, 2, 2, 2]), + rx: arg.rx ?? 10, + ry: arg.ry ?? 10, + spikeSize: arg.spikeSize ?? { width: 20 / Math.sqrt(3), height: 10 }, + }; + }, + getTextRangeRect(shape) { + const { rx, ry, spikeHeightV, spikeHeightH } = getSpikeParameters(shape); + const rect = getRoundedRectInnerBounds( + { + x: shape.p.x + spikeHeightH, + y: shape.p.y + spikeHeightV, + width: shape.width - spikeHeightH * 2, + height: shape.height - spikeHeightV * 2, + }, + rx, + ry, + ); + return shape.textPadding ? getPaddingRect(shape.textPadding, rect) : rect; + }, +}; + +function getPath(shape: SpikyRectangleShape): SimplePath { + const c = { x: shape.width / 2, y: shape.height / 2 }; + const { rx, ry, spikeWidthV, spikeWidthH, spikeHeightV, spikeHeightH } = getSpikeParameters(shape); + const [bx, by] = getBezierControlPaddingForBorderRadius(rx, ry); + const top = spikeHeightV; + const right = shape.width - spikeHeightH; + const bottom = shape.height - spikeHeightV; + const left = spikeHeightH; + + return { + path: [ + { x: left + rx, y: top }, + + { x: c.x - spikeWidthV / 2, y: top }, + { x: c.x, y: 0 }, + { x: c.x + spikeWidthV / 2, y: top }, + + { x: right - rx, y: top }, + { x: right, y: top + ry }, + + { x: right, y: c.y - spikeWidthH / 2 }, + { x: shape.width, y: c.y }, + { x: right, y: c.y + spikeWidthH / 2 }, + + { x: right, y: bottom - ry }, + { x: right - rx, y: bottom }, + + { x: c.x + spikeWidthV / 2, y: bottom }, + { x: c.x, y: shape.height }, + { x: c.x - spikeWidthV / 2, y: bottom }, + + { x: left + rx, y: bottom }, + { x: left, y: bottom - ry }, + + { x: left, y: c.y + spikeWidthH / 2 }, + { x: 0, y: c.y }, + { x: left, y: c.y - spikeWidthH / 2 }, + + { x: left, y: top + ry }, + { x: left + rx, y: top }, + ], + curves: + rx === 0 || ry === 0 + ? undefined + : [ + undefined, + undefined, + undefined, + undefined, + { c1: { x: right - bx, y: top }, c2: { x: right, y: top + by } }, + undefined, + undefined, + undefined, + undefined, + { c1: { x: right, y: bottom - by }, c2: { x: right - bx, y: bottom } }, + undefined, + undefined, + undefined, + undefined, + { c1: { x: left + bx, y: bottom }, c2: { x: left, y: bottom - by } }, + undefined, + undefined, + undefined, + undefined, + { c1: { x: left, y: top + by }, c2: { x: left + bx, y: top } }, + ], + }; +} + +function getCornerRadius(shape: SpikyRectangleShape): IVec2 { + return { x: clamp(0, shape.width / 2, shape.rx), y: clamp(0, shape.height / 2, shape.ry) }; +} + +export function getSpikeParameters(shape: SpikyRectangleShape) { + const cr = getCornerRadius(shape); + const spikeWidth = shape.spikeSize.width; + const spikeHeight = shape.spikeSize.height; + const [rx, ry] = [ + clamp(0, (shape.width - spikeHeight * 2 - spikeWidth) / 2, cr.x), + clamp(0, (shape.height - spikeHeight * 2 - spikeWidth) / 2, cr.y), + ]; + const spikeWidthV = clamp(0, shape.width - (spikeHeight + rx) * 2, spikeWidth); + const spikeWidthH = clamp(0, shape.height - (spikeHeight + ry) * 2, spikeWidth); + const spikeHeightV = clamp(0, shape.height / 2 - ry, spikeHeight); + const spikeHeightH = clamp(0, shape.width / 2 - rx, spikeHeight); + + return { + rx, + ry, + spikeWidthV, + spikeWidthH, + spikeHeightV, + spikeHeightH, + }; +} + +export function getSpikyInnerRectangle(shape: SpikyRectangleShape): IRectangle { + const { spikeHeightV, spikeHeightH } = getSpikeParameters(shape); + return { + x: spikeHeightH, + y: spikeHeightV, + width: shape.width - spikeHeightH * 2, + height: shape.height - spikeHeightV * 2, + }; +} diff --git a/src/utils/geometry.ts b/src/utils/geometry.ts index 2191ffad..0a589acc 100644 --- a/src/utils/geometry.ts +++ b/src/utils/geometry.ts @@ -1398,3 +1398,8 @@ export function getEllipseSlopeAt(c: IVec2, rx: number, ry: number, p: IVec2): n const centeredP = sub(p, c); return Math.atan2(centeredP.y * rx * rx, centeredP.x * ry * ry) - Math.PI / 2; } + +export function getBezierControlPaddingForBorderRadius(rx: number, ry: number): [rx: number, ry: number] { + const rate = 0.44772; // Magic value to approximate border-radius via cubic-bezier + return [rx * rate, ry * rate]; +}