This commit is contained in:
2026-08-03 14:06:06 +09:00
parent 107ccd151e
commit 99cfef1004
37 changed files with 3273 additions and 356 deletions
@@ -9,6 +9,7 @@ type DeletableModule = {
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
dark: boolean
module: Module
allowRepositoryDeletion?: boolean
onCancel: () => void
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
}
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
export default function ModuleDeleteConfirm<Module extends DeletableModule>({
dark,
module,
allowRepositoryDeletion = true,
onCancel,
onConfirm
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
</p>
<label
{allowRepositoryDeletion && <label
className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
</span>
</span>
</label>
</label>}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
</button>
<button
type="button"
onClick={() => onConfirm(module, { deleteRepository })}
onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
>
삭제
@@ -6,12 +6,10 @@ type ModuleListHeaderProps = {
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
return (
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<div className={['grid h-9 grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<span className="text-center">상태</span>
<span className="text-center">분류</span>
<span>모듈명</span>
<span className="text-center">CPU</span>
<span className="text-center">메모리</span>
<span className="text-center">가동시간</span>
<span className="col-span-2 text-center">제어</span>
</div>
@@ -19,6 +19,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
module: Module
installing: boolean
controlling: boolean
bulkControlling: boolean
onAddModuleTab: (module: Module) => void
onOpenSource: (module: Module) => void
onOpenParameters: (module: Module) => void
@@ -80,6 +81,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
module,
installing,
controlling,
bulkControlling,
onAddModuleTab,
onOpenSource,
onOpenParameters,
@@ -92,6 +94,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
}: ModuleListRowProps<Module>): React.JSX.Element {
const selectable = module.state !== 'uninstalled'
const busy = installing || controlling
const parameters = Array.isArray(module.parameters) ? module.parameters : []
const stateStyle: Record<ModuleListRowItem['state'], string> = {
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
@@ -121,6 +124,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
return (
<div
@@ -128,7 +132,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
if (selectable) onOpenSource(module)
}}
className={[
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
'cursor-default',
selectable
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
@@ -158,37 +162,9 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
].join(' ')}
>
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
{parameters.length ? parameters.join(' ') : '파라미터 설정'}
</button>
</span>
<span className="flex items-center justify-center gap-1.5">
{module.cpu === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
</span>
</>
)}
</span>
<span className="flex items-center justify-center gap-1.5">
{module.memory === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
</span>
</>
)}
</span>
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
{module.runtime}
</span>
@@ -215,7 +191,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{controlActions.map(({ label, Icon, tone, action }) => (
(() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = busy && !canCancelBuild
const disabled = bulkControlling || (busy && !canCancelBuild)
return (
<button
key={`${module.name}-${label}`}
@@ -238,7 +214,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className="flex justify-center">
<button
type="button"
disabled={busy}
disabled={installActionDisabled}
onClick={(event) => {
event.stopPropagation()
if (module.state === 'uninstalled') {
@@ -248,7 +224,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onDeleteModule(module)
}}
aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, busy)}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
onInstallAll: () => void
onStartAll: () => void
onStopAll: () => void
onOpenWorldEditor: () => void
}
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
onFilterChange,
onInstallAll,
onStartAll,
onStopAll
onStopAll,
onOpenWorldEditor
}: ModuleListToolbarProps): React.JSX.Element {
const installingAll = installingModuleName === '__all__'
const startingAll = installingModuleName === '__start_all__'
const stoppingAll = installingModuleName === '__stop_all__'
const bulkActions = [
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined },
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
{
label: '전체 설치',
Icon: Download,
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
controllingModuleNames: string[]
listError: string | null
installError: string | null
allowRepositoryDeletion?: boolean
onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void
@@ -29,6 +30,7 @@ type ModuleListViewProps = {
onInstallAll: () => void
onStartAll: () => void
onStopAll: () => void
onOpenWorldEditor: () => void
}
export default function ModuleListView({
@@ -39,6 +41,7 @@ export default function ModuleListView({
controllingModuleNames,
listError,
installError,
allowRepositoryDeletion = true,
onAddModuleTab,
onOpenSource,
onInstallModule,
@@ -50,7 +53,8 @@ export default function ModuleListView({
onRestartModule,
onInstallAll,
onStartAll,
onStopAll
onStopAll,
onOpenWorldEditor
}: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
@@ -60,6 +64,7 @@ export default function ModuleListView({
const runningCount = modules.filter((row) => row.state === 'running').length
const stoppedCount = modules.filter((row) => row.state === 'stopped').length
const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => {
@@ -102,6 +107,7 @@ export default function ModuleListView({
onInstallAll={onInstallAll}
onStartAll={onStartAll}
onStopAll={onStopAll}
onOpenWorldEditor={onOpenWorldEditor}
/>
<ModuleListHeader dark={dark} />
{(listError || installError) && (
@@ -122,6 +128,7 @@ export default function ModuleListView({
module={row}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
@@ -148,6 +155,7 @@ export default function ModuleListView({
<ModuleDeleteConfirm
dark={dark}
module={deleteConfirmModule}
allowRepositoryDeletion={allowRepositoryDeletion}
onCancel={() => setDeleteConfirmModule(null)}
onConfirm={(module, options) => {
onDeleteModule(module, options)
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
}: WorkspaceFilePanelProps): React.JSX.Element {
return (
<aside
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
>
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
} = props
return (
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<button
type="button"
onClick={onSelectRoot}
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
previewOpen: boolean
showControlCountdown: boolean
controlCountdownDanger: boolean
canExtendControl: boolean
controlEndsAt: string | null
submitVisible: boolean
submitDisabled: boolean
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
onTogglePreview: () => void
onSubmit: () => void
onOpenPowerActions: () => void
onRequestControlExtension: () => void
onClose: () => void
}
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
previewOpen,
showControlCountdown,
controlCountdownDanger,
canExtendControl,
controlEndsAt,
submitVisible,
submitDisabled,
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
onTogglePreview,
onSubmit,
onOpenPowerActions,
onRequestControlExtension,
onClose
}: WorkspaceTopBarProps): React.JSX.Element {
return (
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
<Clock3 size={13} />
{formatControlRemainingTime(controlEndsAt)}
</span>
{controlCountdownDanger && canExtendControl && (
<button
type="button"
onClick={onRequestControlExtension}
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
>
연장
</button>
)}
</>
)}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -2,6 +2,7 @@ import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
import type React from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = {
dark: boolean
@@ -16,11 +17,13 @@ type WorkspaceVisualizationPanelProps = {
running: boolean
busy: boolean
viewerUrl: string
nav2Mode: Nav2VisualizationMode
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void
onClose: () => void
onStackChange: (stack: VisualizationStack) => void
onStart: () => void
onStartNav2: (mode: Nav2VisualizationMode) => void
onStop: () => void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
}
@@ -38,14 +41,17 @@ export default function WorkspaceVisualizationPanel({
running,
busy,
viewerUrl,
nav2Mode,
onDragStart,
onToggleLarge,
onClose,
onStackChange,
onStart,
onStartNav2,
onStop,
onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
const isNav2Visualization = activeStack === 'nav2' || activeStack === 'slam'
return (
<section
className={[
@@ -127,21 +133,58 @@ export default function WorkspaceVisualizationPanel({
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => {
if (running) onStop()
else onStart()
}}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : running ? '종료' : '시작'}
</button>
{running ? (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStop}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '종료'}
</button>
) : isNav2Visualization ? (
<>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('slam')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'slam' ? 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'slam' ? '처리 중' : 'SLAM 시작'}
</button>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('navigation')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'navigation' ? 'bg-[#2563eb] text-white hover:bg-[#3b82f6]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'navigation' ? '처리 중' : '주행 시작'}
</button>
</>
) : (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStart}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '시작'}
</button>
)}
</div>
</div>
<div className="relative min-h-0 flex-1">
@@ -0,0 +1,509 @@
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = {
workspaceRoot: string
onClose: () => void
onSaved?: () => void
}
type TransformMode = 'translate' | 'rotate' | 'scale'
const objectLabels: Record<WorldObjectType, string> = {
pillar: '기둥',
wall: '벽',
box: '박스'
}
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
}
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
const template = defaultsByType[type]
return {
id: `${type}-${Date.now()}-${index}`,
type,
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
rotation: [...template.rotation],
size: [...template.size]
}
}
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
}
function createMesh(item: WorldLayoutObject): THREE.Mesh {
const [width, depth, height] = item.size
const geometry = item.type === 'pillar'
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
: new THREE.BoxGeometry(width, depth, height)
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
mesh.position.set(item.position[0], item.position[1], item.position[2])
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
mesh.castShadow = true
mesh.receiveShadow = true
mesh.userData.objectId = item.id
return mesh
}
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
const modelRoots: Record<string, string> = {
turtlebot3_world: modelRoot,
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
}
function parseNumbers(value: string | null | undefined, count = 0): number[] {
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
while (numbers.length < count) numbers.push(0)
return numbers
}
function directChild(element: Element | null | undefined, name: string): Element | null {
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
}
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
return directChild(element, name)?.textContent?.trim() || fallback
}
function applySdfPose(target: THREE.Object3D, value: string): void {
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
target.position.set(x, y, z)
target.rotation.set(roll, pitch, yaw, 'XYZ')
}
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
const ambient = visual.querySelector('ambient')?.textContent
const [red, green, blue] = parseNumbers(ambient, 3)
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
}
function gazeboAssetPath(uri: string): string {
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
}
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
const response = await fetch(`${modelRoot}/model.sdf`)
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
const model = documentNode.querySelector('model')
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
const colladaLoader = new ColladaLoader()
const stlLoader = new STLLoader()
const modelGroup = new THREE.Group()
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
parent.add(modelGroup)
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
const meshNode = directChild(geometry, 'mesh')
const uri = childText(meshNode, 'uri')
if (!uri) return
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
const url = gazeboAssetPath(uri)
const material = worldMaterial(visual)
if (url.endsWith('.stl')) {
const geometryValue = await stlLoader.loadAsync(url)
geometryValue.computeVertexNormals()
const mesh = new THREE.Mesh(geometryValue, material)
mesh.scale.set(scale[0], scale[1], scale[2])
mesh.castShadow = true
mesh.receiveShadow = true
target.add(mesh)
return
}
const collada = await colladaLoader.loadAsync(url)
collada.scene.scale.set(scale[0], scale[1], scale[2])
collada.scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return
child.material = material.clone()
child.castShadow = true
child.receiveShadow = true
})
target.add(collada.scene)
}
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group()
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
modelGroup.add(linkGroup)
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group()
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
linkGroup.add(visualGroup)
const geometry = directChild(visual, 'geometry')
if (!geometry) continue
const material = worldMaterial(visual)
if (directChild(geometry, 'mesh')) {
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
continue
}
const box = directChild(geometry, 'box')
const cylinder = directChild(geometry, 'cylinder')
if (box) {
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
} else if (cylinder) {
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
const height = Number(childText(cylinder, 'length', '1')) || 1
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
mesh.rotateX(Math.PI / 2)
visualGroup.add(mesh)
}
}
}
}
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(null)
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
const selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | null>(null)
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
useEffect(() => {
let active = true
void window.localWorkspace.worldLayout.read(workspaceRoot)
.then((nextLayout) => {
if (!active) return
worldRef.current = nextLayout
setLayout(nextLayout)
setSelectedId(nextLayout.objects[0]?.id ?? null)
})
.catch((cause: unknown) => {
if (!active) return
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
})
.finally(() => active && setLoading(false))
return () => { active = false }
}, [workspaceRoot])
useEffect(() => {
worldRef.current = layout
}, [layout])
useEffect(() => {
selectedIdRef.current = selectedId
renderSceneRef.current?.()
sendLayoutToViewer(worldRef.current)
}, [selectedId])
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-layout',
layout: nextLayout,
selectedId: selectedIdRef.current,
}, '*')
}
useEffect(() => {
sendLayoutToViewer(layout)
}, [layout])
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
if (event.data?.source !== 'autonomy-world-editor') return
if (event.data.type === 'ready') {
sendLayoutToViewer(worldRef.current)
return
}
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
const patch = event.data.patch as Partial<WorldLayoutObject>
setLayout((current) => {
const next = {
...current,
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
}
worldRef.current = next
return next
})
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
const host = canvasHostRef.current
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
// previous canvas path dormant while the shared viewer is mounted.
if (!host || loading || viewerRef.current) return
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b1220')
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
camera.position.set(4, 6, 5)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
host.replaceChildren(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 0, 0)
controls.enableDamping = true
controls.maxPolarAngle = Math.PI / 2.08
controls.update()
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
keyLight.position.set(6, -5, 11)
keyLight.castShadow = true
scene.add(keyLight)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
const sdfRoot = new THREE.Group()
sdfRoot.rotation.x = -Math.PI / 2
scene.add(sdfRoot)
const objectGroup = new THREE.Group()
const selectionGroup = new THREE.Group()
sdfRoot.add(objectGroup)
scene.add(selectionGroup)
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const renderScene = (): void => {
objectGroup.clear()
selectionGroup.clear()
for (const item of worldRef.current.objects) {
const mesh = createMesh(item)
objectGroup.add(mesh)
if (item.id === selectedIdRef.current) {
const outline = new THREE.Box3().setFromObject(mesh)
const size = outline.getSize(new THREE.Vector3())
const center = outline.getCenter(new THREE.Vector3())
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
selectionGroup.add(helper)
}
}
}
renderSceneRef.current = renderScene
renderScene()
let disposed = false
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
})
const resize = (): void => {
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
const observer = new ResizeObserver(resize)
observer.observe(host)
resize()
const handlePointerDown = (event: PointerEvent): void => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
}
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
let frameId = 0
const animate = (): void => {
controls.update()
renderer.render(scene, camera)
frameId = window.requestAnimationFrame(animate)
}
animate()
return () => {
disposed = true
renderSceneRef.current = null
window.cancelAnimationFrame(frameId)
observer.disconnect()
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
controls.dispose()
renderer.dispose()
host.replaceChildren()
}
}, [loading])
const selectTransformMode = (mode: TransformMode): void => {
setTransformMode(mode)
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-transform-mode',
mode,
}, '*')
}
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
setLayout((current) => {
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
worldRef.current = next
return next
})
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
if (!selected) return
const numeric = Number(value)
if (!Number.isFinite(numeric)) return
const next = [...selected[key]] as [number, number, number]
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
updateObject(selected.id, { [key]: next })
}
const addObject = (type: WorldObjectType): void => {
const object = createObject(type, layout.objects.length)
setLayout((current) => {
const next = { ...current, objects: [...current.objects, object] }
worldRef.current = next
return next
})
setSelectedId(object.id)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const removeSelected = (): void => {
if (!selected) return
const remaining = layout.objects.filter((item) => item.id !== selected.id)
const next = { ...layout, objects: remaining }
worldRef.current = next
setLayout(next)
setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] }
worldRef.current = next
setLayout(next)
setSelectedId(null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const save = async (): Promise<void> => {
setSaving(true)
setError(null)
try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved)
onSaved?.()
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
} finally {
setSaving(false)
}
}
return (
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div>
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
</div>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
<div className="border-b border-white/10 px-3 py-3">
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{layout.objects.length ? layout.objects.map((item) => {
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
const selected = item.id === selectedId
return (
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
</button>
)
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
</div>
<div className="border-t border-white/10 p-2">
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
<div className="grid grid-cols-3 gap-1">
{([
['pillar', Cylinder, '기둥'],
['wall', PanelsTopLeft, '벽'],
['box', Box, '박스'],
] as const).map(([type, Icon, label]) => (
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
))}
</div>
</div>
</aside>
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
{([
['translate', Move3D, '이동'],
['rotate', Rotate3D, '회전'],
['scale', Maximize2, '크기'],
] as const).map(([mode, Icon, label]) => (
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
))}
</div>
</main>
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
{selected ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
{(['position', 'rotation', 'size'] as const).map((key) => {
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
})}
</div>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
</aside>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
</footer>
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
</section>
</div>
)
}
@@ -0,0 +1,274 @@
import { AlertTriangle, Check, House, LoaderCircle, Map, PencilRuler, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
export type WorkspaceMapOption = {
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
saved?: boolean
savedMapId?: string
createdAt?: string
sourceMapKey?: string
yamlPath?: string
pgmPath?: string
previewPgm?: string
layout?: unknown
}
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
{
key: 'nav2-turtlebot3-world',
platformKey: 'NAV2',
name: '기본 월드',
description: 'TurtleBot3 기본 장애물 환경',
worldFile: 'turtlebot3_world.world',
viewerModel: 'turtlebot3_world'
},
{
key: 'nav2-turtlebot3-house',
platformKey: 'NAV2',
name: '실내 환경',
description: '방과 복도로 구성된 TurtleBot3 House',
worldFile: 'turtlebot3_house.world',
viewerModel: 'turtlebot3_house'
},
{
key: 'nav2-custom',
platformKey: 'NAV2',
name: '커스텀 맵',
description: '장애물을 직접 배치해 학습 환경 구성',
worldFile: 'empty_world.world',
viewerModel: 'turtlebot3_world',
custom: true
}
]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
const Icon = map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!map.previewPgm || !canvasRef.current) return
const canvas = canvasRef.current
const context = canvas.getContext('2d')
if (!context) return
try {
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
let cursor = 0
const nextToken = (): string => {
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (bytes[cursor] === 35) {
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
return nextToken()
}
const start = cursor
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
return String.fromCharCode(...bytes.slice(start, cursor))
}
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
const width = Number(nextToken())
const height = Number(nextToken())
const maxValue = Number(nextToken())
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
const targetWidth = 192
const targetHeight = 128
canvas.width = targetWidth
canvas.height = targetHeight
context.fillStyle = '#d9dce0'
context.fillRect(0, 0, targetWidth, targetHeight)
const scale = Math.min(targetWidth / width, targetHeight / height)
const renderedWidth = Math.max(1, Math.floor(width * scale))
const renderedHeight = Math.max(1, Math.floor(height * scale))
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
const image = context.createImageData(renderedWidth, renderedHeight)
for (let y = 0; y < renderedHeight; y += 1) {
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
for (let x = 0; x < renderedWidth; x += 1) {
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
const value = bytes[cursor + sourceY * width + sourceX]
const index = (y * renderedWidth + x) * 4
image.data[index] = value
image.data[index + 1] = value
image.data[index + 2] = value
image.data[index + 3] = 255
}
}
context.putImageData(image, offsetX, offsetY)
} catch {
// Keep the fallback map schematic visible when a legacy preview is invalid.
}
}, [map.previewPgm])
return (
<span
aria-hidden="true"
className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')}
>
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
{!map.previewPgm && (map.custom ? (
<>
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
</>
) : map.viewerModel === 'turtlebot3_house' ? (
<>
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
</>
) : (
<>
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{map.saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
</span>
)
}
type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number
maps?: WorkspaceMapOption[]
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = platformKey.toUpperCase() === 'NAV2'
? (configuredMaps?.length
? [...configuredMaps, ...(configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [] : [LOCAL_CUSTOM_MAP])]
: NAV2_MAP_OPTIONS)
: []
const environmentMaps = maps.filter((map) => !map.saved)
const savedMaps = maps.filter((map) => map.saved)
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => selectedMap?.saved ? 'saved' : 'environment')
const visibleMaps = tab === 'environment' ? environmentMaps : savedMaps
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setTab(selectedMap?.saved ? 'saved' : 'environment')
setPendingMap(selectedMap)
}, [selectedMapKey])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
</div>
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
{visibleMaps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
return (
<button
key={map.key}
type="button"
onClick={() => setPendingMap(map)}
className={[
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')}
>
<MapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
</span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
)}
</button>
)
})}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
맵 편집
</button>
)}
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
확인
</button>
</footer>
</section>
</div>
)
}
type WorldMapRestartConfirmModalProps = {
map: WorkspaceMapOption
restarting: boolean
onCancel: () => void
onConfirm: () => void
}
export function WorldMapRestartConfirmModal({ map, restarting, onCancel, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
return (
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
<AlertTriangle className="size-4 text-amber-300" />
<h2 className="text-[15px] font-bold">맵 변경</h2>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold">{map.name}으로 변경하시겠습니까?</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">실행 중인 모듈을 전체 재시작해야 새 맵이 적용됩니다.</p>
<div className="mt-5 flex justify-end gap-2">
<button type="button" disabled={restarting} onClick={onCancel} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/65 transition hover:bg-white/8 hover:text-white disabled:cursor-default disabled:opacity-45">
취소
</button>
<button type="button" disabled={restarting} onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-60">
{restarting && <LoaderCircle className="size-3.5 animate-spin" />}
{restarting ? '재시작 중' : '변경 후 재시작'}
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,102 @@
import type { CurriculumWeek } from '../curriculum/types'
import type { EnvironmentProfile } from '../platforms/types'
import type { ModulePrebuiltArtifact } from './types'
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
type RuntimeModuleOverride = {
id?: string
folderName?: string
name?: string
enabled?: boolean
sortOrder?: number
defaultParameters?: string[]
prebuiltArtifacts?: ModulePrebuiltArtifact[]
dockerImage?: string | null
}
type RuntimeModuleSet = {
target?: 'local' | 'platform'
includeUnlisted?: boolean
modules?: RuntimeModuleOverride[]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
const config = asRecord(profile?.config)
const raw = asRecord(config?.moduleSet)
if (!raw) return null
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
if (target && target !== runtimeTarget) return null
const modules = Array.isArray(raw.modules)
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
id: typeof item.id === 'string' ? item.id : undefined,
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
name: typeof item.name === 'string' ? item.name : undefined,
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
defaultParameters: Array.isArray(item.defaultParameters)
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
: undefined,
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
: undefined,
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
}))
: []
return {
target,
includeUnlisted: raw.includeUnlisted !== false,
modules,
}
}
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
return Boolean(
(override.id && override.id === module.id)
|| (override.folderName && override.folderName === module.folderName)
|| (override.name && override.name === module.name)
)
}
/** Environment-specific module selection preserves the shared student source repositories. */
export function resolveRuntimeModules(
modules: CurriculumWeek['modules'],
profile: EnvironmentProfile | EnvironmentProfile[] | null,
runtimeTarget: 'local' | 'platform',
): CurriculumWeek['modules'] {
const baseModules = modules ?? []
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
const moduleSets = profiles
.map((item) => readModuleSet(item, runtimeTarget))
.filter((item): item is RuntimeModuleSet => item !== null)
if (!moduleSets.length) return baseModules
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
return baseModules
.filter((module) => {
const override = overrides.find((item) => matchesModule(module, item))
return includeUnlisted || Boolean(override?.enabled !== false && override)
})
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
.map((module) => {
const override = overrides.find((item) => matchesModule(module, item))
if (!override) return module
return {
...module,
sortOrder: override.sortOrder ?? module.sortOrder,
defaultParameters: override.defaultParameters ?? module.defaultParameters,
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
}
})
.sort((left, right) => left.sortOrder - right.sortOrder)
}
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const safePlatformKey = platformKey
.replace(/^futuredrive[-_]/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.toLowerCase()
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}`
}
@@ -1,4 +1,5 @@
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
import type { PlatformOption } from '../platforms/types'
export type StudioRuntimeSessionState = {
localPcLayoutOpen: boolean
@@ -8,6 +9,7 @@ export type StudioRuntimeSessionState = {
sessionEndedAt: string | null
connectedPlatformId: string | null
runningPlatformIds: string[]
runningPlatforms: PlatformOption[]
workspaceWeekNumber: number | null
}
@@ -27,6 +29,15 @@ export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | nul
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
runningPlatforms: Array.isArray(parsed.runningPlatforms)
? parsed.runningPlatforms.filter((platform): platform is PlatformOption => (
Boolean(platform)
&& typeof platform === 'object'
&& typeof (platform as PlatformOption).id === 'string'
&& typeof (platform as PlatformOption).platformKey === 'string'
&& typeof (platform as PlatformOption).title === 'string'
))
: [],
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
}
} catch {
@@ -15,6 +15,7 @@ export type ModuleRow = {
templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
parameters: string[]
cpu: number | null
memory: number | null
@@ -62,19 +62,6 @@ export function useRemoteWorkspaceRuntime({
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
}
const submitResult = await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
@@ -87,23 +74,45 @@ export function useRemoteWorkspaceRuntime({
runtimeImages: activeRuntimeImages
}
)
const submitResult = remoteWorkspace
? await window.remoteWorkspace.git.submit(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
)
: await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined,
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
}, [
activeRuntimeImages,
clonePathPreview,
courseKey,
getFreshAccessToken,
getRemoteWorkspaceTarget,
remoteWorkspace,
saveDirtyFilesForModule,
weekNumber
])
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
if (remoteWorkspace) {
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
for (const module of availableModules) {
moduleMap.set(module.name, {
moduleName: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder
})
}
@@ -112,6 +121,7 @@ export function useRemoteWorkspaceRuntime({
moduleMap.set(module.name, {
moduleName: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder
})
}
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
type UseWorkspaceFileActionsOptions = {
selectedModule: ModuleRow | null
clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
selectedTreePath: string | null
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
export function useWorkspaceFileActions({
selectedModule,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath,
setSelectedTreePath,
setSelectedExampleTreePath,
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try {
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const nextFiles = result.files
const nextFolders = result.folders.length ? result.folders : []
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
if (newFileDraft.kind === 'folder') {
const nextFolder = `${newFileDraft.parent}/${normalizedName}`
try {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] create folder failed', error)
return
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
const nextFile = `${newFileDraft.parent}/${normalizedName}`
try {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] create file failed', error)
return
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
}
markEditorFileSaved(file, editorFiles[file] ?? '')
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
}
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
}
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
try {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] rename failed', error)
return
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
if (!selectedModule) return
try {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] delete failed', error)
return
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
try {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] copy failed', error)
return
@@ -87,11 +87,23 @@ export function useWorkspaceModuleControl({
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
const installOptions = {
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages
})
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
}
if (remoteWorkspace) {
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
templateRepositoryUrl,
prepared.repositoryUrlWithToken,
installOptions,
)
return
}
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, installOptions)
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module)
@@ -102,7 +114,8 @@ export function useWorkspaceModuleControl({
getModuleRosParams(module),
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId,
activeRuntimeImages
activeRuntimeImages,
module.dockerImage,
)
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
@@ -206,10 +219,11 @@ export function useWorkspaceModuleControl({
setModuleInstallError(null)
try {
if (remoteWorkspace) {
for (const module of targets) {
const result = await remoteModuleStop(module)
if (!result.ok) throw new Error(result.message)
}
const result = await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message)
} else {
const result = await window.localWorkspace.modules.stopMany(
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
@@ -227,7 +241,7 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteWorkspace])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || controllingModuleNames.includes(module.name)) return
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types'
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean
moduleListError: string | null
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
@@ -49,29 +50,79 @@ export function useWorkspaceModuleList({
const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const loadSequenceRef = useRef(0)
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const now = Date.now()
return modules.map((module) => {
const key = moduleStateKey(module)
const pending = pendingModuleStatesRef.current.get(key)
if (!pending) return module
if (module.state === pending.state || pending.expiresAt <= now) {
pendingModuleStatesRef.current.delete(key)
return module
}
return {
...module,
state: pending.state,
runtime: pending.state === 'running' ? '시작 중' : '-',
cpu: pending.state === 'running' ? module.cpu : null,
memory: pending.state === 'running' ? module.memory : null,
}
})
}, [])
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
const key = moduleStateKey(module)
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + 10_000 })
setAvailableModules((current) => current.map((item) => {
if (moduleStateKey(item) !== key) return item
return {
...item,
state,
runtime: state === 'running' ? '시작 중' : '-',
cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null,
}
}))
}, [])
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
if (!remoteTarget) return { modules: [] }
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
const result = await window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
return {
modules: result.modules.map((module) => {
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
const parameters = Array.isArray(rawModule.parameters)
? rawModule.parameters
: Array.isArray(rawModule.defaultParameters)
? rawModule.defaultParameters.filter((value): value is string => typeof value === 'string')
: []
return { ...module, parameters }
}),
}
}, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
const requestSequence = ++loadSequenceRef.current
if (!silent) setModulesLoading(true)
try {
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
if (remoteWorkspace) {
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] }))
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module]))
result.modules = result.modules.map((module) => {
const remoteModule = remoteStateByName.get(module.name)
return remoteModule?.state === 'running'
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
: module
})
const result = await loadRemoteModules()
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
return
}
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
} catch (error) {
if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error)
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
if (!silent) {
@@ -79,10 +130,11 @@ export function useWorkspaceModuleList({
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
}
} finally {
if (!silent) setModulesLoading(false)
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
}
}, [
applyModuleParameterOverrides,
applyPendingModuleStates,
clonePathPreview,
curriculumModuleKey,
curriculumModules,
@@ -107,6 +159,7 @@ export function useWorkspaceModuleList({
return {
availableModules,
setAvailableModules,
markModuleState,
modulesLoading,
moduleListError,
loadAvailableModules,
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
moduleName?: string | null
moduleFolderName?: string | null
clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
dark: boolean
terminalActive: boolean
resetKey: string
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
moduleName,
moduleFolderName,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark,
terminalActive,
resetKey,
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
const terminalRef = useRef<XTerm | null>(null)
const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
const fitTerminal = (): void => {
terminalFitRef.current?.fit()
}
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
void navigator.clipboard.readText()
.then((text) => {
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
if (!text) return
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), text, terminalSessionId)
return
}
window.localWorkspace.terminal.write(text, terminalSessionId)
})
.catch((error) => console.error('[terminal] paste failed', error))
.finally(() => {
@@ -153,8 +165,18 @@ export function useWorkspaceTerminal({
terminalRef.current = term
terminalFitRef.current = fitAddon
const terminalApi = window.localWorkspace.terminal
const removeDataListener = terminalApi.onData((data) => {
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
const removeDataListener = remoteWorkspace
? terminalApi.onData(remoteTargetResolverRef.current(), (data) => {
const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift()
return
}
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${data}`.slice(-100000)
term.write(data)
}, terminalSessionId)
: terminalApi.onData((data) => {
const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift()
@@ -168,10 +190,20 @@ export function useWorkspaceTerminal({
term.write(nextData)
}
}, terminalSessionId)
const removeSuppressListener = terminalApi.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
const removeSuppressListener = remoteWorkspace
? window.remoteWorkspace.terminal.onSuppressData(remoteTargetResolverRef.current(), (data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
: window.localWorkspace.terminal.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
const inputDisposable = term.onData((data) => {
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), data, terminalSessionId)
return
}
window.localWorkspace.terminal.write(data, terminalSessionId)
})
let fallbackPromptTimer: number | null = null
let sizeSyncTimer: number | null = null
@@ -183,8 +215,24 @@ export function useWorkspaceTerminal({
startNestedFrame = window.requestAnimationFrame(() => {
fitAddon.fit()
if (disposed) return resolvePromise(false)
void window.localWorkspace.terminal
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
const start = remoteWorkspace
? window.remoteWorkspace.terminal.start(
remoteTargetResolverRef.current(),
moduleName,
term.cols,
term.rows,
terminalSessionId,
moduleFolderName ?? undefined,
)
: window.localWorkspace.terminal.start(
moduleName,
term.cols,
term.rows,
terminalSessionId,
clonePathPreview,
moduleFolderName ?? undefined,
)
void start
.then(resolvePromise)
.catch(() => resolvePromise(false))
})
@@ -195,7 +243,11 @@ export function useWorkspaceTerminal({
if (!started) return
sizeSyncTimer = window.setTimeout(() => {
fitAddon.fit()
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
if (remoteWorkspace) {
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
return
}
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150)
fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
@@ -228,14 +280,15 @@ export function useWorkspaceTerminal({
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
terminalApi.stop(terminalSessionId)
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(remoteTargetResolverRef.current(), terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose()
terminalRef.current = null
terminalFitRef.current = null
terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = []
}
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
return {
terminalContextMenu,
@@ -3,12 +3,40 @@ import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'real_slam') return 'slam'
if (type === 'real_navigation') return 'nav2'
return type === 'autoware' || type === 'nav2' || type === 'slam' ? type : null
}
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
}
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
const type = profile?.profileType.toLowerCase()
return type === 'real_slam' || type === 'real_navigation'
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[]
clonePathPreview: string
rosDomainId: string
remoteWorkspace: boolean
mapKey?: string | null
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
@@ -19,6 +47,8 @@ type UseWorkspaceVisualizationResult = {
visualizationRunning: boolean
visualizationBusy: boolean
visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void>
@@ -36,20 +66,25 @@ export function useWorkspaceVisualization({
profiles,
clonePathPreview,
rosDomainId,
remoteWorkspace,
mapKey,
worldLayoutVersion,
onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => (
profiles
.map((profile) => profile.profileType.toLowerCase())
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
), [profiles])
runtimeProfiles
.map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack !== null)
), [runtimeProfiles])
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
@@ -78,16 +113,15 @@ export function useWorkspaceVisualization({
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationViewerUrl = visualizationStack === 'slam'
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}`
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => {
const profileType = profiles[0]?.profileType?.toLowerCase()
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
setVisualizationStack(profileType)
}
}, [profiles.map((profile) => profile.id).join('|')])
const stack = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
if (stack) setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [runtimeProfiles])
const refreshVisualizationStatus = async (): Promise<void> => {
const status = await window.localWorkspace.visualization.status().catch((error) => ({
@@ -155,6 +189,8 @@ export function useWorkspaceVisualization({
visualizationRunning,
visualizationBusy,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile,
activeRuntimeImages,
refreshVisualizationStatus,
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
if (file.endsWith('.json')) return 'json'
if (file.endsWith('.xml')) return 'xml'
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
if (file.endsWith('.html')) return 'html'
if (file.endsWith('.css')) return 'css'
if (file.endsWith('.py')) return 'python'