@jkwd/inbase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +76 -0
  2. package/apps/explorer/index.html +12 -0
  3. package/apps/explorer/package.json +28 -0
  4. package/apps/explorer/scripts/js-source.mjs +188 -0
  5. package/apps/explorer/scripts/patch-lib.d.ts +115 -0
  6. package/apps/explorer/scripts/patch-lib.mjs +472 -0
  7. package/apps/explorer/scripts/scan-target.mjs +188 -0
  8. package/apps/explorer/scripts/session-store.d.ts +156 -0
  9. package/apps/explorer/scripts/session-store.mjs +809 -0
  10. package/apps/explorer/scripts/target-config.d.ts +8 -0
  11. package/apps/explorer/scripts/target-config.mjs +42 -0
  12. package/apps/explorer/src/App.tsx +941 -0
  13. package/apps/explorer/src/agentIntent.ts +182 -0
  14. package/apps/explorer/src/codebase.ts +15 -0
  15. package/apps/explorer/src/index.css +632 -0
  16. package/apps/explorer/src/layout.ts +508 -0
  17. package/apps/explorer/src/main.tsx +16 -0
  18. package/apps/explorer/src/scene/BlockPlacer.tsx +87 -0
  19. package/apps/explorer/src/scene/Bridge.tsx +290 -0
  20. package/apps/explorer/src/scene/FileBlock.tsx +256 -0
  21. package/apps/explorer/src/scene/FolderArea.tsx +96 -0
  22. package/apps/explorer/src/scene/IslandPlacer.tsx +40 -0
  23. package/apps/explorer/src/scene/MapSelectBorder.tsx +57 -0
  24. package/apps/explorer/src/scene/MapView.tsx +247 -0
  25. package/apps/explorer/src/scene/Player.tsx +245 -0
  26. package/apps/explorer/src/scene/RelationLines.tsx +223 -0
  27. package/apps/explorer/src/scene/SelectionController.tsx +89 -0
  28. package/apps/explorer/src/scene/UserContextTracker.tsx +152 -0
  29. package/apps/explorer/src/scene/World.tsx +323 -0
  30. package/apps/explorer/src/theme.ts +111 -0
  31. package/apps/explorer/src/types.ts +245 -0
  32. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +38 -0
  33. package/apps/explorer/src/ui/HUD.tsx +1090 -0
  34. package/apps/explorer/src/ui/NameInput.tsx +45 -0
  35. package/apps/explorer/src/userContext.ts +73 -0
  36. package/apps/explorer/src/userCreated.ts +354 -0
  37. package/apps/explorer/src/vite-env.d.ts +1 -0
  38. package/apps/explorer/tsconfig.json +21 -0
  39. package/apps/explorer/vite.config.ts +295 -0
  40. package/bin/inbase.mjs +170 -0
  41. package/bin/project.mjs +94 -0
  42. package/bin/session.mjs +241 -0
  43. package/package.json +63 -0
  44. package/skill/inbase/SKILL.md +167 -0
