@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,508 @@
1
+ import { CONFIG, fileHeight } from './theme'
2
+ import type {
3
+ CodebaseGraph,
4
+ FileNode,
5
+ FolderNode,
6
+ PatchImport,
7
+ PlacedBridge,
8
+ PlacedFile,
9
+ PlacedFolder,
10
+ WorldLayout,
11
+ } from './types'
12
+
13
+ function indexGraph(graph: CodebaseGraph) {
14
+ const folders = new Map(graph.folders.map((folder) => [folder.path, folder]))
15
+ const files = new Map(graph.files.map((file) => [file.id, file]))
16
+ return { folders, files }
17
+ }
18
+
19
+ function contentWidth() {
20
+ const fileOffset = CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7
21
+ const fileOuter = fileOffset + CONFIG.fileWidth / 2
22
+ return (fileOuter + 1.1) * 2
23
+ }
24
+
25
+ function dockPitch() {
26
+ return CONFIG.bridgeWidth + CONFIG.bridgeDockGap
27
+ }
28
+
29
+ function subtreeWidth(
30
+ folder: FolderNode,
31
+ folders: Map<string, FolderNode>,
32
+ ): number {
33
+ return Math.max(contentWidth(), childrenSpan(folder, folders), docksWidth(folder))
34
+ }
35
+
36
+ function childrenSpan(
37
+ folder: FolderNode,
38
+ folders: Map<string, FolderNode>,
39
+ ): number {
40
+ if (folder.children.length === 0) return 0
41
+
42
+ return folder.children.reduce((total, childPath, index) => {
43
+ const child = folders.get(childPath)
44
+ if (!child) return total
45
+ const gap = index === 0 ? 0 : CONFIG.siblingGap
46
+ return total + gap + subtreeWidth(child, folders)
47
+ }, 0)
48
+ }
49
+
50
+ function docksWidth(folder: FolderNode) {
51
+ if (folder.children.length === 0) return 0
52
+ const edge = CONFIG.bridgeWidth / 2 + 1.4
53
+ return (folder.children.length - 1) * dockPitch() + CONFIG.bridgeWidth + edge * 2
54
+ }
55
+
56
+ function areaDepthForCount(fileCount: number) {
57
+ const rows = Math.max(1, Math.ceil(fileCount / 2))
58
+ return CONFIG.areaPadding * 2 + rows * CONFIG.fileSpacing
59
+ }
60
+
61
+ function areaDepth(folder: FolderNode) {
62
+ return areaDepthForCount(folder.files.length)
63
+ }
64
+
65
+ function placeFolder(
66
+ folder: FolderNode,
67
+ originX: number,
68
+ originZ: number,
69
+ graph: ReturnType<typeof indexGraph>,
70
+ files: Record<string, PlacedFile>,
71
+ folders: Record<string, PlacedFolder>,
72
+ bridges: PlacedBridge[],
73
+ ) {
74
+ const depth = areaDepth(folder)
75
+ const childSpans = folder.children.map((childPath) => {
76
+ const child = graph.folders.get(childPath)
77
+ return child ? subtreeWidth(child, graph.folders) : 0
78
+ })
79
+ const span = childrenSpan(folder, graph.folders)
80
+ const width = Math.max(contentWidth(), span, docksWidth(folder))
81
+
82
+ folders[folder.path] = {
83
+ path: folder.path,
84
+ name: folder.name,
85
+ x: originX,
86
+ z: originZ,
87
+ width,
88
+ depth,
89
+ }
90
+
91
+ folder.files.forEach((fileId, index) => {
92
+ const file = graph.files.get(fileId)
93
+ if (!file) return
94
+ const height = fileHeight(file.lines)
95
+ const side: 1 | -1 = index % 2 === 0 ? -1 : 1
96
+ const row = Math.floor(index / 2)
97
+ const z = originZ + CONFIG.areaPadding + row * CONFIG.fileSpacing
98
+ const x =
99
+ originX + side * (CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7)
100
+
101
+ files[fileId] = {
102
+ id: fileId,
103
+ position: [x, height / 2, z],
104
+ size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
105
+ aisleFace: side === -1 ? 1 : -1,
106
+ }
107
+ })
108
+
109
+ if (folder.children.length === 0) return
110
+
111
+ let cursorX = originX - span / 2
112
+ const parentExitZ = originZ + depth
113
+ const childStartZ = parentExitZ + CONFIG.bridgeLength
114
+
115
+ folder.children.forEach((childPath, index) => {
116
+ const child = graph.folders.get(childPath)
117
+ if (!child) return
118
+ const childSpan = childSpans[index]
119
+ const childX = cursorX + childSpan / 2
120
+ cursorX += childSpan + CONFIG.siblingGap
121
+
122
+ bridges.push({
123
+ id: `${folder.path}→${child.path}`,
124
+ label: child.name,
125
+ fromLabel: folder.name,
126
+ points: [
127
+ [childX, parentExitZ - CONFIG.bridgeOverlap],
128
+ [childX, childStartZ + CONFIG.bridgeOverlap],
129
+ ],
130
+ })
131
+
132
+ placeFolder(child, childX, childStartZ, graph, files, folders, bridges)
133
+ })
134
+ }
135
+
136
+ export function standInFront(file: PlacedFile): [number, number] {
137
+ return [
138
+ file.position[0] + file.aisleFace * (file.size[0] / 2 + 2.6),
139
+ file.position[2],
140
+ ]
141
+ }
142
+
143
+ export function relationTravelTarget(
144
+ fromId: string,
145
+ toId: string,
146
+ x: number,
147
+ z: number,
148
+ files: Record<string, PlacedFile>,
149
+ ): string {
150
+ const from = files[fromId]
151
+ const to = files[toId]
152
+ if (!from) return toId
153
+ if (!to) return fromId
154
+ const distFrom = Math.hypot(x - from.position[0], z - from.position[2])
155
+ const distTo = Math.hypot(x - to.position[0], z - to.position[2])
156
+ return distFrom <= distTo ? toId : fromId
157
+ }
158
+
159
+ export function filesImporting(files: FileNode[], id: string) {
160
+ return files.filter((file) => file.imports.includes(id))
161
+ }
162
+
163
+ export function folderOfFile(fileId: string) {
164
+ return fileId.includes('/') ? fileId.split('/').slice(0, -1).join('/') : '.'
165
+ }
166
+
167
+ export function folderParent(folderPath: string) {
168
+ if (!folderPath || folderPath === '.') return null
169
+ return folderPath.includes('/') ? folderPath.split('/').slice(0, -1).join('/') : '.'
170
+ }
171
+
172
+ function folderName(folderPath: string, rootName: string) {
173
+ if (!folderPath || folderPath === '.') return rootName
174
+ return folderPath.split('/').pop() ?? folderPath
175
+ }
176
+
177
+ function existingAncestor(
178
+ folderPath: string,
179
+ folders: Record<string, PlacedFolder>,
180
+ ) {
181
+ let current = folderPath
182
+ while (current && current !== '.') {
183
+ if (folders[current]) return current
184
+ current = current.includes('/')
185
+ ? current.split('/').slice(0, -1).join('/')
186
+ : '.'
187
+ }
188
+ if (folders['.']) return '.'
189
+ return Object.keys(folders)[0] ?? '.'
190
+ }
191
+
192
+ export function collectCreateFolders(
193
+ creates: string[],
194
+ existingFolders: Iterable<string>,
195
+ ) {
196
+ const known = new Set(existingFolders)
197
+ const created = new Set<string>()
198
+ for (const id of creates) {
199
+ let current = folderOfFile(id)
200
+ while (current && current !== '.') {
201
+ if (!known.has(current)) created.add(current)
202
+ current = folderParent(current) ?? '.'
203
+ }
204
+ }
205
+ return [...created].sort(
206
+ (left, right) =>
207
+ left.split('/').filter(Boolean).length - right.split('/').filter(Boolean).length ||
208
+ left.localeCompare(right),
209
+ )
210
+ }
211
+
212
+ function placeFileOnFolder(
213
+ id: string,
214
+ folder: PlacedFolder,
215
+ index: number,
216
+ lines: number,
217
+ ): PlacedFile {
218
+ const height = fileHeight(lines)
219
+ const side: 1 | -1 = index % 2 === 0 ? -1 : 1
220
+ const row = Math.floor(index / 2)
221
+ return {
222
+ id,
223
+ position: [
224
+ folder.x + side * (CONFIG.aisleWidth / 2 + CONFIG.fileWidth / 2 + 0.7),
225
+ height / 2,
226
+ folder.z + CONFIG.areaPadding + row * CONFIG.fileSpacing,
227
+ ],
228
+ size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
229
+ aisleFace: side === -1 ? 1 : -1,
230
+ }
231
+ }
232
+
233
+ function ensurePreviewFolder(
234
+ folders: Map<string, FolderNode>,
235
+ folderPath: string,
236
+ rootName: string,
237
+ ) {
238
+ if (!folderPath || folders.has(folderPath)) return
239
+ const parent = folderParent(folderPath)
240
+ if (parent) ensurePreviewFolder(folders, parent, rootName)
241
+ folders.set(folderPath, {
242
+ path: folderPath,
243
+ name: folderName(folderPath, rootName),
244
+ parent,
245
+ files: [],
246
+ children: [],
247
+ })
248
+ if (parent) {
249
+ const parentFolder = folders.get(parent)
250
+ if (parentFolder && !parentFolder.children.includes(folderPath)) {
251
+ parentFolder.children.push(folderPath)
252
+ parentFolder.children.sort((left, right) => left.localeCompare(right))
253
+ }
254
+ }
255
+ }
256
+
257
+ export function withPreviewGraph(
258
+ graph: CodebaseGraph,
259
+ creates: string[],
260
+ createLines: Record<string, number> = {},
261
+ createFolders: string[] = [],
262
+ imports: PatchImport[] = [],
263
+ ): CodebaseGraph {
264
+ const files = new Map(
265
+ graph.files.map((file) => [file.id, { ...file, imports: [...file.imports] }]),
266
+ )
267
+ const folders = new Map(
268
+ graph.folders.map((folder) => [
269
+ folder.path,
270
+ { ...folder, files: [...folder.files], children: [...folder.children] },
271
+ ]),
272
+ )
273
+
274
+ for (const folderPath of createFolders) {
275
+ ensurePreviewFolder(folders, folderPath, graph.targetName)
276
+ }
277
+
278
+ for (const id of creates) {
279
+ const folderPath = folderOfFile(id)
280
+ ensurePreviewFolder(folders, folderPath, graph.targetName)
281
+ if (!files.has(id)) {
282
+ files.set(id, {
283
+ id,
284
+ name: id.split('/').pop() ?? id,
285
+ path: id,
286
+ folder: folderPath,
287
+ lines: createLines[id] ?? 12,
288
+ language: id.split('.').pop()?.toLowerCase() ?? 'txt',
289
+ symbols: [],
290
+ imports: imports.filter((edge) => edge.from === id).map((edge) => edge.to),
291
+ })
292
+ }
293
+ const folder = folders.get(folderPath)
294
+ if (folder && !folder.files.includes(id)) {
295
+ folder.files.push(id)
296
+ folder.files.sort((left, right) => left.localeCompare(right))
297
+ }
298
+ }
299
+
300
+ return {
301
+ ...graph,
302
+ files: [...files.values()],
303
+ folders: [...folders.values()],
304
+ }
305
+ }
306
+
307
+ export function markCreatedFolders(layout: WorldLayout, createFolders: string[]) {
308
+ for (const path of createFolders) {
309
+ const folder = layout.folders[path]
310
+ if (folder) layout.folders[path] = { ...folder, added: true }
311
+ }
312
+ return layout
313
+ }
314
+
315
+ export type PreviewOverlay = {
316
+ files: Record<string, PlacedFile>
317
+ folders: Record<string, PlacedFolder>
318
+ bridges: PlacedBridge[]
319
+ }
320
+
321
+ export function placePreviewCreates(
322
+ graph: CodebaseGraph,
323
+ layout: WorldLayout,
324
+ creates: string[],
325
+ createLines: Record<string, number> = {},
326
+ ): PreviewOverlay {
327
+ const extraFiles: Record<string, PlacedFile> = {}
328
+ const extraFolders: Record<string, PlacedFolder> = {}
329
+ const extraBridges: PlacedBridge[] = []
330
+ const allFolders = () => ({ ...layout.folders, ...extraFolders })
331
+ const newFolders = collectCreateFolders(creates, Object.keys(layout.folders))
332
+ const filesByFolder = new Map<string, string[]>()
333
+
334
+ for (const id of creates) {
335
+ const folderPath = folderOfFile(id)
336
+ const list = filesByFolder.get(folderPath) ?? []
337
+ list.push(id)
338
+ filesByFolder.set(folderPath, list)
339
+ }
340
+
341
+ for (const folderPath of newFolders) {
342
+ const parentPath = existingAncestor(folderParent(folderPath) ?? '.', allFolders())
343
+ const parent = allFolders()[parentPath]
344
+ if (!parent) continue
345
+
346
+ const fileCount = filesByFolder.get(folderPath)?.length ?? 0
347
+ const width = contentWidth()
348
+ const depth = areaDepthForCount(fileCount)
349
+ const siblings = Object.values(allFolders()).filter(
350
+ (folder) => folderParent(folder.path) === parentPath,
351
+ )
352
+ const x =
353
+ siblings.length === 0
354
+ ? parent.x
355
+ : Math.max(...siblings.map((folder) => folder.x + folder.width / 2)) +
356
+ CONFIG.siblingGap +
357
+ width / 2
358
+ const z = parent.z + parent.depth + CONFIG.bridgeLength
359
+
360
+ extraFolders[folderPath] = {
361
+ path: folderPath,
362
+ name: folderName(folderPath, graph.targetName),
363
+ x,
364
+ z,
365
+ width,
366
+ depth,
367
+ added: true,
368
+ }
369
+ extraBridges.push({
370
+ id: `${parentPath}→${folderPath}`,
371
+ label: folderName(folderPath, graph.targetName),
372
+ fromLabel: parent.name,
373
+ points: [
374
+ [x, parent.z + parent.depth - CONFIG.bridgeOverlap],
375
+ [x, z + CONFIG.bridgeOverlap],
376
+ ],
377
+ })
378
+ }
379
+
380
+ const used = new Map(
381
+ graph.folders.map((folder) => [folder.path, folder.files.length]),
382
+ )
383
+ for (const path of Object.keys(extraFolders)) used.set(path, 0)
384
+
385
+ for (const id of creates) {
386
+ if (layout.files[id] || extraFiles[id]) continue
387
+ const folderPath = folderOfFile(id)
388
+ const folders = allFolders()
389
+ const folder = folders[folderPath] ?? folders[existingAncestor(folderPath, folders)]
390
+ if (!folder) continue
391
+ const index = used.get(folder.path) ?? 0
392
+ used.set(folder.path, index + 1)
393
+ extraFiles[id] = placeFileOnFolder(id, folder, index, createLines[id] ?? 12)
394
+ }
395
+
396
+ return { files: extraFiles, folders: extraFolders, bridges: extraBridges }
397
+ }
398
+
399
+ export function withPreviewLayout(
400
+ layout: WorldLayout,
401
+ overlay: PreviewOverlay,
402
+ ): WorldLayout {
403
+ return {
404
+ ...layout,
405
+ files: { ...layout.files, ...overlay.files },
406
+ folders: { ...layout.folders, ...overlay.folders },
407
+ bridges: [...layout.bridges, ...overlay.bridges],
408
+ }
409
+ }
410
+
411
+ export function layoutWorld(graph: CodebaseGraph): WorldLayout {
412
+ const indexed = indexGraph(graph)
413
+ const root = indexed.folders.get(graph.root)
414
+ if (!root) {
415
+ throw new Error('Codebase graph is missing the root folder')
416
+ }
417
+
418
+ const files: Record<string, PlacedFile> = {}
419
+ const folders: Record<string, PlacedFolder> = {}
420
+ const bridges: PlacedBridge[] = []
421
+
422
+ placeFolder(root, 0, 0, indexed, files, folders, bridges)
423
+
424
+ const rootFolder = folders[root.path]
425
+ return {
426
+ files,
427
+ folders,
428
+ bridges,
429
+ spawn: [rootFolder.x, CONFIG.eyeHeight, rootFolder.z + 2.4],
430
+ }
431
+ }
432
+
433
+ export function folderAt(
434
+ x: number,
435
+ z: number,
436
+ layout: WorldLayout,
437
+ ): PlacedFolder | null {
438
+ let current: PlacedFolder | null = null
439
+ for (const folder of Object.values(layout.folders)) {
440
+ const insideX = Math.abs(x - folder.x) <= folder.width / 2
441
+ const insideZ = z >= folder.z && z <= folder.z + folder.depth
442
+ if (insideX && insideZ) current = folder
443
+ }
444
+ return current
445
+ }
446
+
447
+ function distanceToSegment(
448
+ px: number,
449
+ pz: number,
450
+ ax: number,
451
+ az: number,
452
+ bx: number,
453
+ bz: number,
454
+ ) {
455
+ const dx = bx - ax
456
+ const dz = bz - az
457
+ const lengthSq = dx * dx + dz * dz
458
+ if (lengthSq === 0) return Math.hypot(px - ax, pz - az)
459
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSq))
460
+ return Math.hypot(px - (ax + t * dx), pz - (az + t * dz))
461
+ }
462
+
463
+ export function locationLabel(x: number, z: number, layout: WorldLayout) {
464
+ const folder = folderAt(x, z, layout)
465
+ if (folder) return folder.name
466
+
467
+ for (const bridge of layout.bridges) {
468
+ for (let i = 1; i < bridge.points.length; i += 1) {
469
+ const from = bridge.points[i - 1]
470
+ const to = bridge.points[i]
471
+ const distance = distanceToSegment(x, z, from[0], from[1], to[0], to[1])
472
+ if (distance <= CONFIG.bridgeWidth / 2 + 0.5) {
473
+ return `bridge to ${bridge.label}`
474
+ }
475
+ }
476
+ }
477
+
478
+ return 'open ground'
479
+ }
480
+
481
+ export function worldBounds(layout: WorldLayout) {
482
+ let minX = Infinity
483
+ let maxX = -Infinity
484
+ let minZ = Infinity
485
+ let maxZ = -Infinity
486
+
487
+ for (const folder of Object.values(layout.folders)) {
488
+ minX = Math.min(minX, folder.x - folder.width / 2)
489
+ maxX = Math.max(maxX, folder.x + folder.width / 2)
490
+ minZ = Math.min(minZ, folder.z)
491
+ maxZ = Math.max(maxZ, folder.z + folder.depth)
492
+ }
493
+
494
+ if (!Number.isFinite(minX)) {
495
+ return { minX: -20, maxX: 20, minZ: -20, maxZ: 20, cx: 0, cz: 0, width: 40, depth: 40 }
496
+ }
497
+
498
+ return {
499
+ minX,
500
+ maxX,
501
+ minZ,
502
+ maxZ,
503
+ cx: (minX + maxX) / 2,
504
+ cz: (minZ + maxZ) / 2,
505
+ width: maxX - minX,
506
+ depth: maxZ - minZ,
507
+ }
508
+ }
@@ -0,0 +1,16 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import App from './App'
4
+ import './index.css'
5
+
6
+ const rootElement = document.getElementById('root')
7
+
8
+ if (!rootElement) {
9
+ throw new Error('Root element #root was not found')
10
+ }
11
+
12
+ createRoot(rootElement).render(
13
+ <StrictMode>
14
+ <App />
15
+ </StrictMode>,
16
+ )
@@ -0,0 +1,87 @@
1
+ import { useEffect } from 'react'
2
+ import { useThree } from '@react-three/fiber'
3
+ import * as THREE from 'three'
4
+ import { folderAt } from '../layout'
5
+ import type { WorldLayout } from '../types'
6
+
7
+ type BlockPlacerProps = {
8
+ enabled: boolean
9
+ layout: WorldLayout
10
+ onPlace: (spot: { x: number; z: number; folder: string }) => void
11
+ }
12
+
13
+ const ndc = new THREE.Vector2(0, 0)
14
+ const raycaster = new THREE.Raycaster()
15
+ const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
16
+ const hit = new THREE.Vector3()
17
+ const forward = new THREE.Vector3()
18
+
19
+ const PLACE_MIN = 3
20
+ const PLACE_MAX = 22
21
+
22
+ function typingInField(target: EventTarget | null) {
23
+ return (
24
+ target instanceof HTMLElement &&
25
+ (target.tagName === 'TEXTAREA' ||
26
+ target.tagName === 'INPUT' ||
27
+ target.tagName === 'SELECT' ||
28
+ target.isContentEditable)
29
+ )
30
+ }
31
+
32
+ function lookPoint(camera: THREE.Camera): { x: number; z: number } {
33
+ raycaster.setFromCamera(ndc, camera)
34
+ const reached = raycaster.ray.intersectPlane(ground, hit)
35
+ camera.getWorldDirection(forward)
36
+ forward.y = 0
37
+ if (forward.lengthSq() === 0) forward.set(0, 0, 1)
38
+ else forward.normalize()
39
+
40
+ let x: number
41
+ let z: number
42
+ if (reached) {
43
+ x = hit.x
44
+ z = hit.z
45
+ } else {
46
+ x = camera.position.x + forward.x * 8
47
+ z = camera.position.z + forward.z * 8
48
+ }
49
+
50
+ const dx = x - camera.position.x
51
+ const dz = z - camera.position.z
52
+ const distance = Math.hypot(dx, dz)
53
+ if (distance < 0.001) {
54
+ return {
55
+ x: camera.position.x + forward.x * PLACE_MIN,
56
+ z: camera.position.z + forward.z * PLACE_MIN,
57
+ }
58
+ }
59
+ const clamped = Math.min(PLACE_MAX, Math.max(PLACE_MIN, distance))
60
+ const scale = clamped / distance
61
+ return {
62
+ x: camera.position.x + dx * scale,
63
+ z: camera.position.z + dz * scale,
64
+ }
65
+ }
66
+
67
+ export function BlockPlacer({ enabled, layout, onPlace }: BlockPlacerProps) {
68
+ const { camera } = useThree()
69
+
70
+ useEffect(() => {
71
+ const onKey = (event: KeyboardEvent) => {
72
+ if (!enabled || event.repeat || event.code !== 'Space') return
73
+ if (typingInField(event.target)) return
74
+ event.preventDefault()
75
+ const { x, z } = lookPoint(camera)
76
+ const island =
77
+ folderAt(x, z, layout) ??
78
+ folderAt(camera.position.x, camera.position.z, layout)
79
+ onPlace({ x, z, folder: island?.path ?? '.' })
80
+ }
81
+
82
+ window.addEventListener('keydown', onKey)
83
+ return () => window.removeEventListener('keydown', onKey)
84
+ }, [camera, enabled, layout, onPlace])
85
+
86
+ return null
87
+ }