기본 개념
좌표
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)).
- 사람은 0° 에서 +y 를 본다. 90° 는 −x, 180° 는 −y, 270° 는 +x (
시점 회전 (
<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) => …}>
씬
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)은 배치를 본다: 겹친 가구, 떠 있는 물건, 그리드 밖, 아무도 앉지 못하는 자리, 갇힌 사람, 루틴이 쓸 자리가 없는 활동. 씬을 띄우기 전에 테스트에서 한 번 훑으면 좋다.
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 가 있으면 그쪽이 이긴다) |
color | tint 파츠의 색을 바꾼다 |
pose·seat | 앉은 사람: pose: 'sit', 누운 사람: pose: 'lie', seat: { on: '가구 id', index: 0 }. 사람과 가구는 따로 있는 오브젝트다 |
behavior | { routine, desk? } 가 있으면 행동 재생 때 루틴대로 움직인다 |
상태를 고르는 순서: 앱이 넘긴 states → 행동 재생이 정한 상태(빈 책상 모니터 off, 사람이 지나는 문 open) → 오브젝트 state → 에셋 defaultState.
데칼 — 면에 인쇄하는 그림·글자
상자에 찍힌 로고, 설비 이름판, 강판 주름 무늬는 파츠를 더 쌓지 않고 면에 인쇄한다.
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 은 그 길에 화물을 흘려보내고 파츠를 키프레임으로 움직인다.
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) 이 한 바퀴를 돌려 보며 이것들을 잡아 준다.
// 집게가 내려가는 로봇 팔 — 램이 늘어나므로 팔에서 떨어지지 않는다
{ 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. - 기본 활동:
workmeetrestsleepdrinkoperateprintreadwanderidle(+ 엔진이 쓰는exitaway). 에셋이 새 이름을 쓰면 그 이름을 적은 루틴이 그 자리를 고른다. - 책상의 모니터·노트북은 그 자리에서 일하는(
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 를 새로 만들 때 같은 시각에 다시 준다.