diff --git a/lib/bounding-box.test.ts b/lib/bounding-box.test.ts new file mode 100644 index 0000000..541a594 --- /dev/null +++ b/lib/bounding-box.test.ts @@ -0,0 +1,72 @@ +import { BBox } from "../vendor/bezier-js"; +import { uniformlyScaleToFit } from "./bounding-box"; + +describe("uniformlyScaleToFit()", () => { + it("returns 1 for identical boxes", () => { + const box: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + expect(uniformlyScaleToFit(box, box)).toBe(1.0); + }); + + it("returns 1 for identically-sized boxes", () => { + const box1: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + const box2: BBox = { + x: { min: -5, max: -4 }, + y: { min: -20, max: -19 }, + }; + expect(uniformlyScaleToFit(box1, box2)).toBe(1.0); + }); + + it("returns 2 when child is half the size of parent", () => { + const parent: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + const child: BBox = { + x: { min: 0, max: 0.5 }, + y: { min: 0, max: 0.5 }, + }; + expect(uniformlyScaleToFit(parent, child)).toBe(2.0); + }); + + it("returns 0.5 when child is twice the size of parent", () => { + const parent: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + const child: BBox = { + x: { min: 0, max: 2 }, + y: { min: 0, max: 2 }, + }; + expect(uniformlyScaleToFit(parent, child)).toBe(0.5); + }); + + it("returns 1 when child is same width as parent but shorter", () => { + const parent: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + const child: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 0.1 }, + }; + expect(uniformlyScaleToFit(parent, child)).toBe(1); + }); + + it("returns 1 when child is same height as parent but thinner", () => { + const parent: BBox = { + x: { min: 0, max: 1 }, + y: { min: 0, max: 1 }, + }; + const child: BBox = { + x: { min: 0, max: 0.1 }, + y: { min: 0, max: 1 }, + }; + expect(uniformlyScaleToFit(parent, child)).toBe(1); + }); +}); diff --git a/lib/bounding-box.ts b/lib/bounding-box.ts index 4c5917b..546b11f 100644 --- a/lib/bounding-box.ts +++ b/lib/bounding-box.ts @@ -91,3 +91,18 @@ export function getSvgBoundingBox( return getPathBoundingBox(element.props); } } + +/** + * Assuming the origins of the giving boxes are aligned and + * the transform origin is set to their center, return the maximum + * amount the child needs to be scaled to fit within the parent. + */ +export function uniformlyScaleToFit(parent: BBox, child: BBox): number { + const [pWidth, pHeight] = getBoundingBoxSize(parent); + const [cWidth, cHeight] = getBoundingBoxSize(child); + + const widthScale = pWidth / cWidth; + const heightScale = pHeight / cHeight; + + return Math.min(widthScale, heightScale); +} diff --git a/lib/pages/creature-page.tsx b/lib/pages/creature-page.tsx index 5f1c553..96a9fe4 100644 --- a/lib/pages/creature-page.tsx +++ b/lib/pages/creature-page.tsx @@ -12,11 +12,12 @@ import { PointWithNormal, } from "../specs"; import { getAttachmentTransforms } from "../attach"; -import { scalePointXY } from "../point"; -import { Point } from "../../vendor/bezier-js"; +import { scalePointXY, subtractPoints } from "../point"; +import { BBox, Point } from "../../vendor/bezier-js"; import { Random } from "../random"; import { SymbolContextWidget } from "../symbol-context-widget"; import { range } from "../util"; +import { getBoundingBoxCenter, uniformlyScaleToFit } from "../bounding-box"; const DEFAULT_BG_COLOR = "#858585"; @@ -89,6 +90,7 @@ type AttachmentIndices = { type CreatureSymbolProps = AttachmentIndices & { data: SvgSymbolData; + nestInside?: boolean; children?: AttachmentChildren; attachTo?: AttachmentPointType; indices?: number[]; @@ -109,40 +111,80 @@ function getAttachmentIndices(ai: AttachmentIndices): number[] { return result; } -const CreatureSymbol: React.FC = (props) => { - const ctx = useContext(CreatureContext); - const { data, attachTo } = props; - const ourSymbol = ( - <> - {props.children && ( - - {props.children} - - )} - - - ); +type SplitCreatureSymbolChildren = { + attachments: JSX.Element[]; + nests: JSX.Element[]; +}; - if (!attachTo) { - return ourSymbol; - } +function splitCreatureSymbolChildren( + children?: AttachmentChildren +): SplitCreatureSymbolChildren { + const result: SplitCreatureSymbolChildren = { + attachments: [], + nests: [], + }; + if (!children) return result; - const parent = ctx.parent; - if (!parent) { - throw new Error( - `Cannot attach ${props.data.name} because it has no parent!` + React.Children.forEach(children, (child) => { + if (child.props.nestInside) { + result.nests.push(child); + } else { + result.attachments.push(child); + } + }); + + return result; +} + +type ChildCreatureSymbolProps = { + symbol: JSX.Element; + data: SvgSymbolData; + parent: SvgSymbolData; + indices: number[]; +}; + +const NestedCreatureSymbol: React.FC = ({ + symbol, + data, + parent, + indices, +}) => { + const children: JSX.Element[] = []; + + for (let nestIndex of indices) { + const parentNest = (parent.specs?.nesting ?? [])[nestIndex]; + if (!parentNest) { + console.error( + `Parent symbol ${parent.name} has no nesting index ${nestIndex}!` + ); + continue; + } + const t = getNestingTransforms(parentNest, data.bbox); + children.push( + + {symbol} + ); } - const attachmentIndices = props.indices || getAttachmentIndices(props); + return <>{children}; +}; + +const AttachedCreatureSymbol: React.FC< + ChildCreatureSymbolProps & { + attachTo: AttachmentPointType; + } +> = ({ symbol, data, parent, indices, attachTo }) => { + const ctx = useContext(CreatureContext); const children: JSX.Element[] = []; - for (let attachIndex of attachmentIndices) { + for (let attachIndex of indices) { const parentAp = safeGetAttachmentPoint(parent, attachTo, attachIndex); const ourAp = safeGetAttachmentPoint(data, "anchor"); @@ -173,7 +215,7 @@ const CreatureSymbol: React.FC = (props) => { scale={{ x: ctx.attachmentScale * xFlip, y: ctx.attachmentScale }} rotate={xFlip * t.rotation} > - {ourSymbol} + {symbol} ); } @@ -181,6 +223,66 @@ const CreatureSymbol: React.FC = (props) => { return <>{children}; }; +const CreatureSymbol: React.FC = (props) => { + const ctx = useContext(CreatureContext); + const { data, attachTo, nestInside } = props; + const childCtx: CreatureContextType = { ...ctx, parent: data }; + const { nests, attachments } = splitCreatureSymbolChildren(props.children); + + // The attachments should be before our symbol in the DOM so they + // appear behind our symbol, while anything nested within our symbol + // should be after our symbol so they appear in front of it. + const symbol = ( + <> + {attachments.length && ( + + {attachments} + + )} + + {nests.length && ( + + {nests} + + )} + + ); + + if (!(attachTo || nestInside)) { + return symbol; + } + + const parent = ctx.parent; + if (!parent) { + throw new Error( + `Cannot attach/nest ${props.data.name} because it has no parent!` + ); + } + + const childProps: ChildCreatureSymbolProps = { + parent, + symbol, + data, + indices: props.indices || getAttachmentIndices(props), + }; + + if (attachTo) { + return ; + } + + return ; +}; + +function getNestingTransforms(parent: BBox, child: BBox) { + const parentCenter = getBoundingBoxCenter(parent); + const childCenter = getBoundingBoxCenter(child); + const translation = subtractPoints(parentCenter, childCenter); + const uniformScaling = uniformlyScaleToFit(parent, child); + const scaling: Point = { x: uniformScaling, y: uniformScaling }; + + return { translation, transformOrigin: childCenter, scaling }; +} + type AttachmentTransformProps = { transformOrigin: Point; translate: Point; @@ -242,6 +344,8 @@ const Leg = createCreatureSymbol("leg"); const Tail = createCreatureSymbol("tail"); +const Lightning = createCreatureSymbol("lightning"); + function getSymbolWithAttachments( numAttachmentKinds: number, rng: Random @@ -273,6 +377,7 @@ function getSymbolWithAttachments( const EYE_CREATURE = ( +