From 6ec8fca60e01ac539802f761091c34e676da774d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 19:18:14 +0900 Subject: [PATCH] 260719_9 --- .../B05_wf2_Route_Engine_Sections.py | 27 +- .../B06_wf3_ProfileCross_Router.py | 8 +- .../B07_wf4_DesignDetail_Router.py | 12 +- .../openwebcad/src/App.consts.ts | 15 +- B07_wf4_DesignDetail/openwebcad/src/App.css | 39 ++ .../openwebcad/src/components/Toolbar.tsx | 161 +++++++ .../openwebcad/src/entities/ArcEntity.ts | 31 +- .../src/entities/ArrowHeadEntity.ts | 20 +- .../openwebcad/src/entities/CircleEntity.ts | 20 +- .../openwebcad/src/entities/Entity.ts | 23 +- .../openwebcad/src/entities/ImageEntity.ts | 26 +- .../openwebcad/src/entities/LineEntity.ts | 31 +- .../src/entities/MeasurementEntity.ts | 82 ++-- .../openwebcad/src/entities/PointEntity.ts | 20 +- .../openwebcad/src/entities/PolyLineEntity.ts | 22 +- .../src/entities/RectangleEntity.ts | 24 +- .../openwebcad/src/entities/TextEntity.ts | 28 +- .../openwebcad/src/helpers/undo-stack.ts | 2 + B07_wf4_DesignDetail/openwebcad/src/state.ts | 39 +- .../openwebcad/src/tools/circle-tool.ts | 295 ++++++------- .../openwebcad/src/tools/line-tool.ts | 308 +++++++------ .../openwebcad/src/tools/measurement-tool.ts | 411 +++++++++--------- .../openwebcad/src/tools/rectangle-tool.ts | 276 ++++++------ 23 files changed, 1130 insertions(+), 790 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index f94278a2..00799058 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -33,6 +33,30 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[ return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords] +def cross_filename(chainage_m: float) -> str: + """측점 chainage에 대응하는 횡단면 파일명(단일 규칙).""" + return f"cross_{int(round(float(chainage_m))):05d}m.json" + + +def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]: + """stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다. + + 측점 정보가 비어 있으면 오삭제를 피하기 위해 아무것도 지우지 않고 + 빈 집합을 반환한다(호출부는 빈 집합이면 필터를 생략한다). + """ + valid = { + cross_filename(station["chainage_m"]) + for station in stations + if isinstance(station, dict) and isinstance(station.get("chainage_m"), (int, float)) + } + if not valid: + return valid + for path in cross_dir.glob("cross_*.json"): + if path.name not in valid: + path.unlink(missing_ok=True) + return valid + + def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: """횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다.""" samples = cross_section.get("samples", []) @@ -101,8 +125,7 @@ def run_section_generation( cross_records: list[dict[str, Any]] = [] for seq, cross_section in enumerate(result["cross_sections"]): chainage = float(cross_section["chainage_m"]) - filename = f"cross_{int(round(chainage)):05d}m.json" - cross_file = cross_dir / filename + cross_file = cross_dir / cross_filename(chainage) atomic_write_json(cross_file, cross_section) cross_records.append( { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 46535c22..a3f20ffb 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -12,7 +12,10 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import ( + prune_stale_cross_files, + run_section_generation, +) from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( confirm_sections_for_route, @@ -122,9 +125,12 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.") longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) cross_sections = [ json.loads(path.read_text(encoding="utf-8")) for path in sorted(cross_dir.glob("cross_*.json")) + if not valid_names or path.name in valid_names ] if not isinstance(longitudinal, dict) or not all( isinstance(section, dict) for section in cross_sections diff --git a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py index 71d3cf29..dbeca753 100644 --- a/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py +++ b/B07_wf4_DesignDetail/B07_wf4_DesignDetail_Router.py @@ -12,6 +12,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B05_wf2_Route.B05_wf2_Route_Engine_Sections import prune_stale_cross_files from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( get_confirmed_route_context, get_longitudinal_section, @@ -63,11 +64,16 @@ def _read_json(path: Path) -> dict[str, Any]: return payload -def _cross_files(longitudinal_path: Path) -> list[Path]: +def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: cross_dir = longitudinal_path.parent.parent / "cross_sections" if not cross_dir.is_dir(): raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") - return sorted(cross_dir.glob("cross_*.json")) + stations = longitudinal.get("stations") + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + files = sorted(cross_dir.glob("cross_*.json")) + if valid_names: + files = [path for path in files if path.name in valid_names] + return files def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: @@ -114,7 +120,7 @@ def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDra confirmed=bool(manifest_drawings.get("longitudinal", {}).get("confirmed")), ) ] - for path in _cross_files(longitudinal_path): + for path in _cross_files(longitudinal_path, longitudinal): match = _CROSS_ID.fullmatch(path.stem) if not match: continue diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts index 0ffd4cdb..50d6bb9a 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_wf4_DesignDetail/openwebcad/src/App.consts.ts @@ -80,18 +80,21 @@ export const MAX_MARKED_SNAP_POINTS = 3; /** * Length of the extensions that extend past the measurement arrows of a measurement + * Screen pixels: converted to world units per current zoom so drawings in meters stay legible */ -export const MEASUREMENT_EXTENSION_LENGTH = 20; +export const MEASUREMENT_EXTENSION_LENGTH = 12; /** * Distance that measurement lines stay away from the point of origin of the measurement + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_ORIGIN_MARGIN = 20; +export const MEASUREMENT_ORIGIN_MARGIN = 8; /** * Distance the measurement is drawn while drawing the start and endpoints of the measurements but before the user decides the offset point + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_DEFAULT_OFFSET = 200; +export const MEASUREMENT_DEFAULT_OFFSET = 60; /** * Length of the arrow heads for measurements @@ -110,13 +113,15 @@ export const MEASUREMENT_DECIMAL_PLACES = 2; /** * Distance between the measurement line and the label of the measurement + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_LABEL_OFFSET = 20; +export const MEASUREMENT_LABEL_OFFSET = 8; /** * Size of the measurement labels containing the length of the measurements + * Screen pixels (zoom-independent) */ -export const MEASUREMENT_FONT_SIZE = 40; +export const MEASUREMENT_FONT_SIZE = 16; /** * Colors for the selection rectangle diff --git a/B07_wf4_DesignDetail/openwebcad/src/App.css b/B07_wf4_DesignDetail/openwebcad/src/App.css index 2ffbc824..cfbc3955 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/App.css +++ b/B07_wf4_DesignDetail/openwebcad/src/App.css @@ -148,6 +148,45 @@ body > canvas[data-id="canvas"] { font-size: 10px; text-align: center; } +.cad-ribbon-props { + gap: 8px; + align-items: center; +} +.cad-prop { + display: flex; + flex-direction: column; + gap: 3px; + align-items: stretch; + font-size: 10px; + color: #8293a5; +} +.cad-prop > span { + text-align: center; +} +.cad-prop select, +.cad-prop input[type='number'] { + height: 24px; + min-width: 64px; + padding: 0 4px; + color: #dce6f2; + background: #2a3745; + border: 1px solid #40505f; + border-radius: 4px; + font-size: 11px; +} +.cad-prop input[type='number'] { + min-width: 48px; + width: 48px; +} +.cad-prop input[type='color'] { + height: 24px; + width: 40px; + padding: 1px; + background: #2a3745; + border: 1px solid #40505f; + border-radius: 4px; + cursor: pointer; +} .cad-tool { display: flex; flex-direction: column; diff --git a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx index 5f18a42a..2524bee8 100644 --- a/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_wf4_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -4,10 +4,18 @@ import { Actor } from 'xstate'; import { HtmlEvent, type Layer } from '../App.types'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; +import type { Entity } from '../entities/Entity'; +import { EntityName } from '../entities/Entity'; +import { TextEntity } from '../entities/TextEntity'; import { getActiveLayerId, + getActiveLineColor, + getActiveLineDash, + getActiveLineWidth, + getActiveTextStyle, getActiveToolActor, getAngleStep, + getEntities, getGridEnabled, getInputController, getLastStateInstructions, @@ -17,8 +25,13 @@ import { getSnapEnabled, redo, setActiveLayerId, + setActiveLineColor, + setActiveLineDash, + setActiveLineWidth, + setActiveTextStyle, setActiveToolActor, setAngleStep, + setEntities, setGridEnabled, setLayers, setSnapEnabled, @@ -69,6 +82,20 @@ const RIBBON_GROUPS: { label: string; tools: RibbonTool[] }[] = [ const COMMANDS = Object.values(Tool); +const LINE_TYPES: { value: string; label: string; dash: number[] | undefined }[] = [ + { value: 'solid', label: '실선', dash: undefined }, + { value: 'dashed', label: '파선', dash: [10, 5] }, + { value: 'dashdot', label: '1점쇄선', dash: [12, 4, 2, 4] }, + { value: 'dotted', label: '점선', dash: [2, 4] }, +]; + +const LINE_WIDTHS = [1, 2, 3, 4, 5]; + +const FONT_FAMILIES = ['Noto Sans KR', 'Malgun Gothic', 'Pretendard', 'Arial', 'monospace']; + +const dashToLineType = (dash: number[] | undefined): string => + LINE_TYPES.find((type) => JSON.stringify(type.dash) === JSON.stringify(dash))?.value ?? 'solid'; + export const Toolbar: FC = () => { const [activeTool, setActiveTool] = useState(Tool.LINE); const [zoom, setZoom] = useState(1); @@ -84,6 +111,10 @@ export const Toolbar: FC = () => { const [ortho, setOrtho] = useState(getAngleStep() === 90); const [command, setCommand] = useState(''); const [commandLog, setCommandLog] = useState('준비'); + const [lineColor, setLineColorLocal] = useState(getActiveLineColor()); + const [lineWidth, setLineWidthLocal] = useState(getActiveLineWidth()); + const [lineType, setLineTypeLocal] = useState(dashToLineType(getActiveLineDash())); + const [textStyle, setTextStyleLocal] = useState(getActiveTextStyle()); const refresh = useCallback(() => { setActiveTool(getActiveToolActor()?.getSnapshot()?.context.type ?? Tool.LINE); @@ -99,6 +130,10 @@ export const Toolbar: FC = () => { setSnap(getSnapEnabled()); setGrid(getGridEnabled()); setOrtho(getAngleStep() === 90); + setLineColorLocal(getActiveLineColor()); + setLineWidthLocal(getActiveLineWidth()); + setLineTypeLocal(dashToLineType(getActiveLineDash())); + setTextStyleLocal({ ...getActiveTextStyle() }); }, []); useEffect(() => { @@ -136,6 +171,55 @@ export const Toolbar: FC = () => { setZoom(controller.getScreenScale()); }; + /** 선택 객체가 있으면 스타일을 즉시 적용하고, 없으면 이후 그리기 기본값만 바꾼다. */ + const applyToSelection = useCallback((mutate: (entity: Entity) => void): boolean => { + const selected = getSelectedEntities(); + if (!selected.length) return false; + for (const entity of selected) { + mutate(entity); + } + setEntities([...getEntities()], true); + return true; + }, []); + + const handleLineColor = (color: string) => { + setActiveLineColor(color); + setLineColorLocal(color); + if (applyToSelection((entity) => (entity.lineColor = color))) { + setCommandLog('선택 객체 색상 변경'); + } + }; + + const handleLineWidth = (width: number) => { + setActiveLineWidth(width); + setLineWidthLocal(width); + if (applyToSelection((entity) => (entity.lineWidth = width))) { + setCommandLog('선택 객체 선굵기 변경'); + } + }; + + const handleLineType = (value: string) => { + const dash = LINE_TYPES.find((type) => type.value === value)?.dash; + setActiveLineDash(dash ? [...dash] : undefined); + setLineTypeLocal(value); + if (applyToSelection((entity) => (entity.lineDash = dash ? [...dash] : undefined))) { + setCommandLog('선택 객체 선종류 변경'); + } + }; + + const handleTextStyle = (patch: Partial) => { + setActiveTextStyle(patch); + setTextStyleLocal((previous) => ({ ...previous, ...patch })); + const applied = applyToSelection((entity) => { + if (entity.getType() === EntityName.Text) { + (entity as TextEntity).setTextOptions(patch); + } + }); + if (applied) { + setCommandLog('선택 문자 스타일 변경'); + } + }; + return ( <>
@@ -195,6 +279,83 @@ export const Toolbar: FC = () => { {group.label} ))} +
+
+ + + +
+ 특성 +
+
+
+ + + +
+ 문자 +