Merge branch 'main' into nadro/adhoc/system-io-machine
This commit is contained in:
@ -344,6 +344,15 @@ export type CommandArgument<
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
}
|
||||
| {
|
||||
inputType: 'path'
|
||||
defaultValue?:
|
||||
| OutputType
|
||||
| ((
|
||||
commandBarContext: ContextFrom<typeof commandBarMachine>,
|
||||
machineContext?: ContextFrom<T>
|
||||
) => OutputType)
|
||||
}
|
||||
| {
|
||||
inputType: 'text'
|
||||
defaultValue?:
|
||||
|
||||
@ -5,10 +5,8 @@ import type { OsInfo } from '@rust/kcl-lib/bindings/OsInfo'
|
||||
import type { WebrtcStats } from '@rust/kcl-lib/bindings/WebrtcStats'
|
||||
|
||||
import type CodeManager from '@src/lang/codeManager'
|
||||
import type {
|
||||
CommandLog,
|
||||
EngineCommandManager,
|
||||
} from '@src/lang/std/engineConnection'
|
||||
import type { CommandLog } from '@src/lang/std/commandLog'
|
||||
import type { EngineCommandManager } from '@src/lang/std/engineConnection'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import type RustContext from '@src/lib/rustContext'
|
||||
import screenshot from '@src/lib/screenshot'
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { relevantFileExtensions } from '@src/lang/wasmUtils'
|
||||
import {
|
||||
FILE_EXT,
|
||||
INDEX_IDENTIFIER,
|
||||
@ -200,14 +201,20 @@ export function getNextFileName({
|
||||
entryName: string
|
||||
baseDir: string
|
||||
}) {
|
||||
// Preserve the extension in case of a relevant but foreign file
|
||||
let extension = window.electron.path.extname(entryName)
|
||||
if (!relevantFileExtensions().includes(extension.replace('.', ''))) {
|
||||
extension = FILE_EXT
|
||||
}
|
||||
|
||||
// Remove any existing index from the name before adding a new one
|
||||
let createdName = entryName.replace(FILE_EXT, '') + FILE_EXT
|
||||
let createdName = entryName.replace(extension, '') + extension
|
||||
let createdPath = window.electron.path.join(baseDir, createdName)
|
||||
let i = 1
|
||||
while (window.electron.exists(createdPath)) {
|
||||
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${FILE_EXT})?$`)
|
||||
const matchOnIndexAndExtension = new RegExp(`(-\\d+)?(${extension})?$`)
|
||||
createdName =
|
||||
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + FILE_EXT
|
||||
entryName.replace(matchOnIndexAndExtension, '') + `-${i}` + extension
|
||||
createdPath = window.electron.path.join(baseDir, createdName)
|
||||
i++
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { Stats } from 'fs'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
importFileExtensions,
|
||||
relevantFileExtensions,
|
||||
} from '@src/lang/wasmUtils'
|
||||
import type { Stats } from 'fs'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
|
||||
import { PROJECT_ENTRYPOINT } from '@src/lib/constants'
|
||||
|
||||
|
||||
@ -28,16 +28,17 @@ import type { CommandBarContext } from '@src/machines/commandBarMachine'
|
||||
import { IS_NIGHTLY_OR_DEBUG } from '@src/routes/utils'
|
||||
|
||||
interface OnSubmitProps {
|
||||
sampleName: string
|
||||
code: string
|
||||
sampleUnits?: UnitLength_type
|
||||
name: string
|
||||
content?: string
|
||||
targetPathToClone?: string
|
||||
method: 'overwrite' | 'newFile'
|
||||
source: 'kcl-samples' | 'local'
|
||||
}
|
||||
|
||||
interface KclCommandConfig {
|
||||
// TODO: find a different approach that doesn't require
|
||||
// special props for a single command
|
||||
specialPropsForSampleCommand: {
|
||||
specialPropsForLoadCommand: {
|
||||
onSubmit: (p: OnSubmitProps) => Promise<void>
|
||||
providedOptions: CommandArgumentOption<string>[]
|
||||
}
|
||||
@ -170,69 +171,108 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'open-kcl-example',
|
||||
displayName: 'Open sample',
|
||||
description: 'Imports an example KCL program into the editor.',
|
||||
name: 'load-external-model',
|
||||
displayName: 'Load external model',
|
||||
description:
|
||||
'Loads a model from an external source into the current project.',
|
||||
needsReview: true,
|
||||
icon: 'code',
|
||||
icon: 'importFile',
|
||||
reviewMessage: ({ argumentsToSubmit }) =>
|
||||
CommandBarOverwriteWarning({
|
||||
heading:
|
||||
'method' in argumentsToSubmit &&
|
||||
argumentsToSubmit.method === 'newFile'
|
||||
? 'Create a new file from sample?'
|
||||
: 'Overwrite current file with sample?',
|
||||
message:
|
||||
'method' in argumentsToSubmit &&
|
||||
argumentsToSubmit.method === 'newFile'
|
||||
? 'This will create a new file in the current project and open it.'
|
||||
: 'This will erase your current file and load the sample part.',
|
||||
}),
|
||||
argumentsToSubmit['method'] === 'overwrite'
|
||||
? CommandBarOverwriteWarning({
|
||||
heading: 'Overwrite current file with sample?',
|
||||
message:
|
||||
'This will erase your current file and load the sample part.',
|
||||
})
|
||||
: 'This will create a new file in the current project and open it.',
|
||||
groupId: 'code',
|
||||
onSubmit(data) {
|
||||
if (!data?.sample) {
|
||||
return
|
||||
if (!data) {
|
||||
return new Error('No input data')
|
||||
}
|
||||
const pathParts = data.sample.split('/')
|
||||
const projectPathPart = pathParts[0]
|
||||
const primaryKclFile = pathParts[1]
|
||||
// local only
|
||||
const sampleCodeUrl =
|
||||
(isDesktop() ? '.' : '') +
|
||||
`/kcl-samples/${encodeURIComponent(
|
||||
projectPathPart
|
||||
)}/${encodeURIComponent(primaryKclFile)}`
|
||||
|
||||
fetch(sampleCodeUrl)
|
||||
.then(async (codeResponse): Promise<OnSubmitProps> => {
|
||||
if (!codeResponse.ok) {
|
||||
console.error(
|
||||
'Failed to fetch sample code:',
|
||||
codeResponse.statusText
|
||||
)
|
||||
return Promise.reject(new Error('Failed to fetch sample code'))
|
||||
}
|
||||
const code = await codeResponse.text()
|
||||
return {
|
||||
sampleName: data.sample.split('/')[0] + FILE_EXT,
|
||||
code,
|
||||
method: data.method,
|
||||
}
|
||||
})
|
||||
.then((props) => {
|
||||
if (props?.code) {
|
||||
commandProps.specialPropsForSampleCommand
|
||||
.onSubmit(props)
|
||||
const { method, source, sample, path } = data
|
||||
if (source === 'local' && path) {
|
||||
commandProps.specialPropsForLoadCommand
|
||||
.onSubmit({
|
||||
name: '',
|
||||
targetPathToClone: path,
|
||||
method,
|
||||
source,
|
||||
})
|
||||
.catch(reportError)
|
||||
} else if (source === 'kcl-samples' && sample) {
|
||||
const pathParts = sample.split('/')
|
||||
const projectPathPart = pathParts[0]
|
||||
const primaryKclFile = pathParts[1]
|
||||
// local only
|
||||
const sampleCodeUrl =
|
||||
(isDesktop() ? '.' : '') +
|
||||
`/kcl-samples/${encodeURIComponent(
|
||||
projectPathPart
|
||||
)}/${encodeURIComponent(primaryKclFile)}`
|
||||
|
||||
fetch(sampleCodeUrl)
|
||||
.then(async (codeResponse) => {
|
||||
if (!codeResponse.ok) {
|
||||
console.error(
|
||||
'Failed to fetch sample code:',
|
||||
codeResponse.statusText
|
||||
)
|
||||
return Promise.reject(new Error('Failed to fetch sample code'))
|
||||
}
|
||||
const code = await codeResponse.text()
|
||||
commandProps.specialPropsForLoadCommand
|
||||
.onSubmit({
|
||||
name: data.sample.split('/')[0] + FILE_EXT,
|
||||
content: code,
|
||||
source,
|
||||
method,
|
||||
})
|
||||
.catch(reportError)
|
||||
}
|
||||
})
|
||||
.catch(reportError)
|
||||
})
|
||||
.catch(reportError)
|
||||
} else {
|
||||
toast.error("The command couldn't be submitted, check the arguments.")
|
||||
}
|
||||
},
|
||||
args: {
|
||||
method: {
|
||||
source: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
skip: false,
|
||||
defaultValue: 'local',
|
||||
hidden: !isDesktop(),
|
||||
options() {
|
||||
return [
|
||||
{
|
||||
value: 'kcl-samples',
|
||||
name: 'KCL Samples',
|
||||
isCurrent: true,
|
||||
},
|
||||
...(isDesktop()
|
||||
? [
|
||||
{
|
||||
value: 'local',
|
||||
name: 'Local Drive',
|
||||
isCurrent: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
},
|
||||
},
|
||||
method: {
|
||||
inputType: 'options',
|
||||
skip: true,
|
||||
required: (commandContext) =>
|
||||
!['local'].includes(
|
||||
commandContext.argumentsToSubmit.source as string
|
||||
),
|
||||
hidden: (commandContext) =>
|
||||
['local'].includes(
|
||||
commandContext.argumentsToSubmit.source as string
|
||||
),
|
||||
defaultValue: isDesktop() ? 'newFile' : 'overwrite',
|
||||
options() {
|
||||
return [
|
||||
@ -255,7 +295,14 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||
},
|
||||
sample: {
|
||||
inputType: 'options',
|
||||
required: true,
|
||||
required: (commandContext) =>
|
||||
!['local'].includes(
|
||||
commandContext.argumentsToSubmit.source as string
|
||||
),
|
||||
hidden: (commandContext) =>
|
||||
['local'].includes(
|
||||
commandContext.argumentsToSubmit.source as string
|
||||
),
|
||||
valueSummary(value) {
|
||||
const MAX_LENGTH = 12
|
||||
if (typeof value === 'string') {
|
||||
@ -265,7 +312,15 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||
}
|
||||
return value
|
||||
},
|
||||
options: commandProps.specialPropsForSampleCommand.providedOptions,
|
||||
options: commandProps.specialPropsForLoadCommand.providedOptions,
|
||||
},
|
||||
path: {
|
||||
inputType: 'path',
|
||||
valueSummary: (value) => window.electron.path.basename(value),
|
||||
required: (commandContext) =>
|
||||
['local'].includes(
|
||||
commandContext.argumentsToSubmit.source as string
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Operation, OpKclValue } from '@rust/kcl-lib/bindings/Operation'
|
||||
import type { OpKclValue, Operation } from '@rust/kcl-lib/bindings/Operation'
|
||||
|
||||
import type { CustomIconName } from '@src/components/CustomIcon'
|
||||
import { getNodePathFromSourceRange } from '@src/lang/queryAstNodePathUtils'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import type { CameraOrbitType } from '@rust/kcl-lib/bindings/CameraOrbitType'
|
||||
import type { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionType'
|
||||
@ -6,7 +6,6 @@ import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
|
||||
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'
|
||||
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import { Toggle } from '@src/components/Toggle/Toggle'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import type { CameraSystem } from '@src/lib/cameraControls'
|
||||
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
|
||||
@ -216,104 +215,37 @@ export function createSettings() {
|
||||
hideOnLevel: 'project',
|
||||
description: 'Save bandwidth & battery',
|
||||
validate: (v) =>
|
||||
v === undefined ||
|
||||
(typeof v === 'number' &&
|
||||
v >= 1 * MS_IN_MINUTE &&
|
||||
v <= 60 * MS_IN_MINUTE),
|
||||
Component: ({
|
||||
value: settingValueInStorage,
|
||||
updateValue: writeSettingValueToStorage,
|
||||
}) => {
|
||||
const [timeoutId, setTimeoutId] = useState<
|
||||
ReturnType<typeof setTimeout> | undefined
|
||||
>(undefined)
|
||||
const [preview, setPreview] = useState(
|
||||
settingValueInStorage === undefined
|
||||
? settingValueInStorage
|
||||
: settingValueInStorage / MS_IN_MINUTE
|
||||
)
|
||||
const onChangeRange = (e: React.SyntheticEvent) => {
|
||||
if (
|
||||
!(
|
||||
e.isTrusted &&
|
||||
'value' in e.currentTarget &&
|
||||
e.currentTarget.value
|
||||
)
|
||||
)
|
||||
return
|
||||
setPreview(Number(e.currentTarget.value))
|
||||
}
|
||||
const onSaveRange = (e: React.SyntheticEvent) => {
|
||||
if (preview === undefined) return
|
||||
if (
|
||||
!(
|
||||
e.isTrusted &&
|
||||
'value' in e.currentTarget &&
|
||||
e.currentTarget.value
|
||||
)
|
||||
)
|
||||
return
|
||||
writeSettingValueToStorage(
|
||||
Number(e.currentTarget.value) * MS_IN_MINUTE
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex item-center gap-4 m-0 py-0">
|
||||
<Toggle
|
||||
name="streamIdleModeToggle"
|
||||
offLabel="Off"
|
||||
onLabel="On"
|
||||
checked={settingValueInStorage !== undefined}
|
||||
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
if (timeoutId) {
|
||||
return
|
||||
}
|
||||
const isChecked = event.currentTarget.checked
|
||||
clearTimeout(timeoutId)
|
||||
setTimeoutId(
|
||||
setTimeout(() => {
|
||||
const requested = !isChecked ? undefined : 5
|
||||
setPreview(requested)
|
||||
writeSettingValueToStorage(
|
||||
requested === undefined
|
||||
? undefined
|
||||
: Number(requested) * MS_IN_MINUTE
|
||||
)
|
||||
setTimeoutId(undefined)
|
||||
}, 100)
|
||||
)
|
||||
}}
|
||||
className="block w-4 h-4"
|
||||
/>
|
||||
<div className="flex flex-col grow">
|
||||
<input
|
||||
type="range"
|
||||
onChange={onChangeRange}
|
||||
onMouseUp={onSaveRange}
|
||||
onKeyUp={onSaveRange}
|
||||
onPointerUp={onSaveRange}
|
||||
disabled={preview === undefined}
|
||||
value={
|
||||
preview !== null && preview !== undefined ? preview : 5
|
||||
}
|
||||
min={1}
|
||||
max={60}
|
||||
step={1}
|
||||
className="block flex-1"
|
||||
/>
|
||||
{preview !== undefined && preview !== null && (
|
||||
<div>
|
||||
{preview / MS_IN_MINUTE === 60
|
||||
? '1 hour'
|
||||
: preview / MS_IN_MINUTE === 1
|
||||
? '1 minute'
|
||||
: preview + ' minutes'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
String(v) == 'undefined' ||
|
||||
(Number(v) >= 0 && Number(v) <= 60 * MS_IN_MINUTE),
|
||||
commandConfig: {
|
||||
inputType: 'options',
|
||||
defaultValueFromContext: (context) =>
|
||||
context.app.streamIdleMode.current,
|
||||
options: (cmdContext, settingsContext) =>
|
||||
[
|
||||
undefined,
|
||||
5 * 1000,
|
||||
30 * 1000,
|
||||
1 * MS_IN_MINUTE,
|
||||
2 * MS_IN_MINUTE,
|
||||
5 * MS_IN_MINUTE,
|
||||
15 * MS_IN_MINUTE,
|
||||
30 * MS_IN_MINUTE,
|
||||
60 * MS_IN_MINUTE,
|
||||
].map((v) => ({
|
||||
name:
|
||||
v === undefined
|
||||
? 'Off'
|
||||
: v < MS_IN_MINUTE
|
||||
? `${Math.floor(v / 1000)} seconds`
|
||||
: `${Math.floor(v / MS_IN_MINUTE)} minutes`,
|
||||
value: v,
|
||||
isCurrent:
|
||||
v ===
|
||||
settingsContext.app.streamIdleMode[
|
||||
cmdContext.argumentsToSubmit.level as SettingsLevel
|
||||
],
|
||||
})),
|
||||
},
|
||||
}),
|
||||
allowOrbitInSketchMode: new Setting<boolean>({
|
||||
|
||||
@ -44,7 +44,12 @@ export const kclManager = new KclManager(engineCommandManager, {
|
||||
// CYCLIC REF
|
||||
editorManager.kclManager = kclManager
|
||||
|
||||
// These are all late binding because of their circular dependency.
|
||||
// TODO: proper dependency injection.
|
||||
engineCommandManager.kclManager = kclManager
|
||||
engineCommandManager.codeManager = codeManager
|
||||
engineCommandManager.rustContext = rustContext
|
||||
|
||||
kclManager.sceneInfraBaseUnitMultiplierSetter = (unit: BaseUnit) => {
|
||||
sceneInfra.baseUnit = unit
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import type { EventFrom, StateFrom } from 'xstate'
|
||||
|
||||
import type { CustomIconName } from '@src/components/CustomIcon'
|
||||
import { createLiteral } from '@src/lang/create'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { commandBarActor } from '@src/machines/commandBarMachine'
|
||||
import type { modelingMachine } from '@src/machines/modelingMachine'
|
||||
import {
|
||||
@ -337,6 +338,50 @@ export const toolbarConfig: Record<ToolbarModeName, ToolbarMode> = {
|
||||
links: [{ label: 'KCL docs', url: 'https://zoo.dev/docs/kcl/helix' }],
|
||||
},
|
||||
'break',
|
||||
{
|
||||
id: 'modules',
|
||||
array: [
|
||||
{
|
||||
id: 'insert',
|
||||
onClick: () =>
|
||||
commandBarActor.send({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'Insert', groupId: 'code' },
|
||||
}),
|
||||
hotkey: 'I',
|
||||
icon: 'import',
|
||||
status: DEV || IS_NIGHTLY_OR_DEBUG ? 'available' : 'kcl-only',
|
||||
disabled: () => !isDesktop(),
|
||||
title: 'Insert',
|
||||
description: 'Insert from a file in the current project directory',
|
||||
links: [
|
||||
{
|
||||
label: 'API docs',
|
||||
url: 'https://zoo.dev/docs/kcl/import',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'transform',
|
||||
icon: 'angle',
|
||||
status: 'kcl-only',
|
||||
title: 'Transform',
|
||||
description: 'Apply a translation and/or rotation to a module',
|
||||
onClick: () => undefined,
|
||||
links: [
|
||||
{
|
||||
label: 'API docs',
|
||||
url: 'https://zoo.dev/docs/kcl/translate',
|
||||
},
|
||||
{
|
||||
label: 'API docs',
|
||||
url: 'https://zoo.dev/docs/kcl/rotate',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'break',
|
||||
{
|
||||
id: 'ai',
|
||||
array: [
|
||||
|
||||
@ -23,6 +23,10 @@ export function isArray(val: any): val is unknown[] {
|
||||
return Array.isArray(val)
|
||||
}
|
||||
|
||||
export type SafeArray<T> = Omit<Array<T>, number> & {
|
||||
[index: number]: T | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* An alternative to `Object.keys()` that returns an array of keys with types.
|
||||
*
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Coords2d } from '@src/lang/std/sketch'
|
||||
import { isPointsCCW } from '@src/lang/wasm'
|
||||
import { initPromise } from '@src/lang/wasmUtils'
|
||||
import { closestPointOnRay } from '@src/lib/utils2d'
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPromise
|
||||
@ -20,3 +21,72 @@ describe('test isPointsCW', () => {
|
||||
expect(CW).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('test closestPointOnRay', () => {
|
||||
test('point lies on ray', () => {
|
||||
const rayOrigin: Coords2d = [0, 0]
|
||||
const rayDirection: Coords2d = [1, 0]
|
||||
const pointToCheck: Coords2d = [7, 0]
|
||||
|
||||
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
|
||||
expect(result.closestPoint).toEqual([7, 0])
|
||||
expect(result.t).toBe(7)
|
||||
})
|
||||
|
||||
test('point is above ray', () => {
|
||||
const rayOrigin: Coords2d = [1, 0]
|
||||
const rayDirection: Coords2d = [1, 0]
|
||||
const pointToCheck: Coords2d = [7, 7]
|
||||
|
||||
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
|
||||
expect(result.closestPoint).toEqual([7, 0])
|
||||
expect(result.t).toBe(6)
|
||||
})
|
||||
|
||||
test('point lies behind ray origin and allowNegative=false', () => {
|
||||
const rayOrigin: Coords2d = [0, 0]
|
||||
const rayDirection: Coords2d = [1, 0]
|
||||
const pointToCheck: Coords2d = [-7, 7]
|
||||
|
||||
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
|
||||
expect(result.closestPoint).toEqual([0, 0])
|
||||
expect(result.t).toBe(0)
|
||||
})
|
||||
|
||||
test('point lies behind ray origin and allowNegative=true', () => {
|
||||
const rayOrigin: Coords2d = [0, 0]
|
||||
const rayDirection: Coords2d = [1, 0]
|
||||
const pointToCheck: Coords2d = [-7, 7]
|
||||
|
||||
const result = closestPointOnRay(
|
||||
rayOrigin,
|
||||
rayDirection,
|
||||
pointToCheck,
|
||||
true
|
||||
)
|
||||
expect(result.closestPoint).toEqual([-7, 0])
|
||||
expect(result.t).toBe(-7)
|
||||
})
|
||||
|
||||
test('diagonal ray and point', () => {
|
||||
const rayOrigin: Coords2d = [0, 0]
|
||||
const rayDirection: Coords2d = [1, 1]
|
||||
const pointToCheck: Coords2d = [3, 4]
|
||||
|
||||
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
|
||||
expect(result.closestPoint[0]).toBeCloseTo(3.5)
|
||||
expect(result.closestPoint[1]).toBeCloseTo(3.5)
|
||||
expect(result.t).toBeCloseTo(4.95, 1)
|
||||
})
|
||||
|
||||
test('non-normalized direction vector', () => {
|
||||
const rayOrigin: Coords2d = [0, 0]
|
||||
const rayDirection: Coords2d = [2, 2]
|
||||
const pointToCheck: Coords2d = [3, 4]
|
||||
|
||||
const result = closestPointOnRay(rayOrigin, rayDirection, pointToCheck)
|
||||
expect(result.closestPoint[0]).toBeCloseTo(3.5)
|
||||
expect(result.closestPoint[1]).toBeCloseTo(3.5)
|
||||
expect(result.t).toBeCloseTo(4.95, 1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -17,3 +17,38 @@ export function getTangentPointFromPreviousArc(
|
||||
Math.sin(deg2Rad(tangentialAngle)) * 10 + lastArcEnd[1],
|
||||
]
|
||||
}
|
||||
|
||||
export function closestPointOnRay(
|
||||
rayOrigin: Coords2d,
|
||||
rayDirection: Coords2d,
|
||||
pointToCheck: Coords2d,
|
||||
allowNegative = false
|
||||
) {
|
||||
const dirMagnitude = Math.sqrt(
|
||||
rayDirection[0] * rayDirection[0] + rayDirection[1] * rayDirection[1]
|
||||
)
|
||||
const normalizedDir: Coords2d = [
|
||||
rayDirection[0] / dirMagnitude,
|
||||
rayDirection[1] / dirMagnitude,
|
||||
]
|
||||
|
||||
const originToPoint: Coords2d = [
|
||||
pointToCheck[0] - rayOrigin[0],
|
||||
pointToCheck[1] - rayOrigin[1],
|
||||
]
|
||||
|
||||
let t =
|
||||
originToPoint[0] * normalizedDir[0] + originToPoint[1] * normalizedDir[1]
|
||||
|
||||
if (!allowNegative) {
|
||||
t = Math.max(0, t)
|
||||
}
|
||||
|
||||
return {
|
||||
closestPoint: [
|
||||
rayOrigin[0] + normalizedDir[0] * t,
|
||||
rayOrigin[1] + normalizedDir[1] * t,
|
||||
] as Coords2d,
|
||||
t,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user