본문으로 건너뛰기

기본 개념 ​

좌표 ​

            z (위)
            │
            │
      ╱ ╲   │   화면에서 x 는 오른쪽 아래, y 는 왼쪽 아래, z 는 위로 간다.
   y ╱   ╲ x    1칸 = 1. 사무실 가구는 1칸을 1m 로 보고 만들었다.
  • 씬 좌표 [x, y, z]: 바닥 격자 위 한 점. 씬은 grid.width × grid.depth 칸이다.

  • 오브젝트 위치 position: 발자국(footprint)의 가장 작은 x·y 모서리와 놓인 높이 z. 가운데가 아니다.

    • 가운데로 놓고 싶으면 placeAt(scene, registry, assetId, [cx, cy], rotation) 이 모서리·높이를 계산한다.
  • 오브젝트 회전 rotation: 0·90·180·270. 90°·270° 면 발자국의 폭과 깊이가 바뀐다 (rotatedSize).

    • 사람은 0° 에서 +y 를 본다. 90° 는 −x, 180° 는 −y, 270° 는 +x (facing(dx, dy)).
  • 시점 회전 (<IsoScene rotation>): 씬 전체를 보는 방향. 데이터는 바뀌지 않고 그릴 때만 돈다.

  • 화면 좌표: SVG 사용자 좌표 (확대·이동 전). 투영은 iso(x, y, z, unit):

    sx = (x − y) · cos30° · unit
    sy = (x + y) · 0.5 · unit − z · unit
    할 일함수
    씬 좌표 → 화면projectPoint([x, y, z], scene, rotation)
    화면 → 높이 z 평면 위 씬 좌표unprojectPoint([sx, sy], scene, rotation, z)
    화면 → 그 아래 바닥(바닥재·러그 윗면)floorPointAt(scene, registry, [sx, sy], rotation)
    클릭한 곳의 바닥 좌표 (React)<IsoScene onSceneClick={(point, object) => …}>

씬 ​

ts
interface Scene {
  version: 1
  meta: { id: string; name: string }
  projection: { type: 'isometric'; unit: number }   // unit: 1칸이 화면에서 몇 px 인지 (기본 25)
  grid: { width: number; depth: number; snap: number }
  assetPacks: Record<string, string>               // 팩 id → 버전 범위
  objects: SceneObject[]
  paths?: ScenePath[]          // 화물이 흐르는 길
  timelines?: Timeline[]       // 경로 위 화물 생성, 파츠 키프레임
  routines?: Routine[]         // 이 씬에서 새로 정의한 루틴 (기본 루틴에 더해진다)
  clock?: SceneClock           // 하루 시각: 0초가 몇 시인지, 1초에 몇 분이 흐르는지
}
  • 파일로는 .isora.json 이다. 읽을 때 parseScene(json) 으로 검증하고, 고친 뒤 validateScene(scene) 으로 다시 본다.
  • 코드로 지을 때는 createSceneBuilder(scene, registry, { packs }) 를 쓴다. 바닥(floor)·벽과 문(room)·줄 세우기(row)· 위에 올리기(on)·앉히기(sit) 가 Studio 와 같은 쌓기 규칙을 따른다.
  • validateScene 이 JSON 모양을 본다면, lintScene(scene, registry) 은 배치를 본다: 겹친 가구, 떠 있는 물건, 그리드 밖, 아무도 앉지 못하는 자리, 갇힌 사람, 루틴이 쓸 자리가 없는 활동. 씬을 띄우기 전에 테스트에서 한 번 훑으면 좋다.
ts
const issues = lintScene(scene, registry, { locale: 'ko' })   // 메시지는 기본 영어. locale 'ko' 면 한국어
expect(issues.filter((i) => i.severity === 'error')).toEqual([])
console.log(formatIssues(issues))   // ! [blocked-spot] chair-3: 0 번 자리 옆에 설 곳이 없어 아무도 앉지 못합니다.

검사 메시지·에셋 이름의 언어는 언어 를 본다.

에셋과 오브젝트 ​

에셋(AssetDef)은 모양과 성질, 오브젝트(SceneObject)는 씬에 놓인 한 개다. 같은 에셋을 여러 번 놓는다.