@@ -0,0 +1,290 @@
1
+ import { Suspense } from 'react'
2
+ import { Text } from '@react-three/drei'
3
+ import { CONFIG } from '../theme'
4
+ import type { PlacedBridge } from '../types'
5
+
6
+ const WALK_Y = 0.02
7
+ const LAND_COLOR = '#2a3340'
8
+ const SPAN_COLOR = '#232e3a'
9
+ const CORNER_SIZE = CONFIG.bridgeWidth
10
+
11
+ const POST_W = 0.3
12
+ const POST_D = 0.38
13
+ const POST_H = 3.45
14
+ const LINTEL_H = 0.34
15
+ const CAP_H = 0.2
16
+ const CAP_OVERHANG = 0.42
17
+ const SIGN_H = 0.72
18
+ const SIGN_D = 0.08
19
+ const GATE_COLOR = '#4a5564'
20
+ const CAP_COLOR = '#5a6574'
21
+ const SIGN_COLOR = '#323a46'
22
+
23
+ type BridgeProps = {
24
+ bridge: PlacedBridge
25
+ }
26
+
27
+ type Segment = {
28
+ key: string
29
+ x: number
30
+ z: number
31
+ width: number
32
+ depth: number
33
+ }
34
+
35
+ type WalkPiece = Segment & { color: string }
36
+
37
+ function segmentsFromPoints(points: [number, number][]): Segment[] {
38
+ const segments: Segment[] = []
39
+
40
+ for (let i = 1; i < points.length; i += 1) {
41
+ const [x0, z0] = points[i - 1]
42
+ const [x1, z1] = points[i]
43
+ const alongX = Math.abs(x1 - x0) >= Math.abs(z1 - z0)
44
+ const length = alongX ? Math.abs(x1 - x0) : Math.abs(z1 - z0)
45
+ if (length < 0.01) continue
46
+
47
+ segments.push({
48
+ key: `seg-${i}`,
49
+ x: (x0 + x1) / 2,
50
+ z: (z0 + z1) / 2,
51
+ width: alongX ? length : CONFIG.bridgeWidth,
52
+ depth: alongX ? CONFIG.bridgeWidth : length,
53
+ })
54
+ }
55
+
56
+ return segments
57
+ }
58
+
59
+ function cornersFromPoints(points: [number, number][]) {
60
+ const corners: { x: number; z: number }[] = []
61
+ for (let i = 1; i < points.length - 1; i += 1) {
62
+ const prev = points[i - 1]
63
+ const point = points[i]
64
+ const next = points[i + 1]
65
+ const incomingX = Math.abs(point[0] - prev[0]) > Math.abs(point[1] - prev[1])
66
+ const outgoingX = Math.abs(next[0] - point[0]) > Math.abs(next[1] - point[1])
67
+ if (incomingX !== outgoingX) {
68
+ corners.push({ x: point[0], z: point[1] })
69
+ }
70
+ }
71
+ return corners
72
+ }
73
+
74
+ function splitOverLand(segment: Segment): WalkPiece[] {
75
+ const overlap = CONFIG.bridgeOverlap
76
+ const alongX = segment.width >= segment.depth
77
+ const length = alongX ? segment.width : segment.depth
78
+ const spanLength = Math.max(0, length - overlap * 2)
79
+
80
+ if (spanLength < 0.01) {
81
+ return [{ ...segment, color: LAND_COLOR }]
82
+ }
83
+
84
+ if (alongX) {
85
+ const left = segment.x - length / 2
86
+ return [
87
+ {
88
+ key: `${segment.key}-land-a`,
89
+ x: left + overlap / 2,
90
+ z: segment.z,
91
+ width: overlap,
92
+ depth: segment.depth,
93
+ color: LAND_COLOR,
94
+ },
95
+ {
96
+ key: `${segment.key}-span`,
97
+ x: segment.x,
98
+ z: segment.z,
99
+ width: spanLength,
100
+ depth: segment.depth,
101
+ color: SPAN_COLOR,
102
+ },
103
+ {
104
+ key: `${segment.key}-land-b`,
105
+ x: left + length - overlap / 2,
106
+ z: segment.z,
107
+ width: overlap,
108
+ depth: segment.depth,
109
+ color: LAND_COLOR,
110
+ },
111
+ ]
112
+ }
113
+
114
+ const start = segment.z - length / 2
115
+ return [
116
+ {
117
+ key: `${segment.key}-land-a`,
118
+ x: segment.x,
119
+ z: start + overlap / 2,
120
+ width: segment.width,
121
+ depth: overlap,
122
+ color: LAND_COLOR,
123
+ },
124
+ {
125
+ key: `${segment.key}-span`,
126
+ x: segment.x,
127
+ z: segment.z,
128
+ width: segment.width,
129
+ depth: spanLength,
130
+ color: SPAN_COLOR,
131
+ },
132
+ {
133
+ key: `${segment.key}-land-b`,
134
+ x: segment.x,
135
+ z: start + length - overlap / 2,
136
+ width: segment.width,
137
+ depth: overlap,
138
+ color: LAND_COLOR,
139
+ },
140
+ ]
141
+ }
142
+
143
+ function Walk({
144
+ x,
145
+ z,
146
+ width,
147
+ depth,
148
+ color,
149
+ }: {
150
+ x: number
151
+ z: number
152
+ width: number
153
+ depth: number
154
+ color: string
155
+ }) {
156
+ return (
157
+ <mesh position={[x, WALK_Y, z]} rotation={[-Math.PI / 2, 0, 0]}>
158
+ <planeGeometry args={[width, depth]} />
159
+ <meshBasicMaterial color={color} />
160
+ </mesh>
161
+ )
162
+ }
163
+
164
+ function segmentDir(from: [number, number], to: [number, number]): [number, number] {
165
+ const dx = to[0] - from[0]
166
+ const dz = to[1] - from[1]
167
+ const length = Math.hypot(dx, dz)
168
+ if (length < 0.001) return [0, 1]
169
+ return [dx / length, dz / length]
170
+ }
171
+
172
+ function Gate({
173
+ x,
174
+ z,
175
+ faceX,
176
+ faceZ,
177
+ label,
178
+ }: {
179
+ x: number
180
+ z: number
181
+ faceX: number
182
+ faceZ: number
183
+ label: string
184
+ }) {
185
+ const rotationY = Math.atan2(faceX, faceZ)
186
+ const postX = CONFIG.bridgeWidth / 2 + POST_W / 2
187
+ const lintelW = postX * 2 + POST_W
188
+ const capW = lintelW + CAP_OVERHANG * 2
189
+ const lintelY = POST_H + LINTEL_H / 2
190
+ const capY = POST_H + LINTEL_H + CAP_H / 2
191
+ const signY = POST_H + LINTEL_H / 2
192
+ const signZ = POST_D / 2 + SIGN_D / 2 + 0.01
193
+ const fontSize = Math.min(0.42, (lintelW - 0.55) / Math.max(label.length * 0.62, 3))
194
+
195
+ return (
196
+ <group position={[x, 0, z]} rotation={[0, rotationY, 0]}>
197
+ <mesh position={[-postX, POST_H / 2, 0]}>
198
+ <boxGeometry args={[POST_W, POST_H, POST_D]} />
199
+ <meshLambertMaterial color={GATE_COLOR} />
200
+ </mesh>
201
+ <mesh position={[postX, POST_H / 2, 0]}>
202
+ <boxGeometry args={[POST_W, POST_H, POST_D]} />
203
+ <meshLambertMaterial color={GATE_COLOR} />
204
+ </mesh>
205
+ <mesh position={[0, lintelY, 0]}>
206
+ <boxGeometry args={[lintelW, LINTEL_H, POST_D]} />
207
+ <meshLambertMaterial color={GATE_COLOR} />
208
+ </mesh>
209
+ <mesh position={[0, capY, 0]}>
210
+ <boxGeometry args={[capW, CAP_H, POST_D + 0.08]} />
211
+ <meshLambertMaterial color={CAP_COLOR} />
212
+ </mesh>
213
+ <mesh position={[0, signY, signZ]}>
214
+ <boxGeometry args={[lintelW - 0.18, SIGN_H, SIGN_D]} />
215
+ <meshLambertMaterial color={SIGN_COLOR} />
216
+ </mesh>
217
+ <Suspense fallback={null}>
218
+ <Text
219
+ position={[0, signY, signZ + SIGN_D / 2 + 0.01]}
220
+ fontSize={fontSize}
221
+ color="#e7ebf2"
222
+ anchorX="center"
223
+ anchorY="middle"
224
+ maxWidth={lintelW - 0.4}
225
+ overflowWrap="break-word"
226
+ outlineWidth={0.02}
227
+ outlineColor="#07080b"
228
+ >
229
+ {label}
230
+ </Text>
231
+ </Suspense>
232
+ </group>
233
+ )
234
+ }
235
+
236
+ export function Bridge({ bridge }: BridgeProps) {
237
+ const segments = segmentsFromPoints(bridge.points)
238
+ const corners = cornersFromPoints(bridge.points)
239
+ const start = bridge.points[0]
240
+ const end = bridge.points[bridge.points.length - 1]
241
+ const second = bridge.points[1]
242
+ const beforeEnd = bridge.points[bridge.points.length - 2]
243
+ const startDir = start && second ? segmentDir(start, second) : null
244
+ const endDir = beforeEnd && end ? segmentDir(beforeEnd, end) : null
245
+
246
+ return (
247
+ <group>
248
+ {segments.flatMap(splitOverLand).map((piece) => (
249
+ <Walk
250
+ key={piece.key}
251
+ x={piece.x}
252
+ z={piece.z}
253
+ width={piece.width}
254
+ depth={piece.depth}
255
+ color={piece.color}
256
+ />
257
+ ))}
258
+
259
+ {corners.map((corner) => (
260
+ <Walk
261
+ key={`corner-${corner.x}-${corner.z}`}
262
+ x={corner.x}
263
+ z={corner.z}
264
+ width={CORNER_SIZE}
265
+ depth={CORNER_SIZE}
266
+ color={SPAN_COLOR}
267
+ />
268
+ ))}
269
+
270
+ {start && startDir && (
271
+ <Gate
272
+ x={start[0]}
273
+ z={start[1]}
274
+ faceX={-startDir[0]}
275
+ faceZ={-startDir[1]}
276
+ label={bridge.label}
277
+ />
278
+ )}
279
+ {end && endDir && (
280
+ <Gate
281
+ x={end[0]}
282
+ z={end[1]}
283
+ faceX={endDir[0]}
284
+ faceZ={endDir[1]}
285
+ label={bridge.fromLabel}
286
+ />
287
+ )}
288
+ </group>
289
+ )
290
+ }
@@ -0,0 +1,256 @@
1
+ import { Suspense } from 'react'
2
+ import { Billboard, Edges, Html, Text } from '@react-three/drei'
3
+ import { CHANGE_HIGHLIGHT, CONFIG, dimColor, fileColor, type ChangeKind } from '../theme'
4
+ import { NameInput } from '../ui/NameInput'
5
+ import { MapSelectBorder } from './MapSelectBorder'
6
+ import type { FileNode } from '../types'
7
+ import type { PlacedFile } from '../types'
8
+
9
+ const BAR = 0.22
10
+ const SIDE_LABEL_PAD = 0.05
11
+ const SIDE_LABEL_MIN_HEIGHT = CONFIG.eyeHeight * 2.5
12
+ const SIDE_LABELS: { rotationY: number; axis: 'x' | 'z'; sign: 1 | -1 }[] = [
13
+ { rotationY: 0, axis: 'z', sign: 1 },
14
+ { rotationY: Math.PI, axis: 'z', sign: -1 },
15
+ { rotationY: Math.PI / 2, axis: 'x', sign: 1 },
16
+ { rotationY: -Math.PI / 2, axis: 'x', sign: -1 },
17
+ ]
18
+
19
+ type FileBlockProps = {
20
+ file: FileNode
21
+ placed: PlacedFile
22
+ selected: boolean
23
+ related: boolean
24
+ planned: boolean
25
+ changeKind?: ChangeKind | null
26
+ added?: boolean
27
+ aimed?: boolean
28
+ dimmed: boolean
29
+ naming?: boolean
30
+ mapMode?: boolean
31
+ onCommitName?: (name: string) => void
32
+ onCancelName?: () => void
33
+ }
34
+
35
+ function PlannedFrame({
36
+ width,
37
+ height,
38
+ depth,
39
+ color,
40
+ }: {
41
+ width: number
42
+ height: number
43
+ depth: number
44
+ color: string
45
+ }) {
46
+ const ox = width / 2 + BAR / 2
47
+ const oy = height / 2 + BAR / 2
48
+ const oz = depth / 2 + BAR / 2
49
+ const bars: [number, number, number, number, number, number][] = [
50
+ [ox, 0, oz, BAR, height + BAR * 2, BAR],
51
+ [-ox, 0, oz, BAR, height + BAR * 2, BAR],
52
+ [ox, 0, -oz, BAR, height + BAR * 2, BAR],
53
+ [-ox, 0, -oz, BAR, height + BAR * 2, BAR],
54
+ [0, oy, oz, width + BAR * 2, BAR, BAR],
55
+ [0, oy, -oz, width + BAR * 2, BAR, BAR],
56
+ [ox, oy, 0, BAR, BAR, depth],
57
+ [-ox, oy, 0, BAR, BAR, depth],
58
+ [0, -oy, oz, width + BAR * 2, BAR, BAR],
59
+ [0, -oy, -oz, width + BAR * 2, BAR, BAR],
60
+ [ox, -oy, 0, BAR, BAR, depth],
61
+ [-ox, -oy, 0, BAR, BAR, depth],
62
+ ]
63
+
64
+ return (
65
+ <group>
66
+ {bars.map(([x, y, z, w, h, d], index) => (
67
+ <mesh key={index} position={[x, y, z]}>
68
+ <boxGeometry args={[w, h, d]} />
69
+ <meshBasicMaterial color={color} toneMapped={false} />
70
+ </mesh>
71
+ ))}
72
+ </group>
73
+ )
74
+ }
75
+
76
+ function fileLabel(name: string, changeKind: ChangeKind | null, added: boolean) {
77
+ if (changeKind === 'remove') return `- ${name}`
78
+ if (changeKind === 'add' || added) return `+ ${name}`
79
+ return name
80
+ }
81
+
82
+ export function FileBlock({
83
+ file,
84
+ placed,
85
+ selected,
86
+ related,
87
+ planned,
88
+ changeKind = null,
89
+ added = false,
90
+ aimed = false,
91
+ dimmed,
92
+ naming = false,
93
+ mapMode = false,
94
+ onCommitName,
95
+ onCancelName,
96
+ }: FileBlockProps) {
97
+ const [width, height, depth] = placed.size
98
+ const highlight = changeKind ? CHANGE_HIGHLIGHT[changeKind] : null
99
+ const color = added || file.userCreated ? '#7ec8e8' : fileColor(file.language)
100
+ const muted = dimColor(color, 0.32)
101
+ const walkSelected = selected && !mapMode
102
+ const mapSelected = selected && mapMode
103
+ const label = fileLabel(file.name, changeKind, added || Boolean(file.userCreated))
104
+ const labelColor = aimed
105
+ ? '#9ad8ff'
106
+ : highlight
107
+ ? highlight.color
108
+ : dimmed
109
+ ? '#b4bcc8'
110
+ : '#e7ebf2'
111
+ const meshColor = aimed
112
+ ? '#9ad8ff'
113
+ : highlight
114
+ ? highlight.color
115
+ : walkSelected
116
+ ? '#5d9ec4'
117
+ : related
118
+ ? '#4e7f72'
119
+ : dimmed
120
+ ? muted
121
+ : color
122
+
123
+ return (
124
+ <group position={placed.position}>
125
+ {highlight && !mapSelected && (
126
+ <mesh
127
+ position={[0, height / 2 + 0.04, 0]}
128
+ rotation={[-Math.PI / 2, 0, 0]}
129
+ userData={{ fileId: file.id }}
130
+ >
131
+ <planeGeometry args={[width + 0.7, depth + 0.7]} />
132
+ <meshBasicMaterial color={highlight.color} toneMapped={false} />
133
+ </mesh>
134
+ )}
135
+ <mesh userData={{ fileId: file.id }}>
136
+ <boxGeometry args={[width, height, depth]} />
137
+ <meshLambertMaterial
138
+ color={meshColor}
139
+ emissive={
140
+ aimed
141
+ ? '#3a6a80'
142
+ : highlight
143
+ ? highlight.emissive
144
+ : walkSelected
145
+ ? '#2a4a5c'
146
+ : related
147
+ ? '#1f4a44'
148
+ : added || file.userCreated
149
+ ? '#2a5064'
150
+ : dimmed
151
+ ? muted
152
+ : color
153
+ }
154
+ emissiveIntensity={
155
+ aimed
156
+ ? 0.45
157
+ : highlight
158
+ ? 0.95
159
+ : walkSelected
160
+ ? 0.28
161
+ : related
162
+ ? 0.18
163
+ : added || file.userCreated
164
+ ? 0.22
165
+ : dimmed
166
+ ? 0.08
167
+ : 0.12
168
+ }
169
+ />
170
+ {walkSelected && (
171
+ <Edges
172
+ color="#000000"
173
+ dashed
174
+ dashSize={0.18}
175
+ gapSize={0.1}
176
+ lineWidth={2.5}
177
+ toneMapped={false}
178
+ scale={1.002}
179
+ />
180
+ )}
181
+ </mesh>
182
+ {mapSelected && (
183
+ <MapSelectBorder
184
+ width={width}
185
+ depth={depth}
186
+ y={height / 2 + 0.05}
187
+ userData={{ fileId: file.id }}
188
+ />
189
+ )}
190
+ {highlight && !mapSelected && (
191
+ <PlannedFrame
192
+ width={width}
193
+ height={height}
194
+ depth={depth}
195
+ color={highlight.color}
196
+ />
197
+ )}
198
+ {naming && onCommitName && onCancelName && (
199
+ <Html
200
+ position={[0, height / 2 + 0.42, 0]}
201
+ center
202
+ occlude={false}
203
+ wrapperClass="block-name-overlay"
204
+ zIndexRange={[100, 0]}
205
+ >
206
+ <NameInput
207
+ placeholder="File name"
208
+ onCommit={onCommitName}
209
+ onCancel={onCancelName}
210
+ />
211
+ </Html>
212
+ )}
213
+ <Suspense fallback={null}>
214
+ {!naming && (
215
+ <Billboard position={[0, height / 2 + (planned || highlight ? 0.55 : 0.38), 0]}>
216
+ <Text
217
+ fontSize={0.28}
218
+ color={labelColor}
219
+ anchorX="center"
220
+ anchorY="bottom"
221
+ maxWidth={3.4}
222
+ >
223
+ {label}
224
+ </Text>
225
+ </Billboard>
226
+ )}
227
+ {height >= SIDE_LABEL_MIN_HEIGHT &&
228
+ SIDE_LABELS.map((side) => {
229
+ const face = side.axis === 'x' ? width : depth
230
+ const x =
231
+ side.axis === 'x' ? side.sign * (width / 2 + SIDE_LABEL_PAD) : 0
232
+ const z =
233
+ side.axis === 'z' ? side.sign * (depth / 2 + SIDE_LABEL_PAD) : 0
234
+ return (
235
+ <Text
236
+ key={`${side.axis}:${side.sign}`}
237
+ position={[x, -height / 2 + 0.22, z]}
238
+ rotation={[0, side.rotationY, 0]}
239
+ fontSize={0.22}
240
+ color={labelColor}
241
+ anchorX="center"
242
+ anchorY="middle"
243
+ maxWidth={face - 0.2}
244
+ overflowWrap="break-word"
245
+ outlineWidth={0.012}
246
+ outlineColor="#11151c"
247
+ depthOffset={-1}
248
+ >
249
+ {label}
250
+ </Text>
251
+ )
252
+ })}
253
+ </Suspense>
254
+ </group>
255
+ )
256
+ }
@@ -0,0 +1,96 @@
1
+ import { Suspense } from 'react'
2
+ import { Text } from '@react-three/drei'
3
+ import { CHANGE_HIGHLIGHT, folderFloorColor, MAP_SELECTION, type ChangeKind } from '../theme'
4
+ import type { PlacedFolder } from '../types'
5
+ import { MapSelectBorder } from './MapSelectBorder'
6
+
7
+ type FolderAreaProps = {
8
+ folder: PlacedFolder
9
+ naming?: boolean
10
+ selected?: boolean
11
+ highlightKind?: ChangeKind | null
12
+ }
13
+
14
+ export function FolderArea({
15
+ folder,
16
+ naming = false,
17
+ selected = false,
18
+ highlightKind = null,
19
+ }: FolderAreaProps) {
20
+ const added = Boolean(folder.added)
21
+ const highlight = highlightKind ? CHANGE_HIGHLIGHT[highlightKind] : null
22
+ const addedOnly = added && !highlight
23
+ const outline = highlight ? highlight.color : addedOnly ? '#7ec8e8' : null
24
+ const color = highlight
25
+ ? highlight.floor
26
+ : addedOnly
27
+ ? '#16323f'
28
+ : folderFloorColor(folder.path)
29
+ const label =
30
+ highlightKind === 'remove'
31
+ ? `- ${folder.name}`
32
+ : highlightKind === 'add' || added
33
+ ? `+ ${folder.name}`
34
+ : folder.name
35
+
36
+ return (
37
+ <group position={[folder.x, 0, folder.z + folder.depth / 2]}>
38
+ {outline && (
39
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
40
+ <planeGeometry args={[folder.width + 0.9, folder.depth + 0.9]} />
41
+ <meshBasicMaterial
42
+ color={outline}
43
+ toneMapped={false}
44
+ />
45
+ </mesh>
46
+ )}
47
+ <mesh rotation={[-Math.PI / 2, 0, 0]}>
48
+ <planeGeometry args={[folder.width, folder.depth]} />
49
+ <meshBasicMaterial color={color} />
50
+ </mesh>
51
+ {selected && (
52
+ <MapSelectBorder
53
+ width={folder.width}
54
+ depth={folder.depth}
55
+ y={0.06}
56
+ stroke={MAP_SELECTION.islandPad}
57
+ />
58
+ )}
59
+ <mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
60
+ <planeGeometry args={[2.2, folder.depth - 1.5]} />
61
+ <meshBasicMaterial
62
+ color={highlight ? highlight.aisle : addedOnly ? '#23485a' : '#2a3340'}
63
+ />
64
+ </mesh>
65
+ {folder.width > 28 && (
66
+ <mesh
67
+ position={[0, 0.02, folder.depth / 2 - 1.3]}
68
+ rotation={[-Math.PI / 2, 0, 0]}
69
+ >
70
+ <planeGeometry args={[folder.width - 2.4, 2.4]} />
71
+ <meshBasicMaterial
72
+ color={
73
+ highlight ? highlight.aisle : addedOnly ? '#23485a' : '#2a3340'
74
+ }
75
+ />
76
+ </mesh>
77
+ )}
78
+ <Suspense fallback={null}>
79
+ {!naming && (
80
+ <Text
81
+ position={[0, 0.05, -folder.depth / 2 + 1.6]}
82
+ rotation={[-Math.PI / 2, 0, 0]}
83
+ fontSize={0.55}
84
+ color={
85
+ highlight ? highlight.color : addedOnly ? '#9ad8ff' : '#8b95a5'
86
+ }
87
+ anchorX="center"
88
+ anchorY="middle"
89
+ >
90
+ {label}
91
+ </Text>
92
+ )}
93
+ </Suspense>
94
+ </group>
95
+ )
96
+ }
@@ -0,0 +1,40 @@
1
+ import { useEffect } from 'react'
2
+ import { useThree } from '@react-three/fiber'
3
+ import { folderAt } from '../layout'
4
+ import type { WorldLayout } from '../types'
5
+
6
+ type IslandPlacerProps = {
7
+ enabled: boolean
8
+ layout: WorldLayout
9
+ onPlace: (parent: string) => void
10
+ }
11
+
12
+ function typingInField(target: EventTarget | null) {
13
+ return (
14
+ target instanceof HTMLElement &&
15
+ (target.tagName === 'TEXTAREA' ||
16
+ target.tagName === 'INPUT' ||
17
+ target.tagName === 'SELECT' ||
18
+ target.isContentEditable)
19
+ )
20
+ }
21
+
22
+ export function IslandPlacer({ enabled, layout, onPlace }: IslandPlacerProps) {
23
+ const { camera } = useThree()
24
+
25
+ useEffect(() => {
26
+ const onKey = (event: KeyboardEvent) => {
27
+ if (!enabled || event.repeat || event.code !== 'KeyB') return
28
+ if (typingInField(event.target)) return
29
+ const island = folderAt(camera.position.x, camera.position.z, layout)
30
+ if (!island) return
31
+ event.preventDefault()
32
+ onPlace(island.path)
33
+ }
34
+
35
+ window.addEventListener('keydown', onKey)
36
+ return () => window.removeEventListener('keydown', onKey)
37
+ }, [camera, enabled, layout, onPlace])
38
+
39
+ return null
40
+ }