diff --git a/B07_DesignDetail/openwebcad/index.html b/B07_DesignDetail/openwebcad/index.html index 365aed00..4eb332cc 100644 --- a/B07_DesignDetail/openwebcad/index.html +++ b/B07_DesignDetail/openwebcad/index.html @@ -7,9 +7,9 @@ Aislo 2D Drawing - -
- + +
+
diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 8dc87e3f..92bf0607 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +/* 색 토큰의 정의처는 앱과 같은 파일 하나 — 여기서 색을 새로 정하지 않는다. */ +@import "../../../ui_template/ui_template_theme.css"; :root { --cad-title-height: 42px; @@ -7,9 +9,21 @@ --cad-command-height: 0px; --cad-status-height: 28px; --cad-panel-width: 248px; - font-family: Inter, Pretendard, "Noto Sans KR", system-ui, sans-serif; - color: #dce6f2; - background: #11161d; + font-family: var(--font-body); + color: var(--color-text-body); + background: var(--color-bg); + + /* CAD 표면 단계 — 앱 토큰을 CAD 골격(툴바·리본·패널)에 이름만 붙여 쓴다. */ + --cad-chrome: var(--color-surface); + --cad-chrome-raised: var(--color-surface-raised); + --cad-chrome-sunken: var(--color-bg); + --cad-line: var(--color-border); + --cad-text: var(--color-text-body); + --cad-text-dim: var(--color-text-secondary); + --cad-text-muted: var(--color-text-muted); + --cad-accent: var(--color-accent); + --cad-hover: var(--color-mist-violet); + --cad-canvas: var(--color-canvas); } * { @@ -56,7 +70,7 @@ body > canvas[data-id="canvas"] { var(--cad-command-height) - var(--cad-status-height) ); - background: #111; + background: var(--cad-canvas); cursor: none; } @@ -68,9 +82,9 @@ body > canvas[data-id="canvas"] { align-items: center; gap: 24px; padding: 0 12px; - background: #19222d; - border-bottom: 1px solid #344252; - box-shadow: 0 1px 4px #0008; + background: var(--cad-chrome); + border-bottom: 1px solid var(--cad-line); + box-shadow: var(--shadow-sm); } .cad-brand { display: flex; @@ -79,12 +93,12 @@ body > canvas[data-id="canvas"] { min-width: 220px; } .cad-brand strong { - color: #f7fbff; + color: var(--color-text); font-size: 14px; } .cad-brand span, .cad-file-state { - color: #91a2b5; + color: var(--cad-text-dim); font-size: 11px; } .cad-file-state { @@ -96,8 +110,8 @@ body > canvas[data-id="canvas"] { width: 7px; height: 7px; border-radius: 50%; - background: #49b675; - box-shadow: 0 0 0 2px #49b67522; + background: var(--color-success); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-success) 25%, transparent); } .cad-title-actions { display: flex; @@ -114,8 +128,8 @@ body > canvas[data-id="canvas"] { font-size: 12px; } .cad-title-actions button:hover { - border-color: #4d6074; - background: #273544; + border-color: var(--cad-line); + background: var(--cad-hover); } .cad-ribbon { @@ -128,15 +142,15 @@ body > canvas[data-id="canvas"] { align-items: stretch; padding: 5px 8px 3px; overflow-x: auto; - background: #202b37; - border-bottom: 1px solid #3c4a59; + background: var(--cad-chrome-raised); + border-bottom: 1px solid var(--cad-line); } .cad-ribbon-group { display: flex; flex-direction: column; min-width: max-content; padding: 0 9px; - border-right: 1px solid #40505f; + border-right: 1px solid var(--cad-line); } .cad-ribbon-tools { display: flex; @@ -145,7 +159,7 @@ body > canvas[data-id="canvas"] { } .cad-ribbon-group__label { margin-top: auto; - color: #8293a5; + color: var(--cad-text-muted); font-size: 10px; text-align: center; } @@ -159,7 +173,7 @@ body > canvas[data-id="canvas"] { gap: 3px; align-items: stretch; font-size: 10px; - color: #8293a5; + color: var(--cad-text-muted); } .cad-prop > span { text-align: center; @@ -169,9 +183,9 @@ body > canvas[data-id="canvas"] { height: 24px; min-width: 64px; padding: 0 4px; - color: #dce6f2; - background: #2a3745; - border: 1px solid #40505f; + color: var(--cad-text); + background: var(--cad-chrome-sunken); + border: 1px solid var(--cad-line); border-radius: 4px; font-size: 11px; } @@ -183,8 +197,8 @@ body > canvas[data-id="canvas"] { height: 24px; width: 40px; padding: 1px; - background: #2a3745; - border: 1px solid #40505f; + background: var(--cad-chrome-sunken); + border: 1px solid var(--cad-line); border-radius: 4px; cursor: pointer; } @@ -203,24 +217,24 @@ body > canvas[data-id="canvas"] { } .cad-tool:hover:not(:disabled), .cad-tool[data-active="true"] { - border-color: #4f87b8; - background: #2b455c; + border-color: var(--cad-accent); + background: var(--cad-hover); } .cad-tool[data-active="true"] { - box-shadow: inset 0 -2px #4ca6e8; + box-shadow: inset 0 -2px var(--cad-accent); } .cad-tool:disabled { - color: #657382; + color: var(--color-smoke); cursor: not-allowed; } .cad-tool__glyph { height: 27px; - color: #a9d7fb; + color: var(--cad-accent); font-size: 22px; line-height: 27px; } .cad-tool:disabled .cad-tool__glyph { - color: #657382; + color: var(--color-smoke); } .cad-inspector { @@ -230,8 +244,8 @@ body > canvas[data-id="canvas"] { bottom: calc(var(--cad-command-height) + var(--cad-status-height)); left: 0; width: var(--cad-panel-width); - background: #1b2530; - border-right: 1px solid #3d4c5c; + background: var(--cad-chrome); + border-right: 1px solid var(--cad-line); transition: width 120ms ease; } .cad-inspector[data-collapsed="true"] { @@ -244,57 +258,57 @@ body > canvas[data-id="canvas"] { left: 100%; width: 20px; height: 38px; - border: 1px solid #465769; + border: 1px solid var(--cad-line); border-left: 0; border-radius: 0 4px 4px 0; - background: #263442; - color: #a9bbcd; + background: var(--cad-chrome-raised); + color: var(--cad-text-dim); } .cad-inspector-tabs { display: grid; grid-template-columns: 1fr 1fr; height: 38px; - border-bottom: 1px solid #394857; + border-bottom: 1px solid var(--cad-line); } .cad-inspector-tabs button { border: 0; border-bottom: 2px solid transparent; - background: #17202a; - color: #94a7ba; + background: var(--cad-chrome-sunken); + color: var(--cad-text-dim); font-size: 12px; } .cad-inspector-tabs button[data-active="true"] { - border-bottom-color: #4da3df; - background: #223141; - color: white; + border-bottom-color: var(--cad-accent); + background: var(--cad-chrome-raised); + color: var(--color-text); } .cad-properties { padding: 14px 12px; } .cad-properties h2 { margin: 0 0 14px; - color: #f3f7fb; + color: var(--color-text); font-size: 13px; font-weight: 600; } .cad-properties dl { margin: 0; - border-top: 1px solid #344352; + border-top: 1px solid var(--cad-line); } .cad-properties dl div { display: grid; grid-template-columns: 92px 1fr; padding: 8px 0; - border-bottom: 1px solid #2d3a47; + border-bottom: 1px solid var(--cad-line); font-size: 11px; } .cad-properties dt { - color: #8092a4; + color: var(--cad-text-dim); } .cad-properties dd { margin: 0; overflow: hidden; - color: #d7e1eb; + color: var(--cad-text); text-overflow: ellipsis; } .cad-layer-manager { @@ -306,7 +320,23 @@ body > canvas[data-id="canvas"] { min-height: 36px; padding-top: 6px; padding-bottom: 6px; - background: #202c38; +} +/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */ +.cad-layer-manager button[data-type="regular"] { + background: var(--cad-chrome-sunken); + color: var(--cad-accent); +} +.cad-layer-manager button[data-type="transparent"] { + background: transparent; + color: var(--cad-accent); +} +.cad-layer-manager button:hover { + background: var(--cad-hover); + color: var(--color-text); +} +.cad-layer-manager button[data-active="true"] { + background: var(--color-primary); + color: var(--color-primary-text); } .cad-view-controls { @@ -317,29 +347,29 @@ body > canvas[data-id="canvas"] { display: flex; align-items: center; overflow: hidden; - border: 1px solid #465565; + border: 1px solid var(--cad-line); border-radius: 4px; - background: #202b37e8; - box-shadow: 0 3px 12px #0008; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); } .cad-view-controls button { width: 34px; height: 32px; border: 0; - border-right: 1px solid #3d4c5b; + border-right: 1px solid var(--cad-line); background: transparent; font-size: 17px; } .cad-view-controls button:hover { - background: #31516b; + background: var(--cad-hover); } .cad-view-controls__fit { - color: #7fd0ff; + color: var(--cad-accent); font-size: 15px; } .cad-view-controls span { min-width: 48px; - color: #9fb0c1; + color: var(--cad-text-dim); font-size: 10px; text-align: center; } @@ -357,11 +387,11 @@ body > canvas[data-id="canvas"] { white-space: nowrap; } .cad-command-prompt span { - color: #6eaee0; + color: var(--cad-accent); } .cad-command-prompt strong { overflow: hidden; - color: #aab8c6; + color: var(--cad-text-dim); font-weight: 400; text-overflow: ellipsis; } @@ -372,7 +402,7 @@ body > canvas[data-id="canvas"] { height: 28px; } .cad-command-area label { - color: #e6eef6; + color: var(--color-text); font-size: 11px; font-weight: 600; } @@ -380,16 +410,16 @@ body > canvas[data-id="canvas"] { flex: 1; height: 26px; padding: 0 8px; - border: 1px solid #46586a; + border: 1px solid var(--cad-line); border-radius: 2px; outline: none; - background: #0e141a; - color: #eef5fc; + background: var(--cad-chrome-sunken); + color: var(--cad-text); font: 11px Consolas, monospace; } .cad-command-area input:focus { - border-color: #4b9bd3; - box-shadow: 0 0 0 1px #4b9bd344; + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); } .cad-statusbar { @@ -403,8 +433,8 @@ body > canvas[data-id="canvas"] { align-items: center; gap: 2px; padding: 0 8px; - background: #263544; - border-top: 1px solid #415263; + background: var(--cad-chrome); + border-top: 1px solid var(--cad-line); } .cad-statusbar button { height: 21px; @@ -412,20 +442,20 @@ body > canvas[data-id="canvas"] { border: 1px solid transparent; border-radius: 2px; background: transparent; - color: #9caebf; + color: var(--cad-text-dim); font-size: 10px; } .cad-statusbar button:hover { - background: #33485b; + background: var(--cad-hover); } .cad-statusbar button[data-active="true"] { - border-color: #4f9bd0; - background: #285579; - color: white; + border-color: var(--cad-accent); + background: var(--color-primary); + color: var(--color-primary-text); } .cad-statusbar__hint { margin-left: auto; - color: #7f91a2; + color: var(--cad-text-muted); font-size: 10px; } @@ -441,11 +471,11 @@ body > canvas[data-id="canvas"] { /* 표(가장 넓은 자식) 기준 폭 고정 → 접었다 펴도 폭이 변하지 않는다 */ width: max-content; max-width: min(1040px, calc(100vw - var(--cad-panel-width) - 24px)); - border: 1px solid #465565; + border: 1px solid var(--cad-line); border-radius: 6px; - background: #202b37f2; - box-shadow: 0 4px 16px #0009; - color: #dce6f2; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); + color: var(--cad-text); } .cad-qtable__header { @@ -463,12 +493,12 @@ body > canvas[data-id="canvas"] { min-width: 0; } .cad-qtable__title strong { - color: #f7fbff; + color: var(--color-text); font-size: 14px; } .cad-qtable__title span { overflow: hidden; - color: #91a2b5; + color: var(--cad-text-dim); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; @@ -480,26 +510,26 @@ body > canvas[data-id="canvas"] { font-style: normal; } .cad-qtable__status[data-confirmed="true"] { - background: #1f4d33; - color: #7fdca4; + background: color-mix(in srgb, var(--color-success) 22%, transparent); + color: var(--color-success); } .cad-qtable__status[data-confirmed="false"] { - background: #4a3a1c; - color: #e6c07a; + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: var(--color-warning); } .cad-qtable__nav, .cad-qtable__collapse { width: 26px; height: 24px; - border: 1px solid #40505f; + border: 1px solid var(--cad-line); border-radius: 4px; - background: #2a3745; - color: #cdd8e4; + background: var(--cad-chrome-sunken); + color: var(--cad-text); font-size: 13px; } .cad-qtable__nav:hover:not(:disabled), .cad-qtable__collapse:hover { - background: #31516b; + background: var(--cad-hover); } .cad-qtable__nav:disabled { opacity: 0.35; @@ -525,17 +555,17 @@ body > canvas[data-id="canvas"] { .cad-qtable th, .cad-qtable td { padding: 0; - border: 1px solid #40505f; + border: 1px solid var(--cad-line); font-size: 11px; text-align: center; vertical-align: middle; } .cad-qtable__label { padding: 2px 9px; - color: #a9bccd; + color: var(--cad-text-dim); font-weight: 600; white-space: nowrap; - background: #26333f; + background: var(--cad-chrome); } .cad-qtable__label--vertical { width: 18px; @@ -548,18 +578,18 @@ body > canvas[data-id="canvas"] { padding: 3px 4px; border: 0; background: transparent; - color: #eaf1f8; + color: var(--cad-text); font: inherit; text-align: center; } .cad-qtable__value input:focus { - outline: 2px solid #4ca6e8; + outline: 2px solid var(--color-focus-ring); outline-offset: -2px; - background: #12324a; + background: var(--cad-hover); } .cad-qtable__value--derived input { - color: #8fa3b5; - background: #1a242e; + color: var(--cad-text-muted); + background: var(--cad-chrome-sunken); cursor: not-allowed; } diff --git a/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx new file mode 100644 index 00000000..0de5382a --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/Icon/Icon.tsx @@ -0,0 +1,191 @@ +import type {FC} from 'react'; +import AlignBottomIcon from 'teenyicons/outline/align-bottom.svg?react'; +import AlignCenterHorizontalIcon from 'teenyicons/outline/align-center-horizontal.svg?react'; +import AlignCenterVerticalIcon from 'teenyicons/outline/align-center-vertical.svg?react'; +import AlignLeftIcon from 'teenyicons/outline/align-left.svg?react'; +import AlignRightIcon from 'teenyicons/outline/align-right.svg?react'; +import AlignTextJustifyIcon from 'teenyicons/outline/align-text-justify.svg?react'; +import AlignTopIcon from 'teenyicons/outline/align-top.svg?react'; +import AntiClockwiseIcon from 'teenyicons/outline/anti-clockwise.svg?react'; +import ArrowLeftCircle from 'teenyicons/outline/arrow-left-circle.svg?react'; +import ArrowRightCircle from 'teenyicons/outline/arrow-right-circle.svg?react'; +import CircleIcon from 'teenyicons/outline/circle.svg?react'; +import ClockwiseIcon from 'teenyicons/outline/clockwise.svg?react'; +import CropIcon from 'teenyicons/outline/crop.svg?react'; +import DirectionIcon from 'teenyicons/outline/direction.svg?react'; +import DocumentsIcon from 'teenyicons/outline/documents.svg?react'; +import DownloadIcon from 'teenyicons/outline/download.svg?react'; +import EditIcon from 'teenyicons/outline/edit.svg?react'; +import ExpandIcon from 'teenyicons/outline/expand.svg?react'; +import EyeClosedIcon from 'teenyicons/outline/eye-closed.svg?react'; +import EyeIcon from 'teenyicons/outline/eye.svg?react'; +import FilePlusIcon from 'teenyicons/outline/file-plus.svg?react'; +import FolderPlusIcon from 'teenyicons/outline/folder-plus.svg?react'; +import FolderTickIcon from 'teenyicons/outline/folder-tick.svg?react'; +import FolderXIcon from 'teenyicons/outline/folder-x.svg?react'; +import FolderIcon from 'teenyicons/outline/folder.svg?react'; +import GithubIcon from 'teenyicons/outline/github.svg?react'; +import GridLayoutIcon from 'teenyicons/outline/grid-layout.svg?react'; +import ImageIcon from 'teenyicons/outline/image.svg?react'; +import JavascriptIcon from 'teenyicons/outline/javascript.svg?react'; +import LayersDifferenceIcon from 'teenyicons/outline/layers-difference.svg?react'; +import LineIcon from 'teenyicons/outline/line.svg?react'; +import LockIcon from 'teenyicons/outline/lock.svg?react'; +import PdfIcon from 'teenyicons/outline/pdf.svg?react'; +import PngIcon from 'teenyicons/outline/png.svg?react'; +import SaveIcon from 'teenyicons/outline/save.svg?react'; +import SendDownIcon from 'teenyicons/outline/send-down.svg?react'; +import SendUpIcon from 'teenyicons/outline/send-up.svg?react'; +import SquareIcon from 'teenyicons/outline/square.svg?react'; +import SvgIcon from 'teenyicons/outline/svg.svg?react'; +import UnlockIcon from 'teenyicons/outline/unlock.svg?react'; +import HomeAltIcon from 'teenyicons/outline/home-alt.svg?react'; + +import VectorDocumentIcon from 'teenyicons/outline/vector-document.svg?react'; +import SolidDownSmallIcon from 'teenyicons/solid/down-small.svg?react'; +import SolidDownIcon from 'teenyicons/solid/down.svg?react'; +import ImageSoldIcon from 'teenyicons/solid/image.svg?react'; +import JavascriptSolidIcon from 'teenyicons/solid/javascript.svg?react'; +import PdfSolidIcon from 'teenyicons/solid/pdf.svg?react'; +import SolidUpSmallIcon from 'teenyicons/solid/up-small.svg?react'; +import SolidUpIcon from 'teenyicons/solid/up.svg?react'; +import VectorDocumentSolidIcon from 'teenyicons/solid/vector-document.svg?react'; +import MeasurementIcon from './custom-icons/measurement.svg?react'; +import ScaleIcon from './custom-icons/scale.svg?react'; // https://icon-sets.iconify.design/teenyicons + +// https://icon-sets.iconify.design/teenyicons +enum IconName { + // Outline icons + Line = 'Line', + Square = 'Square', + Circle = 'Circle', + Direction = 'Direction', + VectorDocument = 'VectorDocument', + VectorDocumentSolid = 'VectorDocumentSolid', + LayersDifference = 'LayersDifference', + AntiClockwise = 'AntiClockwise', + Clockwise = 'Clockwise', + Github = 'Github', + Crop = 'Crop', + Folder = 'Folder', + FolderTick = 'FolderTick', + Save = 'Save', + Svg = 'Svg', + Pdf = 'Pdf', + PdfSolid = 'PdfSolid', + Png = 'Png', + Javascript = 'Javascript', + JavascriptSolid = 'JavascriptSolid', + Expand = 'Expand', + Image = 'Image', + ImageSolid = 'ImageSolid', + ArrowLeftCircle = 'ArrowLeftCircle', + ArrowRightCircle = 'ArrowRightCircle', + Measurement = 'Measurement', + FilePlus = 'FilePlus', + Edit = 'Edit', + SendUp = 'SendUp', + SendDown = 'SendDown', + AlignLeft = 'AlignLeft', + AlignRight = 'AlignRight', + AlignCenterHorizontal = 'AlignCenterHorizontal', + AlignTop = 'AlignTop', + AlignBottom = 'AlignBottom', + AlignCenterVertical = 'AlignCenterVertical', + Documents = 'Documents', + Download = 'Download', + FolderX = 'FolderX', + FolderPlus = 'FolderPlus', + AlignTextJustify = 'AlignTextJustify', + Eye = 'Eye', + EyeClosed = 'EyeClosed', + Lock = 'Lock', + Unlock = 'Unlock', + GridLayout = 'GridLayout', + HomeAlt = 'HomeAlt', + + // Solid icons + SolidDown = 'SolidDown', + SolidUp = 'SolidUp', + SolidUpSmall = 'SolidUpSmall', + SolidDownSmall = 'SolidDownSmall', + + // Custom icons + Scale = 'Scale', +} + +const icons: Record = { + // Outline icons + [IconName.Line]: LineIcon, + [IconName.Square]: SquareIcon, + [IconName.Circle]: CircleIcon, + [IconName.Direction]: DirectionIcon, + [IconName.VectorDocument]: VectorDocumentIcon, + [IconName.VectorDocumentSolid]: VectorDocumentSolidIcon, + [IconName.LayersDifference]: LayersDifferenceIcon, + [IconName.AntiClockwise]: AntiClockwiseIcon, + [IconName.Clockwise]: ClockwiseIcon, + [IconName.Github]: GithubIcon, + [IconName.Crop]: CropIcon, + [IconName.Folder]: FolderIcon, + [IconName.FolderTick]: FolderTickIcon, + [IconName.Save]: SaveIcon, + [IconName.Svg]: SvgIcon, + [IconName.Pdf]: PdfIcon, + [IconName.PdfSolid]: PdfSolidIcon, + [IconName.Png]: PngIcon, + [IconName.Javascript]: JavascriptIcon, + [IconName.JavascriptSolid]: JavascriptSolidIcon, + [IconName.Expand]: ExpandIcon, + [IconName.Image]: ImageIcon, + [IconName.ImageSolid]: ImageSoldIcon, + [IconName.ArrowLeftCircle]: ArrowLeftCircle, + [IconName.ArrowRightCircle]: ArrowRightCircle, + [IconName.Measurement]: MeasurementIcon, + [IconName.FilePlus]: FilePlusIcon, + [IconName.Edit]: EditIcon, + [IconName.SendUp]: SendUpIcon, + [IconName.SendDown]: SendDownIcon, + [IconName.AlignLeft]: AlignLeftIcon, + [IconName.AlignRight]: AlignRightIcon, + [IconName.AlignCenterHorizontal]: AlignCenterHorizontalIcon, + [IconName.AlignTop]: AlignTopIcon, + [IconName.AlignBottom]: AlignBottomIcon, + [IconName.AlignCenterVertical]: AlignCenterVerticalIcon, + [IconName.Documents]: DocumentsIcon, + [IconName.Download]: DownloadIcon, + [IconName.FolderX]: FolderXIcon, + [IconName.FolderPlus]: FolderPlusIcon, + [IconName.AlignTextJustify]: AlignTextJustifyIcon, + [IconName.Eye]: EyeIcon, + [IconName.EyeClosed]: EyeClosedIcon, + [IconName.Lock]: LockIcon, + [IconName.Unlock]: UnlockIcon, + [IconName.GridLayout]: GridLayoutIcon, + [IconName.HomeAlt]: HomeAltIcon, + + // Solid icons + [IconName.SolidDown]: SolidDownIcon, + [IconName.SolidUp]: SolidUpIcon, + [IconName.SolidUpSmall]: SolidUpSmallIcon, + [IconName.SolidDownSmall]: SolidDownSmallIcon, + + // Custom icons + [IconName.Scale]: ScaleIcon, +}; + +export { IconName }; + +interface IconProps { + name: IconName; + className?: string; +} + +export const Icon: FC = ({ name, className }) => { + const CurrentIcon = icons[name]; + return ( +
+ +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-outline.jpg b/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-outline.jpg new file mode 100644 index 00000000..24096024 Binary files /dev/null and b/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-outline.jpg differ diff --git a/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-solid.jpg b/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-solid.jpg new file mode 100644 index 00000000..4494acd0 Binary files /dev/null and b/B07_DesignDetail/openwebcad/src/components/Icon/teenyicons-solid.jpg differ diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index a10e9010..b2eec766 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -1,11 +1,12 @@ import { Point, type Vector } from '@flatten-js/core'; -import { CANVAS_BACKGROUND_COLOR, MOUSE_ZOOM_MULTIPLIER } from '../App.consts'; +import { MOUSE_ZOOM_MULTIPLIER } from '../App.consts'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts'; import { mapNumberRange } from '../helpers/map-number-range.ts'; import { StateVariable } from '../helpers/undo-stack.ts'; import { getEntities, getGridEnabled, triggerReactUpdate } from '../state.ts'; import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; +import { paintColor, themeColor } from '../theme.ts'; /** * Screen coordinate system: @@ -231,10 +232,12 @@ export class ScreenCanvasDrawController implements DrawController { public setLineStyles( isHighlighted: boolean, isSelected: boolean, - color: string, + rawColor: string, lineWidth: number, dash: number[] = [] ) { + // 저장된 도면 색은 다크 기준 — 라이트 테마에서는 그릴 때만 대비를 맞춘다. + const color = paintColor(rawColor); if (this.batching) { const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth; const effectiveDash = isSelected ? [5, 5] : dash; @@ -298,7 +301,7 @@ export class ScreenCanvasDrawController implements DrawController { } public setFillStyles(fillColor: string) { - this.context.fillStyle = fillColor; + this.context.fillStyle = paintColor(fillColor); } /** @@ -330,10 +333,10 @@ export class ScreenCanvasDrawController implements DrawController { if (!this.context) return; if (this.batching) this.flushBatch(); - this.context.fillStyle = CANVAS_BACKGROUND_COLOR; + this.context.fillStyle = themeColor('--cad-canvas', '#111'); this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); if (getGridEnabled()) { - this.context.strokeStyle = '#242b35'; + this.context.strokeStyle = themeColor('--color-mist', '#242b35'); this.context.lineWidth = 1; this.context.setLineDash([]); this.context.beginPath(); @@ -554,7 +557,7 @@ export class ScreenCanvasDrawController implements DrawController { this.context.rotate(angle); this.context.font = `${opts.fontSize}px ${opts.fontFamily}`; this.context.textAlign = opts.textAlign; - this.context.fillStyle = opts.textColor; + this.context.fillStyle = paintColor(opts.textColor); this.context.textBaseline = 'middle'; this.context.fillText(label, 0, 0); this.context.restore(); @@ -630,7 +633,7 @@ export class ScreenCanvasDrawController implements DrawController { public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon - this.context.fillStyle = color; + this.context.fillStyle = paintColor(color); this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts new file mode 100644 index 00000000..d33f56c1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-json.ts @@ -0,0 +1,33 @@ +import {compact} from 'es-toolkit'; +import {saveAs} from 'file-saver'; +import type {Layer} from '../../App.types.ts'; +import type {Entity, JsonEntity} from '../../entities/Entity'; +import {getEntities, getLayers} from '../../state'; + +export async function exportEntitiesToJsonFile() { + const json = await exportEntitiesAndLayersToJsonString(); + + const blob = new Blob([json], { type: 'text/json;charset=utf-8' }); + saveAs(blob, 'open-web-cad--drawing.json'); +} + +export async function exportEntitiesAndLayersToJsonString() { + const entities = getEntities(); + + const jsonEntities = entities.map((entity) => entity.toJson()); + const jsonDrawingFile: JsonDrawingFileSerialized = { + entities: compact(await Promise.all(jsonEntities)), // TODO use a mapLimit to avoid overloading the event loop + layers: getLayers(), + }; + return JSON.stringify(jsonDrawingFile, null, 2); +} + +export interface JsonDrawingFileSerialized { + entities: JsonEntity[]; + layers: Layer[]; +} + +export interface JsonDrawingFileDeserialized { + entities: Entity[]; + layers: Layer[]; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts new file mode 100644 index 00000000..73243c8c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-local-storage.ts @@ -0,0 +1,8 @@ +import {LOCAL_STORAGE_KEY} from '../../App.types.ts'; +import {exportEntitiesAndLayersToJsonString} from './export-entities-to-json.ts'; + +export async function exportEntitiesToLocalStorage() { + const json = await exportEntitiesAndLayersToJsonString(); + + localStorage.setItem(LOCAL_STORAGE_KEY.DRAWING, json); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts new file mode 100644 index 00000000..aa69bdb9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-png.ts @@ -0,0 +1,68 @@ +import { saveAs } from 'file-saver'; +import { convertEntitiesToSvgString } from './export-entities-to-svg'; +import { getEntities } from '../../state'; + +/** + * Takes an svg string and converts it to a png data uri + * by creating an svg element in the dom and drawing that element on the canvas + * Then taking the canvas data and outputting it as a png data blob + * @param svgLines + * @param width + * @param height + * @param margin + */ +export function convertSvgToPngBlob( + svgLines: string[], + width: number, + height: number, + margin: number, +): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Could not get canvas context'); + } + + const img = new Image(); + const svg = new Blob(svgLines, { type: 'image/svg+xml' }); + const url = URL.createObjectURL(svg); + + img.onload = () => { + canvas.width = width + margin * 2; + canvas.height = height + margin * 2; + + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.drawImage(img, margin, margin); + + URL.revokeObjectURL(url); + + canvas.toBlob(blob => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Could not convert canvas to blob')); + } + }, 'image/png'); + }; + + img.src = url; + }); +} + +export async function exportEntitiesToPngFile() { + const entities = getEntities(); + + const svg = convertEntitiesToSvgString(entities); + const pngDataBlob: Blob = await convertSvgToPngBlob( + svg.svgLines, + svg.width, + svg.height, + 20, + ); + + saveAs(pngDataBlob, 'open-web-cad--drawing.png'); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts new file mode 100644 index 00000000..96898837 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/export-entities-to-svg.ts @@ -0,0 +1,36 @@ +import {saveAs} from 'file-saver'; +import {SVG_MARGIN} from '../../App.consts'; +import {SvgDrawController} from '../../drawControllers/svg.drawController.ts'; +import type {Entity} from '../../entities/Entity'; +import {getEntities} from '../../state'; +import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts'; + +export function convertEntitiesToSvgString(entities: Entity[]): { + svgLines: string[]; + width: number; + height: number; +} { + const boundingBox = getBoundingBoxOfMultipleEntities(entities); + + const svgDrawController = new SvgDrawController( + boundingBox.minX - SVG_MARGIN, + boundingBox.minY - SVG_MARGIN, + boundingBox.maxX + SVG_MARGIN, + boundingBox.maxY + SVG_MARGIN + ); + + for (const entity of entities) { + entity.draw(svgDrawController); + } + + return svgDrawController.export(); +} + +export function exportEntitiesToSvgFile() { + const entities = getEntities(); + + const svg = convertEntitiesToSvgString(entities); + + const blob = new Blob(svg.svgLines, { type: 'text/svg;charset=utf-8' }); + saveAs(blob, 'open-web-cad--drawing.svg'); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts new file mode 100644 index 00000000..1bae91ca --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-json.ts @@ -0,0 +1,92 @@ +import {compact} from 'es-toolkit'; +import {ArcEntity, type ArcJsonData} from '../../entities/ArcEntity'; +import {CircleEntity, type CircleJsonData} from '../../entities/CircleEntity'; +import {type Entity, EntityName, type JsonEntity} from '../../entities/Entity'; +import {ImageEntity, type ImageJsonData} from '../../entities/ImageEntity.ts'; +import {LineEntity, type LineJsonData} from '../../entities/LineEntity'; +import {MeasurementEntity, type MeasurementJsonData} from '../../entities/MeasurementEntity.ts'; +import {PointEntity, type PointJsonData} from '../../entities/PointEntity'; +import {PolyLineEntity, type PolyLineJsonData} from '../../entities/PolyLineEntity.ts'; +import {RectangleEntity, type RectangleJsonData} from '../../entities/RectangleEntity'; +import {TextEntity, type TextJsonData} from '../../entities/TextEntity.ts'; +import {setActiveLayerId, setEntities, setLayers} from '../../state'; +import {getNewLayer} from '../get-new-layer.ts'; +import type {JsonDrawingFileDeserialized, JsonDrawingFileSerialized,} from './export-entities-to-json'; + +/** + * Open a file selection dialog to select *.json files + * Parse the JSON file + * Generate entities from the JSON data + * Set the entities in the state + */ +export function importEntitiesFromJsonFile(file: File | null | undefined) { + return new Promise((resolve) => { + if (!file) return; + + const reader = new FileReader(); + reader.addEventListener('load', async () => { + const json = reader.result as string; + const file = await getEntitiesAndLayersFromJsonString(json); + setEntities(file.entities); + setLayers(file.layers); + setActiveLayerId(file.layers[0].id); + resolve(); + }); + reader.readAsText(file, 'utf-8'); + }); +} + +export async function getEntitiesAndLayersFromJsonObject( + data: JsonDrawingFileSerialized +): Promise { + const entityPromises: Promise[] = compact( + data.entities.map((entity) => { + switch (entity.type) { + case EntityName.Arc: + return ArcEntity.fromJson(entity as JsonEntity); + case EntityName.Circle: + return CircleEntity.fromJson(entity as JsonEntity); + case EntityName.Line: + return LineEntity.fromJson(entity as JsonEntity); + case EntityName.Point: + return PointEntity.fromJson(entity as JsonEntity); + case EntityName.Rectangle: + return RectangleEntity.fromJson(entity as JsonEntity); + case EntityName.Text: + return TextEntity.fromJson(entity as JsonEntity); + case EntityName.Measurement: + return MeasurementEntity.fromJson(entity as JsonEntity); + case EntityName.Image: + return ImageEntity.fromJson(entity as JsonEntity); + case EntityName.PolyLine: + return PolyLineEntity.fromJson(entity as JsonEntity); + + default: + throw new Error(`Invalid entity type: ${entity.type}`); + } + }) + ); + + const entities = compact(await Promise.all(entityPromises)); + let layers = data.layers; + if (data.layers.length === 0) { + layers = [getNewLayer()]; + } + return { + entities, + layers, + }; +} + +export async function getEntitiesAndLayersFromJsonString( + json: string +): Promise { + const data = JSON.parse(json) as JsonDrawingFileSerialized; + + if (!data.entities) { + throw new Error('Invalid JSON file'); + } + + // TODO use map limit to avoid overloading the event loop + return await getEntitiesAndLayersFromJsonObject(data); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts new file mode 100644 index 00000000..91184e79 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-local-storage.ts @@ -0,0 +1,25 @@ +import {LOCAL_STORAGE_KEY} from '../../App.types.ts'; +import {setActiveLayerId, setEntities, setLayers} from '../../state.ts'; +import {getNewLayer} from '../get-new-layer.ts'; +import {getEntitiesAndLayersFromJsonString} from './import-entities-from-json.ts'; +import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts"; + +export async function importEntitiesAndLayersFromLocalStorage(): Promise { + const file = await getEntitiesAndLayersFromLocalStorage(); + setEntities(file.entities); + setLayers(file.layers); + setActiveLayerId(file.layers[0].id); +} + +export async function getEntitiesAndLayersFromLocalStorage(): Promise { + const json = localStorage.getItem(LOCAL_STORAGE_KEY.DRAWING); + if (!json) { + return { + entities: [], + layers: [getNewLayer()], + }; + } + + const file = (await getEntitiesAndLayersFromJsonString(json)) || []; + return file; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts new file mode 100644 index 00000000..1e0ee91b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.ts @@ -0,0 +1,149 @@ +import {toast} from 'react-toastify'; +import {CircleEntity} from '../../entities/CircleEntity'; +import type {Entity} from '../../entities/Entity'; +import {LineEntity} from '../../entities/LineEntity'; +import {RectangleEntity} from '../../entities/RectangleEntity'; +import {getActiveLayerId, getEntities, setEntities} from '../../state'; + +import {Point} from '@flatten-js/core'; +import {type Node, parse, type RootNode} from 'svg-parser'; +import {svgPathToSegments} from '../convert-svg-path-to-line-segments.ts'; +import {getBoundingBoxOfMultipleEntities} from '../get-bounding-box-of-multiple-entities.ts'; +import {middle} from '../middle.ts'; + +function svgChildrenToEntities(root: RootNode): Entity[] { + if (!root.children || !root.children?.[0]) { + toast.error('Failed to load SVG file since it appears to be empty'); + console.error(new Error('Empty SVG file')); + return []; + } + const entities: Entity[] = []; + const childrenToProcess: (string | Node)[] = [root.children[0]]; + while (childrenToProcess[0]) { + const child = childrenToProcess[0]; + if (typeof child === 'string') { + continue; // Don't import text // TODO convert this text to a TextEntity + } + if (child.type === 'element') { + if (child.tagName === 'rect') { + const corner = new Point( + Number.parseFloat(String(child.properties?.x)), + Number.parseFloat(String(child.properties?.y)) + ); + entities.push( + new RectangleEntity( + getActiveLayerId(), + corner, + new Point( + corner.x + Number.parseFloat(String(child.properties?.width)), + corner.y + Number.parseFloat(String(child.properties?.height)) + ) + ) + ); + } + if (child.tagName === 'ellipse') { + if (child.properties?.rx === child.properties?.ry) { + entities.push( + new CircleEntity( + getActiveLayerId(), + new Point( + Number.parseFloat(String(child.properties?.cx)), + Number.parseFloat(String(child.properties?.cy)) + ), + Number.parseFloat(String(child.properties?.rx)) + ) + ); + } else { + // TODO convert ellipse to line segments + } + } + if (child.tagName === 'path' && typeof child.properties?.d === 'string') { + const lines = svgPathToSegments(child.properties.d); + for (const line of lines) { + entities.push( + new LineEntity( + getActiveLayerId(), + new Point(line.x1, line.y1), + new Point(line.x2, line.y2) + ) + ); + } + } + if (child.tagName === 'polygon' && typeof child.properties?.points === 'string') { + const coords: number[] = child.properties.points + .split(' ') + .map((coord) => Number.parseFloat(coord)); + for (let i = 0; i <= coords.length; i = i + 2) { + if (i + 4 <= coords.length) { + // still enough points, keep going + const startPoint = new Point(coords[i], coords[i + 1]); + const endPoint = new Point(coords[i + 2], coords[i + 3]); + entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint)); + } else if (i + 2 === coords.length) { + // last point, add line back to the start + const startPoint = new Point(coords[i], coords[i + 1]); + const endPoint = new Point(coords[0], coords[1]); + entities.push(new LineEntity(getActiveLayerId(), startPoint, endPoint)); + } else { + // stop + toast.error( + `Error processing SVG polygon: expected an even number of points, but got ${coords.length}` + ); + console.error(`expected even number of points but got: ${coords.length}`); + } + } + } + + childrenToProcess.push(...(child.children || [])); + } + childrenToProcess.shift(); + } + return entities; +} + +/** + * Open a file selection dialog to select *.svg files + * Parse the SVG file + * Generate entities from the XML data + * Set the entities in the state + */ +export function importEntitiesFromSvgFile(file: File | null | undefined) { + return new Promise((resolve) => { + if (!file) return; + + const reader = new FileReader(); + reader.addEventListener('load', async () => { + try { + const svg = reader.result as string; + + const data = parse(svg); + + const svgEntities: Entity[] = svgChildrenToEntities(data); + + // We still need to flip the image top to bottom since the coordinate system of svg has a y-axis that goes down + // And the world coordinate system of this application has a mathematical y-axis that goes up + const boundingBox = getBoundingBoxOfMultipleEntities(svgEntities); + const centerPoint = new Point( + middle(boundingBox.minX, boundingBox.maxX), + middle(boundingBox.minY, boundingBox.maxY) + ); + + const mirrorAxis = new LineEntity( + getActiveLayerId(), + centerPoint, + new Point(centerPoint.x + 1, centerPoint.y) + ); + for (const svgEntity of svgEntities) { + svgEntity.mirror(mirrorAxis); + } + + setEntities([...getEntities(), ...svgEntities]); + resolve(); + } catch (error) { + toast.error('Failed to load SVG file'); + console.error(error); + } + }); + reader.readAsText(file, 'utf-8'); + }); +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts new file mode 100644 index 00000000..6769ce86 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-entities-from-svg.types.ts @@ -0,0 +1,12 @@ +export interface SvgParseResult { + type: string + children: Children[] +} + +export interface Children { + type: string + tagName: string + properties: Record + children: Children[] + metadata?: string +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts new file mode 100644 index 00000000..bbc74e7c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/import-export-handlers/import-image-from-file.ts @@ -0,0 +1,18 @@ +/** + * Open a file selection dialog to select *.jpg, *.jpeg, *.png files + * Load the image data + * Convert it to a base64 string + */ +export function importImageFromFile( + file: File | null | undefined, +): Promise { + return new Promise(resolve => { + if (!file) return; + + const img = new Image(); + img.onload = () => { + resolve(img); + }; + img.src = URL.createObjectURL(file); + }); +} diff --git a/B07_DesignDetail/openwebcad/src/theme.ts b/B07_DesignDetail/openwebcad/src/theme.ts new file mode 100644 index 00000000..77324c5b --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/theme.ts @@ -0,0 +1,96 @@ +/** + * 호스트 앱(Aislo) 테마를 CAD iframe에 그대로 따라 붙인다. + * + * iframe은 호스트와 **같은 오리진**이라 `localStorage["frd_theme"]`를 공유한다. + * 부모가 토글하며 값을 쓰면 이 문서에는 `storage` 이벤트가 오므로, 부모 쪽에 + * 별도 전달 코드(postMessage)를 넣지 않는다. 값이 없으면 미지정 상태로 두어 + * `ui_template_theme.css`의 `prefers-color-scheme` 폴백이 그대로 먹는다. + */ + +const THEME_STORAGE_KEY = 'frd_theme'; + +/** 테마가 바뀔 때마다 비우는 색 캐시 (CSS 변수 조회·휘도 보정 결과). */ +let cssVarCache = new Map(); +let paintCache = new Map(); + +function applyTheme(value: string | null): void { + if (value === 'dark' || value === 'light') { + document.documentElement.setAttribute('data-theme', value); + } else { + document.documentElement.removeAttribute('data-theme'); + } + cssVarCache = new Map(); + paintCache = new Map(); +} + +/** 부팅 시 1회 호출 — 현재 테마 반영 + 이후 변경 구독. */ +export function syncThemeFromHost(): void { + applyTheme(localStorage.getItem(THEME_STORAGE_KEY)); + window.addEventListener('storage', (event) => { + if (event.key === THEME_STORAGE_KEY) applyTheme(event.newValue); + }); + // 시스템 설정 폴백으로 도는 중이면 OS 전환도 따라간다. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + if (!document.documentElement.hasAttribute('data-theme')) applyTheme(null); + }); +} + +/** 토큰 값 조회 (테마 바뀔 때까지 캐시). */ +export function themeColor(varName: string, fallback: string): string { + const cached = cssVarCache.get(varName); + if (cached !== undefined) return cached; + const value = + getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || fallback; + cssVarCache.set(varName, value); + return value; +} + +export function isLightTheme(): boolean { + const attr = document.documentElement.getAttribute('data-theme'); + if (attr === 'dark') return false; + if (attr === 'light') return true; + return !window.matchMedia('(prefers-color-scheme: dark)').matches; +} + +function relativeLuminance(r: number, g: number, b: number): number { + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +/** + * 도면 색 보정 — 저장된 엔티티 색은 다크 배경 기준(밝은 회색·흰색)이라 + * 라이트 모드 흰 종이 위에서는 안 보인다. **그릴 때만** 휘도를 낮춰 색상(색조)은 + * 유지한 채 대비를 살린다. 저장 데이터는 건드리지 않는다. + */ +export function paintColor(color: string): string { + if (!isLightTheme()) return color; + const cached = paintCache.get(color); + if (cached !== undefined) return cached; + + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color); + let result = color; + if (hex) { + const raw = hex[1]; + const full = + raw.length === 3 + ? raw + .split('') + .map((c) => c + c) + .join('') + : raw; + const r = Number.parseInt(full.slice(0, 2), 16); + const g = Number.parseInt(full.slice(2, 4), 16); + const b = Number.parseInt(full.slice(4, 6), 16); + const luminance = relativeLuminance(r, g, b); + // 흰 배경에서 묻히는 밝기(0.58 초과)만 목표 휘도 0.3으로 눌러 준다. + if (luminance > 0.58) { + const scale = 0.3 / luminance; + const to = (v: number) => + Math.round(Math.min(255, v * scale)) + .toString(16) + .padStart(2, '0'); + result = `#${to(r)}${to(g)}${to(b)}`; + } + } + paintCache.set(color, result); + return result; +} diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json new file mode 100644 index 00000000..4db26e47 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.recording.json @@ -0,0 +1,60 @@ +{ + "title": "circle 5", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='circle-button'] svg"], + ["xpath///*[@data-id=\"circle-button\"]/div/svg"], + ["pierce/[data-id='circle-button'] svg"], + ["aria/Circle (c)", "aria/[role=\"image\"]"] + ], + "offsetY": 14, + "offsetX": 7 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 257, + "offsetX": 325 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 424, + "offsetX": 366 + } + ] +} diff --git a/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts new file mode 100644 index 00000000..f8724213 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/entities/circle/circle.test.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework + */ +import {expect, test} from 'vitest'; +import {getEntities} from '../../../src/state'; +import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; +import {initApplication} from '../../helpers/init-application'; +import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; +import type {CircleJsonData} from '../../../src/entities/CircleEntity'; +import circleRecording from './circle.recording.json'; +import {replayRecording} from '../../helpers/replay-recording'; + +test('Draw circle', async () => { + const inputController = initApplication(); + + replayRecording(inputController, circleRecording); + + const entities = getEntities(); + + expect(entities).toHaveLength(1); + + const circleEntity = entities[0]; + expect(circleEntity.getType()).toBe(EntityName.Circle); + const circleJson = + (await circleEntity.toJson()) as JsonEntity; + + expect(circleJson.lineColor).toBe('#fff'); + expect(circleJson.lineWidth).toBe(1); + expect(circleJson.type).toBe('Circle'); + expect(circleJson.shapeData.center.x).toBe(325); + expect(circleJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 257); + + const diffX = 424 - 257; + const diffY = 366 - 325; + expect(circleJson.shapeData.radius).toBe( + Math.sqrt(diffX * diffX + diffY * diffY), + ); +}); diff --git a/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts new file mode 100644 index 00000000..800b255e --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/entities/line/line.test.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework + */ +import {expect, test} from 'vitest'; +import {getEntities} from '../../../src/state'; +import {Tool} from '../../../src/tools'; +import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; +import {initApplication} from '../../helpers/init-application'; +import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; +import {click} from '../../helpers/click'; +import type {LineJsonData} from '../../../src/entities/LineEntity'; +import {setActiveTool} from '../../helpers/set-active-tool'; + +test('Draw line', async () => { + const inputController = initApplication(); + setActiveTool(Tool.LINE); + click(inputController, 185, 94); + click(inputController, 740, 395); + const entities = getEntities(); + + const lineEntity = entities[0]; + expect(lineEntity.getType()).toBe(EntityName.Line); + const lineJson = (await lineEntity.toJson()) as JsonEntity; + + expect(lineJson.lineColor).toBe('#fff'); + expect(lineJson.lineWidth).toBe(1); + expect(lineJson.type).toBe('Line'); + expect(lineJson.shapeData.startPoint.x).toBe(185); + expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 94); + expect(lineJson.shapeData.endPoint.x).toBe(740); + expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 395); +}); diff --git a/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts new file mode 100644 index 00000000..be0e7d17 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/entities/rectangle/rectangle.test.ts @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* + * Draw a rectangle to the screen and check if the json export contains the correct data using the vitest testing framework + */ +import {expect, test} from 'vitest'; +import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; +import type {RectangleJsonData} from '../../../src/entities/RectangleEntity'; +import {getEntities} from '../../../src/state'; +import {Tool} from '../../../src/tools'; +import {click} from '../../helpers/click'; +import {initApplication} from '../../helpers/init-application'; +import {setActiveTool} from '../../helpers/set-active-tool'; +import {CANVAS_HEIGHT} from '../../helpers/tests.consts'; + +test('Draw circle', async () => { + const inputController = initApplication(); + setActiveTool(Tool.RECTANGLE); + click(inputController, 185, 94); + click(inputController, 740, 395); + const entities = getEntities(); + + const rectangleEntity = entities[0]; + expect(rectangleEntity.getType()).toBe(EntityName.Rectangle); + const rectangleJson = (await rectangleEntity.toJson()) as JsonEntity; + + expect(rectangleJson.lineColor).toBe('#fff'); + expect(rectangleJson.lineWidth).toBe(1); + expect(rectangleJson.type).toBe('Rectangle'); + expect(rectangleJson.shapeData.points[0].x).toBe(185); + expect(rectangleJson.shapeData.points[0].y).toBe(CANVAS_HEIGHT - 395); + + expect(rectangleJson.shapeData.points[1].x).toBe(185); + expect(rectangleJson.shapeData.points[1].y).toBe(CANVAS_HEIGHT - 94); + + expect(rectangleJson.shapeData.points[2].x).toBe(740); + expect(rectangleJson.shapeData.points[2].y).toBe(CANVAS_HEIGHT - 94); + + expect(rectangleJson.shapeData.points[3].x).toBe(740); + expect(rectangleJson.shapeData.points[3].y).toBe(CANVAS_HEIGHT - 395); +}); diff --git a/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts new file mode 100644 index 00000000..6dc45847 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/drawControllers/screenCanvas.drawController.ts @@ -0,0 +1,317 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +// noinspection JSUnusedLocalSymbols + +import {Point, type Vector} from '@flatten-js/core'; +import type {DrawController} from '../../../src/drawControllers/DrawController'; +import {triggerReactUpdate} from '../../../src/state'; +import {StateVariable} from '../../../src/helpers/undo-stack'; +import {MOUSE_ZOOM_MULTIPLIER} from '../../../src/App.consts'; +import {mapNumberRange} from '../../../src/helpers/map-number-range'; + +/** + * Screen coordinate system: + * 0, 0 X + * +----------> + * | + * | + * | + * Y v + * + * + * World coordinate system: + * Y ^ + * | + * | + * | + * +----------> + * 0, 0 X + * + * To convert between the 2 coordinate systems, you need the screenOffset and screenScale + */ +export class ScreenCanvasDrawController implements DrawController { + private screenOffset: Point = new Point(0, 0); + private screenScale = 1; + private worldMouseLocation: Point; + + constructor( + private context: CanvasRenderingContext2D | null, + private canvasSize: Point, + ) { + this.worldMouseLocation = this.targetToWorld( + new Point(canvasSize.x / 2, canvasSize.y / 2), + ); + this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down + } + + public getCanvasSize() { + return this.canvasSize; + } + + public getScreenScale() { + return this.screenScale; + } + + public setScreenScale(newScreenScale: number) { + this.screenScale = newScreenScale; + triggerReactUpdate(StateVariable.screenZoom); + } + + public getScreenOffset() { + return this.screenOffset; + } + + public setScreenOffset(newScreenOffset: Point) { + this.screenOffset = newScreenOffset; + triggerReactUpdate(StateVariable.screenOffset); + } + + public setScreenMouseLocation(newScreenMouseLocation: Point): void { + this.worldMouseLocation = this.targetToWorld(newScreenMouseLocation); + triggerReactUpdate(StateVariable.screenMouseLocation); + } + + public getWorldMouseLocation(): Point { + return this.worldMouseLocation; + } + + public getScreenMouseLocation(): Point { + return this.worldToTarget(this.worldMouseLocation); + } + + public panScreen(screenOffsetX: number, screenOffsetY: number) { + this.screenOffset = new Point( + this.screenOffset.x - screenOffsetX / this.screenScale, + this.screenOffset.y - screenOffsetY / this.screenScale, + ); + } + + /** + * This function takes the deltaY from the mouse wheel event and zooms the screen in or out + * The location of the mouse in world space is preserved + * @param deltaY + */ + public zoomScreen(deltaY: number) { + const worldMouseLocationBeforeZoom = this.getWorldMouseLocation(); + const newScreenScale = + this.getScreenScale() * + (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY))); + this.setScreenScale(newScreenScale); + + // now get the location of the cursor in world space again + // It will have changed because the scale has changed, + // but we can offset our world now to fix the zoom location in screen space, + // because we know how much it changed laterally between the two spatial scales. + const worldMouseLocationAfterZoom = this.getWorldMouseLocation(); + + // Adjust the screen offset to maintain the cursor position + this.screenOffset = new Point( + this.screenOffset.x + + (worldMouseLocationBeforeZoom.x - + worldMouseLocationAfterZoom.x), + this.screenOffset.y + + (worldMouseLocationBeforeZoom.y - + worldMouseLocationAfterZoom.y), + ); + } + + /** + * Convert coordinates from World Space --> Screen Space + */ + public worldToTarget(worldCoordinate: Point): Point { + return new Point( + mapNumberRange( + worldCoordinate.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale, + 0, + this.canvasSize.x, + ), + mapNumberRange( + worldCoordinate.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left + this.screenOffset.y, + 0, + this.canvasSize.y, + ), + ); + } + + public worldsToTargets(worldCoordinates: Point[]): Point[] { + return worldCoordinates.map(this.worldToTarget.bind(this)); + } + + /** + * Convert coordinates from Screen Space --> World Space + * (0, 0) (1920, 0) + * + * (0, 1080) (1920, 1080) + * + * convert to + * + * (0, 1080) (1920, 1080) + * + * (0, 0) (1920, 0) + */ + public targetToWorld(screenCoordinate: Point): Point { + // map the screen coordinate to the world coordinate based on this.getScreenOffset() and the this.getScreenScale() + return new Point( + mapNumberRange( + screenCoordinate.x, + 0, + this.canvasSize.x, + this.screenOffset.x, + this.screenOffset.x + this.canvasSize.x / this.screenScale, + ), + mapNumberRange( + screenCoordinate.y, + 0, + this.canvasSize.y, + this.screenOffset.y, + this.screenOffset.y + this.canvasSize.y / this.screenScale, + ), + ); + } + + public targetsToWorlds(screenCoordinates: Point[]): Point[] { + return screenCoordinates.map(this.targetToWorld.bind(this)); + } + + public setLineStyles( + isHighlighted: boolean, + isSelected: boolean, + color: string, + lineWidth: number, + dash: number[] = [], + ) {} + + public setFillStyles(fillColor: string) {} + + public clear() {} + + /** + * Draws a line from startPoint to endPoint and auto converts to screen space first + * @param worldStartPoint + * @param worldEndPoint + */ + public drawLine(worldStartPoint: Point, worldEndPoint: Point): void {} + + /** + * Needs to be public to draw UI that is zoom independent, like snap point indicators + * @param screenStartPoint + * @param screenEndPoint + */ + public drawLineScreen( + screenStartPoint: Point, + screenEndPoint: Point, + ): void {} + + /** + * Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI + * @param centerPoint + * @param radius + * @param startAngle + * @param endAngle + * @param counterClockWise + */ + public drawArc( + centerPoint: Point, + radius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean, + ) {} + + public drawArcScreen( + screenCenterPoint: Point, + screenRadius: number, + startAngle: number, + endAngle: number, + counterClockWise: boolean, + ) {} + + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text * @param label + * @param label + * @param basePoint + * @param options + */ + public drawText( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {}, + ): void {} + + /** + * Draw some text at the base location + * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text + * @param label + * @param basePoint + * @param options + */ + public drawTextScreen( + label: string, + basePoint: Point, + options: Partial<{ + textDirection?: Vector; + textAlign: 'left' | 'center' | 'right'; + textColor: string; + fontSize: number; + fontFamily: string; + }> = {}, + ): void {} + + /** + * Draw an image to the canvas using world coordinates + * @param imageElement + * @param xMin + * @param yMin + * @param width + * @param height + * @param angle + */ + public drawImage( + imageElement: HTMLImageElement, + xMin: number, + yMin: number, + width: number, + height: number, + angle: number, + ): void {} + + public fillRect( + xMin: number, + yMin: number, + width: number, + height: number, + color: string, + ) {} + + /** + * Fill rectangle with color, but interpret the provided coordinates as screen coordinates + * @param xMin + * @param yMin + * @param width + * @param height + * @param color + */ + public fillRectScreen( + xMin: number, + yMin: number, + width: number, + height: number, + color: string, + ) {} + + /** + * Fill polygon with color + * @param points + */ + public fillPolygon(...points: Point[]) {} +} diff --git a/B07_DesignDetail/openwebcad/test/mocks/dxf/circle.dxf b/B07_DesignDetail/openwebcad/test/mocks/dxf/circle.dxf new file mode 100644 index 00000000..d4a05194 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/dxf/circle.dxf @@ -0,0 +1,20 @@ +0 +SECTION +2 +ENTITIES +0 +CIRCLE +8 +0 +10 +30.0 +20 +30.0 +30 +0.0 +40 +5.0 +0 +ENDSEC +0 +EOF diff --git a/B07_DesignDetail/openwebcad/test/mocks/dxf/empty.dxf b/B07_DesignDetail/openwebcad/test/mocks/dxf/empty.dxf new file mode 100644 index 00000000..aeb059aa --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/dxf/empty.dxf @@ -0,0 +1,8 @@ +0 +SECTION +2 +ENTITIES +0 +ENDSEC +0 +EOF diff --git a/B07_DesignDetail/openwebcad/test/mocks/dxf/line-and-circle.dxf b/B07_DesignDetail/openwebcad/test/mocks/dxf/line-and-circle.dxf new file mode 100644 index 00000000..fa9d46ea --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/dxf/line-and-circle.dxf @@ -0,0 +1,36 @@ +0 +SECTION +2 +ENTITIES +0 +LINE +8 +0 +10 +10.0 +20 +10.0 +30 +0.0 +11 +20.0 +21 +20.0 +31 +0.0 +0 +CIRCLE +8 +0 +10 +30.0 +20 +30.0 +30 +0.0 +40 +5.0 +0 +ENDSEC +0 +EOF diff --git a/B07_DesignDetail/openwebcad/test/mocks/dxf/line.dxf b/B07_DesignDetail/openwebcad/test/mocks/dxf/line.dxf new file mode 100644 index 00000000..51f80769 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/dxf/line.dxf @@ -0,0 +1,24 @@ +0 +SECTION +2 +ENTITIES +0 +LINE +8 +0 +10 +10.0 +20 +10.0 +30 +0.0 +11 +20.0 +21 +20.0 +31 +0.0 +0 +ENDSEC +0 +EOF diff --git a/B07_DesignDetail/openwebcad/test/mocks/dxf/unsupported.dxf b/B07_DesignDetail/openwebcad/test/mocks/dxf/unsupported.dxf new file mode 100644 index 00000000..75057828 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/mocks/dxf/unsupported.dxf @@ -0,0 +1,24 @@ +0 +SECTION +2 +ENTITIES +0 +ARC +8 +0 +10 +50.0 +20 +50.0 +30 +0.0 +40 +10.0 +50 +0.0 +51 +90.0 +0 +ENDSEC +0 +EOF diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json new file mode 100644 index 00000000..ad61f1bd --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.recording.json @@ -0,0 +1,163 @@ +{ + "title": "record eraser 2", + "selectorAttribute": "data-id", + "steps": [ + { + "type": "setViewport", + "width": 1278, + "height": 1430, + "deviceScaleFactor": 1, + "isMobile": false, + "hasTouch": false, + "isLandscape": false + }, + { + "type": "navigate", + "url": "http://localhost:5173/", + "assertedEvents": [ + { + "type": "navigation", + "url": "http://localhost:5173/", + "title": "Open WebCAD" + } + ] + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 148, + "offsetX": 473 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 698, + "offsetX": 473 + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "i" + }, + { + "type": "keyUp", + "key": "i", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "r" + }, + { + "type": "keyUp", + "key": "r", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "c" + }, + { + "type": "keyUp", + "key": "c", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "l" + }, + { + "type": "keyUp", + "key": "l", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "e" + }, + { + "type": "keyUp", + "key": "e", + "target": "main" + }, + { + "type": "keyDown", + "target": "main", + "key": "Enter" + }, + { + "type": "keyUp", + "key": "Enter", + "target": "main" + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 402, + "offsetX": 266 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 441, + "offsetX": 542 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='delete-segment-button'] path"], + ["xpath///*[@data-id=\"delete-segment-button\"]/div/svg/path"], + ["pierce/[data-id='delete-segment-button'] path"], + ["aria/Delete segments", "aria/[role=\"graphics-symbol\"]"] + ], + "offsetY": 15, + "offsetX": 9 + }, + { + "type": "click", + "target": "main", + "selectors": [ + ["[data-id='canvas']"], + ["xpath///*[@data-id=\"canvas\"]"], + ["pierce/[data-id='canvas']"] + ], + "offsetY": 291, + "offsetX": 524 + } + ] +} diff --git a/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts new file mode 100644 index 00000000..237c6a41 --- /dev/null +++ b/B07_DesignDetail/openwebcad/test/tools/eraser/eraser.test.ts @@ -0,0 +1,50 @@ +import {Point} from '@flatten-js/core'; /* eslint-disable @typescript-eslint/no-explicit-any */ +import {expect, test} from 'vitest'; +import type {ArcJsonData} from '../../../src/entities/ArcEntity'; +import {EntityName, type JsonEntity} from '../../../src/entities/Entity'; +import type {LineJsonData} from '../../../src/entities/LineEntity'; +import {pointDistance} from '../../../src/helpers/distance-between-points'; +import {getEntities} from '../../../src/state'; +import {initApplication} from '../../helpers/init-application'; +import {replayRecording} from '../../helpers/replay-recording'; +import {CANVAS_HEIGHT} from "../../helpers/tests.consts"; +import eraserRecording from './eraser.recording.json'; + +test('Draw circle and line and erase part of circle', async () => { + const inputController = initApplication(); + + replayRecording(inputController, eraserRecording); + + const entities = getEntities(); + + expect(entities).toHaveLength(2); + + const lineEntity = entities[0]; + expect(lineEntity.getType()).toBe(EntityName.Line); + const lineJson = (await lineEntity.toJson()) as JsonEntity; + + expect(lineJson.lineColor).toBe('#fff'); + expect(lineJson.lineWidth).toBe(1); + expect(lineJson.type).toBe('Line'); + expect(lineJson.shapeData.startPoint.x).toBe(473); + expect(lineJson.shapeData.startPoint.y).toBe(CANVAS_HEIGHT - 148); + expect(lineJson.shapeData.endPoint.x).toBe(473); + expect(lineJson.shapeData.endPoint.y).toBe(CANVAS_HEIGHT - 698); + + const arcEntity = entities[1]; + expect(arcEntity.getType()).toBe(EntityName.Arc); + const arcJson = (await arcEntity.toJson()) as JsonEntity; + + expect(arcJson.lineColor).toBe('#fff'); + expect(arcJson.lineWidth).toBe(1); + expect(arcJson.type).toBe('Arc'); + expect(arcJson.shapeData.center.x).toBe(266); + expect(arcJson.shapeData.center.y).toBe(CANVAS_HEIGHT - 402); + const radius = pointDistance( + new Point(266, CANVAS_HEIGHT - 402), + new Point(542, CANVAS_HEIGHT - 441) + ); + expect(arcJson.shapeData.radius).toBeCloseTo(radius, 5); + expect(arcJson.shapeData.startAngle).toBeCloseTo(0.7338182524767606, 5); + expect(arcJson.shapeData.endAngle).toBeCloseTo(-0.7338182524767606, 5); +});