에셋 필드뜻
parts상자(파츠) 목록. 그림·실루엣·깊이 정렬·충돌이 모두 파츠 상자에서 나온다
파츠의 screen그 면에 움직이는 화면 (code·chart·dashboard·status·alert·video)
파츠의 decals그 면에 인쇄하는 그림·글자 (로고·이름판·무늬). 아래 참고
footprint·height바닥에서 차지하는 폭×깊이, 높이
surface·surfaceHeight위에 다른 것을 올릴 수 있다 (책상 → 모니터). 올린 것은 받친 윗면 높이에 놓인다
ground바닥재. 늘 z = 0 에 깔리고 무엇보다 먼저 그린다
anchor: 'floor'책상·컨베이어 위에 올라타지 않고 늘 바닥재 위에 선다 (사람·벽·문)
states·defaultState이름 붙은 상태. 파츠의 재질·애니메이션·투명도·화면을 바꾼다
poses다른 자세 (사람의 sit). 자세마다 발자국·파츠가 따로 있다
seats앉을 자리 (의자·소파·벤치·휠체어). pose: 'lie' 면 눕는 자리 (침대)
spots서서 쓰는 자리 (정수기·조작반 앞)
walkable사람이 지나갈 수 있다 (문)
hollow속이 비어 있다 (부스·천막·초소·L 자 책상). 길 찾기가 발자국 전체가 아니라 파츠가 선 자리만 막아 안쪽 seats·spots 를 쓸 수 있다
오브젝트 필드뜻
asset·position·rotation무엇을, 어디에, 어느 방향으로
state상태 이름 (앱이 넘긴 states 가 있으면 그쪽이 이긴다)
colortint 파츠의 색을 바꾼다
pose·seat앉은 사람: pose: 'sit', 누운 사람: pose: 'lie', seat: { on: '가구 id', index: 0 }. 사람과 가구는 따로 있는 오브젝트다
behavior{ routine, desk? } 가 있으면 행동 재생 때 루틴대로 움직인다

상태를 고르는 순서: 앱이 넘긴 states → 행동 재생이 정한 상태(빈 책상 모니터 off, 사람이 지나는 문 open) → 오브젝트 state → 에셋 defaultState.

데칼 — 면에 인쇄하는 그림·글자 ​

상자에 찍힌 로고, 설비 이름판, 강판 주름 무늬는 파츠를 더 쌓지 않고 면에 인쇄한다.

ts
box({
  id: 'body',
  size: [1.26, 1.44, 0.72],
  material: '#f46800',
  decals: [
    { face: 'top', image: '/icons/grafana.svg', size: 0.72 },   // 뚜껑 로고
    { face: '+y', text: 'CI/CD', color: '#2b3a5a' },            // 앞면 이름판
    { face: '+x', image: ribsPattern, fit: 'fill' },            // 면을 꽉 채우는 무늬
  ],
})
  • face 는 top·+x·+y. 세로면은 회전을 거쳐 보는 사람 쪽을 향할 때만 그린다 (화면과 같은 규칙).
  • size 는 면에서 차지하는 비율(기본 0.8), fit: 'fill' 은 면을 늘려 채운다 (무늬).
  • turn 은 면 안에서 그림을 돌린다. 벨트를 따라 90° 로 눕는 화물의 로고를 바로 세울 때 쓴다.
  • 글자는 면 안에 들어가도록 크기를 맞춘다. image 는 데이터 URI·상대 경로·http(s) 주소다 (팩으로 배포할 때 바깥 주소는 경고).

경로와 타임라인 ​

paths 는 화물이 지나는 길, timelines 은 그 길에 화물을 흘려보내고 파츠를 키프레임으로 움직인다.

ts
scene.paths = [{ id: 'in', points: [[6, 18, 1.45], [6, 3.5, 1.45]], speed: 0.8 }]
scene.timelines = [{
  id: 'line',
  duration: 19,
  loop: true,
  tracks: [
    {
      type: 'spawn', asset: 'nullus/line/pallet-in', path: 'in', every: 19, fade: 0.6,
      // 판넬이 3.7칸 지난 자리에서 크레인이 상자를 얹는다. 거기서부터 함께 간다.
      loads: [{ asset: 'nullus/line/oss-grafana', at: [0, 0, 0.18], from: 3.7 }],
    },
    { type: 'keyframes', target: 'rig-in-0', prop: 'z', keys: [[0.1, 0], [0.24, -2.6], [0.36, 0]] },
  ],
}]
스폰 트랙every 초마다 경로 시작점에 화물을 만들어 끝까지 옮긴다. 긴 화물은 가는 방향으로 눕는다 (에셋의 x 축이 가는 방향)
face'direction' 이면 눕히는 대신 가는 쪽을 앞으로 돌린다. 사람처럼 +y 를 앞으로 그린 차량에 쓴다 — 왕복 경로에서 차가 뒤로 가 보이지 않는다
loads화물에 실려 함께 가는 짐. at 은 화물 기준 자리, from·to 는 경로를 얼마나 간 뒤에 나타나고 사라지는지 (칸)
fade나타나고 사라질 때 이만큼(초) 동안 서서히
키프레임 트랙target(오브젝트 id 또는 오브젝트 id/파츠 id)의 값을 [진행률 0~1, 값] 으로 움직인다
키프레임 prop자리 x·y·z, 크기 w·d·h, opacity

