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);
+});