같은 값에 걸린 트랙이 여럿이면 자리·크기는 더하고 불투명도는 곱한다. 임시 오브젝트 id 는 ~ 로 시작한다.

움직임은 물리를 따라야 읽힌다. 경로의 z 는 화물이 올라탄 면의 세계 좌표다 (바닥재 위에 놓인 컨베이어면 바닥재 0.1 을 더한다). 경로는 받쳐 줄 것이 있는 구간 안에서 시작하고 끝내야 하고, 매달린 파츠를 내릴 때는 z 를 내리는 만큼 h 를 키워야 윗끝이 제자리에 붙어 있다. lintMotion(scene, registry) 이 한 바퀴를 돌려 보며 이것들을 잡아 준다.

ts
// 집게가 내려가는 로봇 팔 — 램이 늘어나므로 팔에서 떨어지지 않는다
{ type: 'keyframes', target: 'arm-1/wrist', prop: 'z', keys: [[0, 0], [0.5, -0.35], [1, 0]] },
{ type: 'keyframes', target: 'arm-1/wrist', prop: 'h', keys: [[0, 0], [0.5, 0.35], [1, 0]] },
{ type: 'keyframes', target: 'arm-1/gripper', prop: 'z', keys: [[0, 0], [0.5, -0.35], [1, 0]] },

자리 (Activity spot) ​

가구·설비의 seats·spots 는 씬에 놓이면 활동 자리가 된다. id 는 오브젝트id#seat0, 오브젝트id#spot0 이다.

  • 앉는 자리의 활동은 적혀 있으면 그것(sofa → rest), 없으면 앞에 놓인 것으로 정한다: 모니터·노트북이 놓인 책상 → work, 탁자 → meet, 아무것도 없으면 rest. 눕는 자리(침대)는 sleep.
  • 기본 활동: work meet rest sleep drink operate print read wander idle (+ 엔진이 쓰는 exit away). 에셋이 새 이름을 쓰면 그 이름을 적은 루틴이 그 자리를 고른다.
  • 책상의 모니터·노트북은 그 자리에서 일하는(work) 사람이 있을 때만 켜진다. 비었거나 앉아서 졸면 꺼진다.
  • findActivitySpots(scene, registry) 로 씬의 자리와, 자리마다 사람이 설 곳(approach)·앉은 모습(seated)을 얻는다.

시간 ​

  • 재생 시각: 초. 0 에서 시작한다. 씬에 clock 이 있으면 clockMinutes(clock, t) 가 하루 시각(분)이다.
  • 시계 (createTimelineClock()): 재생·멈춤·배속·시각 이동. 행동·타임라인 재생이 함께 쓸 수 있다.
    • getState().time·subscribe: 틱(80ms)마다 바뀐다. 하루 시각 표시·말풍선 활동·인스펙터처럼 자주 안 바뀌어도 되는 것에 쓴다.
    • timeAt(performance.now()): 지금 이 프레임의 시각. 걷는 사람처럼 매 프레임 그리는 것에 쓴다 (LiveSceneLayer 가 쓴다).
  • prefers-reduced-motion 이면 행동·타임라인 재생은 0초에 멈춰 그린다.

결정성 ​

사람의 움직임은 씬 + seed + 명령(각 명령의 시각 포함) + 재생 시각만으로 정해진다.

  • createBehaviorWorld(scene, registry, { seed }).frameAt(t) 은 언제, 몇 번, 어떤 순서로 불러도 같은 t 에 같은 모습이다. 서버 렌더·되감기·배속·스크린샷 테스트가 모두 이 성질에 기대고 있다.
  • 명령(command)도 입력이다. 같은 명령을 같은 시각에 주면 같은 움직임이 나온다. useBehaviorPlayback 은 명령 기록을 두었다가, 씬을 고쳐 world 를 새로 만들 때 같은 시각에 다시 준다.

MIT 라이선스로 배포합니다.